commit
5009726d66
93 files changed
+1613
-423
No files matched your search
@@ -78,6 +78,7 @@ type IAdminV1 interface {
|
||||
AdminWalletSetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (res *v1.AdminWalletSetWarningThresholdRes, err error)
|
||||
AdminUsageLogExport(ctx context.Context, req *v1.AdminUsageLogExportReq) (res *v1.AdminUsageLogExportRes, err error)
|
||||
AdminBillingRecordExport(ctx context.Context, req *v1.AdminBillingRecordExportReq) (res *v1.AdminBillingRecordExportRes, err error)
|
||||
AdminTransactionList(ctx context.Context, req *v1.AdminTransactionListReq) (res *v1.AdminTransactionListRes, err error)
|
||||
DataGovernanceSettingsGet(ctx context.Context, req *v1.DataGovernanceSettingsGetReq) (res *v1.DataGovernanceSettingsGetRes, err error)
|
||||
DataGovernanceSettingsUpdate(ctx context.Context, req *v1.DataGovernanceSettingsUpdateReq) (res *v1.DataGovernanceSettingsUpdateRes, err error)
|
||||
DataGovernanceExport(ctx context.Context, req *v1.DataGovernanceExportReq) (res *v1.DataGovernanceExportRes, err error)
|
||||
|
||||
@@ -323,3 +323,39 @@ type AdminBillingRecordExportReq struct {
|
||||
}
|
||||
|
||||
type AdminBillingRecordExportRes struct{}
|
||||
|
||||
// === 交易流水 ===
|
||||
|
||||
type AdminTransactionListReq struct {
|
||||
g.Meta `path:"/transactions" method:"get" mime:"json" tags:"管理后台-交易" summary:"交易流水列表"`
|
||||
Page int `json:"page" d:"1" dc:"页码"`
|
||||
PageSize int `json:"page_size" d:"20" v:"between:1,100" dc:"每页数量"`
|
||||
TenantID int64 `json:"tenant_id" dc:"租户ID"`
|
||||
Type string `json:"type" dc:"交易类型:consume/recharge/adjust"`
|
||||
Username string `json:"username" dc:"用户名(模糊匹配)"`
|
||||
ModelName string `json:"model_name" dc:"模型名称(模糊匹配)"`
|
||||
StartDate string `json:"start_date" dc:"开始日期"`
|
||||
EndDate string `json:"end_date" dc:"结束日期"`
|
||||
}
|
||||
|
||||
type AdminTransactionItem struct {
|
||||
Id int64 `json:"id"`
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
Description string `json:"description"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
RequestId string `json:"request_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
type AdminTransactionListRes struct {
|
||||
List []*AdminTransactionItem `json:"list"`
|
||||
Total int `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
@@ -153,6 +153,7 @@ type ITenantV1 interface {
|
||||
TenantLoginHistory(ctx context.Context, req *v1.TenantLoginHistoryReq) (res *v1.TenantLoginHistoryRes, err error)
|
||||
TenantTaskList(ctx context.Context, req *v1.TenantTaskListReq) (res *v1.TenantTaskListRes, err error)
|
||||
TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (res *v1.TenantTaskDetailRes, err error)
|
||||
TenantTaskExport(ctx context.Context, req *v1.TenantTaskExportReq) (res *v1.TenantTaskExportRes, err error)
|
||||
TenantTicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (res *v1.TenantTicketCreateRes, err error)
|
||||
TenantTicketList(ctx context.Context, req *v1.TenantTicketListReq) (res *v1.TenantTicketListRes, err error)
|
||||
TenantTicketGet(ctx context.Context, req *v1.TenantTicketGetReq) (res *v1.TenantTicketGetRes, err error)
|
||||
|
||||
+17
-15
@@ -4,15 +4,16 @@ import "github.com/gogf/gf/v2/frame/g"
|
||||
|
||||
// TenantRegisterReq 租户注册请求
|
||||
type TenantRegisterReq struct {
|
||||
g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
|
||||
Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"`
|
||||
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
|
||||
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"`
|
||||
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"`
|
||||
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
|
||||
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(邮箱验证关闭时必填)"`
|
||||
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(邮箱验证关闭时必填)"`
|
||||
g.Meta `path:"/auth/register" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户注册" group:"public" middleware:"-"`
|
||||
Email string `json:"email" v:"required|email#请输入邮箱|邮箱格式不正确" dc:"邮箱"`
|
||||
Code string `json:"code" dc:"邮箱验证码(邮箱验证开启时必填)"`
|
||||
Password string `json:"password" v:"required|length:8,64#请输入密码|密码长度为8-64位" dc:"密码"`
|
||||
TenantName string `json:"tenant_name" v:"required|length:2,100#请输入组织名称|组织名称长度为2-100位" dc:"组织名称"`
|
||||
TenantCode string `json:"tenant_code" v:"required|length:3,30|regex:^[a-z0-9][a-z0-9-]*[a-z0-9]$#请输入组织代码|组织代码为3-30位小写字母数字|组织代码格式不正确" dc:"组织代码"`
|
||||
Username string `json:"username" v:"required|length:3,50#请输入用户名|用户名长度为3-50位" dc:"用户名"`
|
||||
CaptchaKey string `json:"captcha_key" dc:"滑块验证码key(Turnstile关闭时必填)"`
|
||||
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"`
|
||||
TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"`
|
||||
}
|
||||
|
||||
type TenantRegisterRes struct {
|
||||
@@ -33,12 +34,13 @@ type TenantRegisterRes struct {
|
||||
|
||||
// TenantLoginReq 租户登录请求
|
||||
type TenantLoginReq struct {
|
||||
g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"`
|
||||
Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"`
|
||||
Password string `json:"password" v:"required#请输入密码" dc:"密码"`
|
||||
Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"`
|
||||
CaptchaKey string `json:"captcha_key" dc:"验证码key(验证码启用时必填)"`
|
||||
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(验证码启用时必填)"`
|
||||
g.Meta `path:"/auth/login" method:"post" mime:"json" tags:"租户控制台-认证" summary:"租户登录" group:"public" middleware:"-"`
|
||||
Account string `json:"account" v:"required#请输入账号" dc:"账号(username@tenant_code 或邮箱)"`
|
||||
Password string `json:"password" v:"required#请输入密码" dc:"密码"`
|
||||
Type string `json:"type" v:"required|in:ram,admin#请选择登录方式|登录方式无效" dc:"登录方式:ram(RAM账号)/ admin(管理员邮箱)"`
|
||||
CaptchaKey string `json:"captcha_key" dc:"验证码key(Turnstile关闭时必填)"`
|
||||
CaptchaX int `json:"captcha_x" dc:"滑块X坐标(Turnstile关闭时必填)"`
|
||||
TurnstileToken string `json:"turnstile_token" dc:"Cloudflare Turnstile验证token(Turnstile启用时必填)"`
|
||||
}
|
||||
|
||||
type TenantLoginRes struct {
|
||||
|
||||
@@ -47,3 +47,14 @@ type TenantTaskDetailReq struct {
|
||||
type TenantTaskDetailRes struct {
|
||||
Task TenantTaskItem `json:"task"`
|
||||
}
|
||||
|
||||
// TenantTaskExportReq 导出异步任务日志请求
|
||||
type TenantTaskExportReq struct {
|
||||
g.Meta `path:"/tasks/export" method:"get" mime:"json" tags:"租户控制台-任务管理" summary:"导出异步任务日志"`
|
||||
Format string `json:"format" in:"query" d:"csv" v:"in:csv,xlsx" dc:"导出格式:csv / xlsx"`
|
||||
Status string `json:"status" in:"query" dc:"筛选状态"`
|
||||
Platform string `json:"platform" in:"query" dc:"筛选平台(sora/kling/midjourney/suno)"`
|
||||
PublicTaskID string `json:"public_task_id" in:"query" dc:"任务ID(精确匹配)"`
|
||||
}
|
||||
|
||||
type TenantTaskExportRes struct{}
|
||||
@@ -174,6 +174,8 @@ const (
|
||||
// Registration errors
|
||||
CodeRegistrationDisabled = 10077
|
||||
CodeEmailVerificationDisabled = 10082
|
||||
CodeIpRateLimitExceeded = 10090 // IP级别限流
|
||||
CodeGlobalRateLimitExceeded = 10091 // 全局限流
|
||||
|
||||
// Setup errors
|
||||
CodeSetupCompleted = 10078
|
||||
@@ -308,6 +310,8 @@ const (
|
||||
// Registration errors
|
||||
MsgRegistrationDisabled = "注册功能已关闭"
|
||||
MsgEmailVerificationDisabled = "注册邮箱验证未启用"
|
||||
MsgIpRateLimitExceeded = "该IP注册频率过高,请稍后再试"
|
||||
MsgGlobalRateLimitExceeded = "系统注册繁忙,请稍后再试"
|
||||
|
||||
// Setup errors
|
||||
MsgSetupCompleted = "系统已完成初始化"
|
||||
|
||||
@@ -52,3 +52,6 @@ func (c *ControllerV1) AdminUsageLogExport(ctx context.Context, req *v1.AdminUsa
|
||||
func (c *ControllerV1) AdminBillingRecordExport(ctx context.Context, req *v1.AdminBillingRecordExportReq) (res *v1.AdminBillingRecordExportRes, err error) {
|
||||
return service.Admin().ExportBillingRecords(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) AdminTransactionList(ctx context.Context, req *v1.AdminTransactionListReq) (res *v1.AdminTransactionListRes, err error) {
|
||||
return service.Admin().GetAllTransactions(ctx, req)
|
||||
}
|
||||
@@ -13,3 +13,6 @@ func (c *ControllerV1) TenantTaskList(ctx context.Context, req *v1.TenantTaskLis
|
||||
func (c *ControllerV1) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (res *v1.TenantTaskDetailRes, err error) {
|
||||
return service.Tenant().TenantTaskDetail(ctx, req)
|
||||
}
|
||||
func (c *ControllerV1) TenantTaskExport(ctx context.Context, req *v1.TenantTaskExportReq) (res *v1.TenantTaskExportRes, err error) {
|
||||
return service.Tenant().ExportTasks(ctx, req)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ type AudRequestLogsColumns struct {
|
||||
TaskUpstreamHeaders string // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt string // 异步任务达到终态的时间
|
||||
AttachmentIds string // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
|
||||
Model string // 请求使用的模型名称(从请求体或 URL 中提取)
|
||||
}
|
||||
|
||||
// audRequestLogsColumns holds the columns for the table aud_request_logs.
|
||||
@@ -87,6 +88,7 @@ var audRequestLogsColumns = AudRequestLogsColumns{
|
||||
TaskUpstreamHeaders: "task_upstream_headers",
|
||||
TaskCompletedAt: "task_completed_at",
|
||||
AttachmentIds: "attachment_ids",
|
||||
Model: "model",
|
||||
}
|
||||
|
||||
// NewAudRequestLogsDao creates and returns a new DAO object for table data access.
|
||||
|
||||
@@ -453,11 +453,15 @@ func captureRequestHeaders(r *ghttp.Request) map[string]string {
|
||||
func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCaptureWriter, body []byte, path string, latencyMs int, ttft int) {
|
||||
// 解析 stream 标志
|
||||
var isStream bool
|
||||
var modelName string
|
||||
var rawRequest map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &rawRequest); err == nil {
|
||||
if streamVal, ok := rawRequest["stream"]; ok {
|
||||
_ = json.Unmarshal(streamVal, &isStream)
|
||||
}
|
||||
if modelVal, ok := rawRequest["model"]; ok {
|
||||
_ = json.Unmarshal(modelVal, &modelName)
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获响应体(流式请求记录 SSE 原始数据,非流式记录完整响应)
|
||||
@@ -476,6 +480,7 @@ func recordAudit(r *ghttp.Request, rc *handler.RelayContext, capture *ResponseCa
|
||||
StatusCode: capture.StatusCode(),
|
||||
ClientIP: rc.ClientIP,
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
Model: modelName,
|
||||
RequestBody: string(body),
|
||||
ResponseBody: responseBody,
|
||||
LatencyMs: latencyMs,
|
||||
|
||||
@@ -544,6 +544,87 @@ func (s *sAdmin) SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSet
|
||||
return &v1.AdminWalletSetWarningThresholdRes{}, nil
|
||||
}
|
||||
|
||||
// GetAllTransactions 获取所有租户交易流水(管理后台)
|
||||
func (s *sAdmin) GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error) {
|
||||
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
|
||||
|
||||
query := dao.BilTransactions.Ctx(ctx)
|
||||
|
||||
if req.TenantID > 0 {
|
||||
query = query.Where("bil_transactions.tenant_id", req.TenantID)
|
||||
}
|
||||
if req.Type != "" {
|
||||
query = query.Where("bil_transactions.type", req.Type)
|
||||
}
|
||||
if req.Username != "" {
|
||||
query = query.Where("tu.username LIKE ?", "%"+req.Username+"%")
|
||||
}
|
||||
if req.ModelName != "" {
|
||||
query = query.Where("bil_transactions.model_name LIKE ?", "%"+req.ModelName+"%")
|
||||
}
|
||||
if req.StartDate != "" {
|
||||
query = query.Where("bil_transactions.created_at >= ?", req.StartDate+" 00:00:00")
|
||||
}
|
||||
if req.EndDate != "" {
|
||||
query = query.Where("bil_transactions.created_at <= ?", req.EndDate+" 23:59:59")
|
||||
}
|
||||
|
||||
type transactionRow struct {
|
||||
Id int64 `json:"id"`
|
||||
TenantId int64 `json:"tenant_id"`
|
||||
TenantName string `json:"tenant_name"`
|
||||
Type string `json:"type"`
|
||||
Amount float64 `json:"amount"`
|
||||
BalanceAfter float64 `json:"balance_after"`
|
||||
Description string `json:"description"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
RequestId string `json:"request_id"`
|
||||
ModelName string `json:"model_name"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
var records []*transactionRow
|
||||
var total int
|
||||
err := query.Fields("bil_transactions.id, bil_transactions.tenant_id, COALESCE(tn.name, '') AS tenant_name, bil_transactions.type, bil_transactions.amount, bil_transactions.balance_after, bil_transactions.description, bil_transactions.user_id, COALESCE(tu.username, '') AS username, bil_transactions.request_id, bil_transactions.model_name, bil_transactions.created_at").
|
||||
LeftJoin("tnt_users tu", "bil_transactions.user_id = tu.id AND bil_transactions.tenant_id = tu.tenant_id").
|
||||
LeftJoin("tnt_tenants tn", "bil_transactions.tenant_id = tn.id").
|
||||
OrderDesc("bil_transactions.created_at").
|
||||
Page(page, pageSize).
|
||||
ScanAndCount(&records, &total, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if records == nil {
|
||||
records = make([]*transactionRow, 0)
|
||||
}
|
||||
|
||||
list := make([]*v1.AdminTransactionItem, 0, len(records))
|
||||
for _, r := range records {
|
||||
list = append(list, &v1.AdminTransactionItem{
|
||||
Id: r.Id,
|
||||
TenantId: r.TenantId,
|
||||
TenantName: r.TenantName,
|
||||
Type: r.Type,
|
||||
Amount: r.Amount,
|
||||
BalanceAfter: r.BalanceAfter,
|
||||
Description: r.Description,
|
||||
UserId: r.UserId,
|
||||
Username: r.Username,
|
||||
RequestId: r.RequestId,
|
||||
ModelName: r.ModelName,
|
||||
CreatedAt: r.CreatedAt.String(),
|
||||
})
|
||||
}
|
||||
|
||||
return &v1.AdminTransactionListRes{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道)
|
||||
func (s *sAdmin) GetDashboardChannelHealth(ctx context.Context, req *v1.AdminDashboardChannelHealthReq) (*v1.AdminDashboardChannelHealthRes, error) {
|
||||
var items []v1.ChannelHealthItem
|
||||
|
||||
@@ -90,7 +90,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
MaxOutputTokens int `json:"max_output_tokens"`
|
||||
Capabilities string `json:"capabilities"`
|
||||
Description string `json:"description"`
|
||||
Tags string `json:"tags"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt *gtime.Time `json:"created_at"`
|
||||
UpdatedAt *gtime.Time `json:"updated_at"`
|
||||
DeprecatedAt *gtime.Time `json:"deprecated_at"`
|
||||
@@ -145,7 +145,7 @@ func (s *sAdmin) ListModels(ctx context.Context, req *v1.ModelListReq) (*v1.Mode
|
||||
MaxOutput: m.MaxOutputTokens,
|
||||
Capabilities: parseCapabilities(m.Capabilities),
|
||||
Description: m.Description,
|
||||
Tags: parseTagsArray(m.Tags),
|
||||
Tags: m.Tags,
|
||||
CreatedAt: m.CreatedAt.String(),
|
||||
UpdatedAt: m.UpdatedAt.String(),
|
||||
}
|
||||
@@ -362,10 +362,6 @@ func (s *sAdmin) DeleteModel(ctx context.Context, req *v1.ModelDeleteReq) (*v1.M
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func parseTagsArray(tags string) []string {
|
||||
return parsePgArray(tags)
|
||||
}
|
||||
|
||||
func parseCapabilities(raw string) map[string]bool {
|
||||
if raw == "" || raw == "{}" {
|
||||
return nil
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/database/gdb"
|
||||
"github.com/gogf/gf/v2/errors/gcode"
|
||||
@@ -32,7 +31,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"`
|
||||
MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"`
|
||||
Description string `orm:"description" json:"description"`
|
||||
Tags string `orm:"tags" json:"tags"`
|
||||
Tags []string `orm:"tags" json:"tags"`
|
||||
Capabilities string `orm:"capabilities" json:"capabilities"`
|
||||
SunsetDate *gtime.Time `orm:"sunset_date" json:"sunset_date"`
|
||||
ReplacementModel string `orm:"replacement_model" json:"replacement_model"`
|
||||
@@ -120,9 +119,7 @@ func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonRe
|
||||
if m.SunsetDate != nil {
|
||||
em.SunsetDate = m.SunsetDate.Format("Y-m-d")
|
||||
}
|
||||
if m.Tags != "" {
|
||||
em.Tags = parsePgArray(m.Tags)
|
||||
}
|
||||
em.Tags = m.Tags
|
||||
result = append(result, em)
|
||||
}
|
||||
|
||||
@@ -334,15 +331,3 @@ func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
// parsePgArray 解析 PostgreSQL 数组格式的字符串,如 {tag1,tag2}
|
||||
func parsePgArray(raw string) []string {
|
||||
if raw == "" || raw == "{}" || raw == "NULL" {
|
||||
return nil
|
||||
}
|
||||
s := strings.Trim(raw, "{}")
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(s, ",")
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/admin/v1"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
@@ -56,12 +57,46 @@ func (s *sAdmin) UpdateSettings(ctx context.Context, req *v1.AdminSettingsUpdate
|
||||
for key, val := range req.Settings {
|
||||
strValues[key] = common.NormalizeSettingValue(val)
|
||||
}
|
||||
|
||||
if err := s.validateCrossFieldSettings(ctx, req.Category, strValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := common.Config().UpdateCategory(ctx, req.Category, strValues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// validateCrossFieldSettings validates interdependent settings within a category.
|
||||
func (s *sAdmin) validateCrossFieldSettings(ctx context.Context, category string, values map[string]string) error {
|
||||
if category != "security" {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabled, ok := values["turnstile_enabled"]
|
||||
if !ok || (enabled != "true" && enabled != "1") {
|
||||
return nil
|
||||
}
|
||||
|
||||
siteKey := values["turnstile_site_key"]
|
||||
secretKey := values["turnstile_secret_key"]
|
||||
|
||||
// If keys not in the current request, read existing values from DB
|
||||
if siteKey == "" {
|
||||
siteKey = common.Config().GetString(ctx, "turnstile_site_key")
|
||||
}
|
||||
if secretKey == "" || secretKey == "******" {
|
||||
secretKey = common.Config().GetString(ctx, "turnstile_secret_key")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(siteKey) == "" || strings.TrimSpace(secretKey) == "" {
|
||||
return common.NewBadRequestError("启用 Turnstile 前必须先配置 Site Key 和 Secret Key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidCategory(category string) bool {
|
||||
for _, c := range common.Categories {
|
||||
if c.Key == category {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/wenlng/go-captcha/v2/slide"
|
||||
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/utility/turnstile"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -211,3 +212,46 @@ func getCaptchaExpireSeconds(ctx context.Context) time.Duration {
|
||||
}
|
||||
return time.Duration(secs) * time.Second
|
||||
}
|
||||
|
||||
// CheckTurnstileRequired checks Cloudflare Turnstile verification when enabled.
|
||||
// If Turnstile is disabled, passes through (allows fallback to captcha).
|
||||
func CheckTurnstileRequired(ctx context.Context, token string) error {
|
||||
// Check if Turnstile is enabled
|
||||
enabled := Config().GetBool(ctx, "turnstile_enabled")
|
||||
if !enabled {
|
||||
// Turnstile not enabled, allow pass-through to captcha
|
||||
return nil
|
||||
}
|
||||
|
||||
// Turnstile is enabled, token is required
|
||||
if token == "" {
|
||||
return NewBusinessError(consts.CodeCaptchaRequired, consts.MsgCaptchaRequired)
|
||||
}
|
||||
|
||||
// Verify token with Cloudflare
|
||||
secretKey := Config().GetString(ctx, "turnstile_secret_key")
|
||||
if secretKey == "" {
|
||||
g.Log().Errorf(ctx, "[Turnstile] secret_key not configured")
|
||||
return NewBusinessError(consts.CodeInternalServerError, "验证服务配置错误")
|
||||
}
|
||||
|
||||
// Get client IP for verification
|
||||
ipAddress := ""
|
||||
if r := g.RequestFromCtx(ctx); r != nil {
|
||||
ipAddress = r.GetClientIp()
|
||||
}
|
||||
|
||||
result, err := turnstile.Verify(ctx, secretKey, token, ipAddress)
|
||||
if err != nil {
|
||||
g.Log().Errorf(ctx, "[Turnstile] verification failed: %v", err)
|
||||
return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败")
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
g.Log().Warningf(ctx, "[Turnstile] verification failed: error codes=%v", result.ErrorCodes)
|
||||
return NewBusinessError(consts.CodeCaptchaFailed, "人机验证失败")
|
||||
}
|
||||
|
||||
// Verification successful
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
)
|
||||
|
||||
// CheckRegisterRateLimit 检查注册速率限制
|
||||
// 1. IP级别限流(每小时/每天)
|
||||
// 2. 全局限流(每分钟)
|
||||
func CheckRegisterRateLimit(ctx context.Context, ipAddress string) error {
|
||||
// 1. 检查IP级别限流
|
||||
if err := checkIPRateLimit(ctx, ipAddress); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 检查全局限流
|
||||
if err := checkGlobalRateLimit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkIPRateLimit 检查IP级别的注册限流
|
||||
func checkIPRateLimit(ctx context.Context, ipAddress string) error {
|
||||
if ipAddress == "" {
|
||||
return nil // 无法获取IP时跳过检查
|
||||
}
|
||||
|
||||
// 每小时限制
|
||||
hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour")
|
||||
if hourlyLimit > 0 {
|
||||
hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress)
|
||||
count, err := incrementWithExpire(ctx, hourlyKey, 3600) // 1小时过期
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "IP注册限流检查失败: %v", err)
|
||||
return nil // Redis失败时不阻塞注册
|
||||
}
|
||||
if count > int64(hourlyLimit) {
|
||||
return NewBusinessError(consts.CodeIpRateLimitExceeded, consts.MsgIpRateLimitExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
// 每天限制
|
||||
dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day")
|
||||
if dailyLimit > 0 {
|
||||
dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress)
|
||||
count, err := incrementWithExpire(ctx, dailyKey, 86400) // 24小时过期
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "IP注册限流检查失败: %v", err)
|
||||
return nil // Redis失败时不阻塞注册
|
||||
}
|
||||
if count > int64(dailyLimit) {
|
||||
return NewBusinessError(consts.CodeIpRateLimitExceeded, consts.MsgIpRateLimitExceeded)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkGlobalRateLimit 检查全局注册速率限制
|
||||
func checkGlobalRateLimit(ctx context.Context) error {
|
||||
globalLimit := Config().GetInt(ctx, "register_global_limit_per_minute")
|
||||
if globalLimit <= 0 {
|
||||
return nil // 未启用全局限流
|
||||
}
|
||||
|
||||
// 使用当前分钟的Unix时间戳作为key,确保每分钟重置
|
||||
now := time.Now()
|
||||
minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60) // 每分钟一个key
|
||||
|
||||
count, err := incrementWithExpire(ctx, minuteKey, 120) // 2分钟过期(确保当前分钟和下一分钟都能计数)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "全局注册限流检查失败: %v", err)
|
||||
return nil // Redis失败时不阻塞注册
|
||||
}
|
||||
|
||||
if count > int64(globalLimit) {
|
||||
return NewBusinessError(consts.CodeGlobalRateLimitExceeded, consts.MsgGlobalRateLimitExceeded)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// incrementWithExpire 原子递增计数器,首次递增时设置过期时间
|
||||
// 使用Lua脚本确保原子性:INCR + EXPIRE(仅在第一次)
|
||||
func incrementWithExpire(ctx context.Context, key string, expireSeconds int) (int64, error) {
|
||||
// Lua脚本:先递增,如果结果为1则设置过期时间
|
||||
luaScript := `
|
||||
local count = redis.call("INCR", KEYS[1])
|
||||
if count == 1 then
|
||||
redis.call("EXPIRE", KEYS[1], ARGV[1])
|
||||
end
|
||||
return count
|
||||
`
|
||||
|
||||
result, err := g.Redis().Do(ctx, "EVAL", luaScript, 1, key, expireSeconds)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.Int64(), nil
|
||||
}
|
||||
|
||||
// GetRegisterRateLimitStatus 获取当前IP的注册限流状态(用于前端提示)
|
||||
func GetRegisterRateLimitStatus(ctx context.Context, ipAddress string) map[string]any {
|
||||
status := make(map[string]any)
|
||||
|
||||
if ipAddress == "" {
|
||||
return status
|
||||
}
|
||||
|
||||
// 查询IP每小时限制
|
||||
hourlyLimit := Config().GetInt(ctx, "register_ip_limit_per_hour")
|
||||
if hourlyLimit > 0 {
|
||||
hourlyKey := fmt.Sprintf("register:ip:hourly:%s", ipAddress)
|
||||
hourlyCount, _ := g.Redis().Do(ctx, "GET", hourlyKey)
|
||||
if !hourlyCount.IsNil() {
|
||||
remaining := hourlyLimit - hourlyCount.Int()
|
||||
status["hourly_remaining"] = remaining
|
||||
status["hourly_limit"] = hourlyLimit
|
||||
if ttl, err := g.Redis().Do(ctx, "TTL", hourlyKey); err == nil {
|
||||
status["hourly_reset_seconds"] = ttl.Int()
|
||||
}
|
||||
} else {
|
||||
status["hourly_remaining"] = hourlyLimit
|
||||
status["hourly_limit"] = hourlyLimit
|
||||
}
|
||||
}
|
||||
|
||||
// 查询IP每天限制
|
||||
dailyLimit := Config().GetInt(ctx, "register_ip_limit_per_day")
|
||||
if dailyLimit > 0 {
|
||||
dailyKey := fmt.Sprintf("register:ip:daily:%s", ipAddress)
|
||||
dailyCount, _ := g.Redis().Do(ctx, "GET", dailyKey)
|
||||
if !dailyCount.IsNil() {
|
||||
remaining := dailyLimit - dailyCount.Int()
|
||||
status["daily_remaining"] = remaining
|
||||
status["daily_limit"] = dailyLimit
|
||||
if ttl, err := g.Redis().Do(ctx, "TTL", dailyKey); err == nil {
|
||||
status["daily_reset_seconds"] = ttl.Int()
|
||||
}
|
||||
} else {
|
||||
status["daily_remaining"] = dailyLimit
|
||||
status["daily_limit"] = dailyLimit
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
)
|
||||
|
||||
// TestCheckRegisterRateLimit 测试注册速率限制
|
||||
func TestCheckRegisterRateLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试IP级别限流
|
||||
t.Run("IP hourly limit", func(t *testing.T) {
|
||||
testIP := "192.168.1.100"
|
||||
|
||||
// 清理测试数据
|
||||
hourlyKey := "register:ip:hourly:" + testIP
|
||||
g.Redis().Do(ctx, "DEL", hourlyKey)
|
||||
|
||||
// 设置配置为每小时最多3次
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "3")
|
||||
|
||||
// 前3次应该成功
|
||||
for i := 0; i < 3; i++ {
|
||||
err := CheckRegisterRateLimit(ctx, testIP)
|
||||
if err != nil {
|
||||
t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 第4次应该失败
|
||||
err := CheckRegisterRateLimit(ctx, testIP)
|
||||
if err == nil {
|
||||
t.Error("第4次注册应该被限流,但成功了")
|
||||
} else if err.Error() != consts.MsgIpRateLimitExceeded {
|
||||
t.Errorf("第4次注册应该返回IP限流错误,但得到: %v", err)
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
g.Redis().Do(ctx, "DEL", hourlyKey)
|
||||
})
|
||||
|
||||
t.Run("IP daily limit", func(t *testing.T) {
|
||||
testIP := "192.168.1.101"
|
||||
|
||||
// 清理测试数据
|
||||
dailyKey := "register:ip:daily:" + testIP
|
||||
g.Redis().Do(ctx, "DEL", dailyKey)
|
||||
|
||||
// 设置配置为每天最多5次
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "5")
|
||||
|
||||
// 禁用每小时限制
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0")
|
||||
|
||||
// 前5次应该成功
|
||||
for i := 0; i < 5; i++ {
|
||||
err := CheckRegisterRateLimit(ctx, testIP)
|
||||
if err != nil {
|
||||
t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 第6次应该失败
|
||||
err := CheckRegisterRateLimit(ctx, testIP)
|
||||
if err == nil {
|
||||
t.Error("第6次注册应该被限流,但成功了")
|
||||
} else if err.Error() != consts.MsgIpRateLimitExceeded {
|
||||
t.Errorf("第6次注册应该返回IP限流错误,但得到: %v", err)
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
g.Redis().Do(ctx, "DEL", dailyKey)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGlobalRateLimit 测试全局限流
|
||||
func TestGlobalRateLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 清理测试数据
|
||||
now := time.Now()
|
||||
minuteKey := fmt.Sprintf("register:global:%d", now.Unix()/60)
|
||||
g.Redis().Do(ctx, "DEL", minuteKey)
|
||||
|
||||
// 设置配置为每分钟最多2次
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_global_limit_per_minute", 60, "2")
|
||||
|
||||
// 禁用IP限制
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "0")
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "0")
|
||||
|
||||
// 前2次应该成功(使用不同IP)
|
||||
for i := 0; i < 2; i++ {
|
||||
testIP := fmt.Sprintf("192.168.1.%d", i+100)
|
||||
err := CheckRegisterRateLimit(ctx, testIP)
|
||||
if err != nil {
|
||||
t.Errorf("第 %d 次注册应该成功,但得到错误: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 第3次应该失败(无论使用什么IP)
|
||||
err := CheckRegisterRateLimit(ctx, "192.168.1.200")
|
||||
if err == nil {
|
||||
t.Error("第3次注册应该被全局限流,但成功了")
|
||||
} else if err.Error() != consts.MsgGlobalRateLimitExceeded {
|
||||
t.Errorf("第3次注册应该返回全局限流错误,但得到: %v", err)
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
g.Redis().Do(ctx, "DEL", minuteKey)
|
||||
}
|
||||
|
||||
// TestGetRegisterRateLimitStatus 测试获取限流状态
|
||||
func TestGetRegisterRateLimitStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
testIP := "192.168.1.102"
|
||||
|
||||
// 清理测试数据
|
||||
hourlyKey := "register:ip:hourly:" + testIP
|
||||
dailyKey := "register:ip:daily:" + testIP
|
||||
g.Redis().Do(ctx, "DEL", hourlyKey)
|
||||
g.Redis().Do(ctx, "DEL", dailyKey)
|
||||
|
||||
// 设置配置
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_hour", 60, "5")
|
||||
g.Redis().Do(ctx, "SETEX", "config:register_ip_limit_per_day", 60, "20")
|
||||
|
||||
// 注册2次
|
||||
g.Redis().Do(ctx, "SETEX", hourlyKey, 3600, "2")
|
||||
g.Redis().Do(ctx, "SETEX", dailyKey, 86400, "2")
|
||||
|
||||
// 获取状态
|
||||
status := GetRegisterRateLimitStatus(ctx, testIP)
|
||||
|
||||
// 验证状态
|
||||
if hourlyLimit, ok := status["hourly_limit"].(int); !ok || hourlyLimit != 5 {
|
||||
t.Errorf("期望每小时限制为5,得到: %v", status["hourly_limit"])
|
||||
}
|
||||
if hourlyRemaining, ok := status["hourly_remaining"].(int); !ok || hourlyRemaining != 3 {
|
||||
t.Errorf("期望每小时剩余3次,得到: %v", status["hourly_remaining"])
|
||||
}
|
||||
if dailyLimit, ok := status["daily_limit"].(int); !ok || dailyLimit != 20 {
|
||||
t.Errorf("期望每天限制为20,得到: %v", status["daily_limit"])
|
||||
}
|
||||
if dailyRemaining, ok := status["daily_remaining"].(int); !ok || dailyRemaining != 18 {
|
||||
t.Errorf("期望每天剩余18次,得到: %v", status["daily_remaining"])
|
||||
}
|
||||
|
||||
// 清理测试数据
|
||||
g.Redis().Do(ctx, "DEL", hourlyKey)
|
||||
g.Redis().Do(ctx, "DEL", dailyKey)
|
||||
}
|
||||
@@ -45,7 +45,6 @@ var Categories = []SettingCategory{
|
||||
{Key: "channel", Label: "渠道配置", Icon: "channel", Order: 9},
|
||||
{Key: "storage", Label: "存储配置", Icon: "cloud", Order: 10},
|
||||
{Key: "data_governance", Label: "数据治理", Icon: "database", Order: 11},
|
||||
{Key: "update", Label: "系统更新", Icon: "sync", Order: 12},
|
||||
}
|
||||
|
||||
// Registry is the central definition of all configuration items.
|
||||
@@ -59,6 +58,12 @@ var Registry = []SettingDef{
|
||||
Label: "开放注册", Description: "是否允许新用户注册", IsPublic: true},
|
||||
{Key: "register_email_verification", Type: SettingTypeBool, Default: "false", Category: "general",
|
||||
Label: "注册邮箱验证", Description: "注册时是否需要邮箱验证码,关闭时使用滑块验证", IsPublic: true},
|
||||
{Key: "register_ip_limit_per_hour", Type: SettingTypeInt, Default: "3", Category: "general",
|
||||
Label: "IP每小时注册限制", Description: "同一IP每小时最多注册次数(0表示不限制)", Validation: "min:0,max:20"},
|
||||
{Key: "register_ip_limit_per_day", Type: SettingTypeInt, Default: "10", Category: "general",
|
||||
Label: "IP每天注册限制", Description: "同一IP每天最多注册次数(0表示不限制)", Validation: "min:0,max:100"},
|
||||
{Key: "register_global_limit_per_minute", Type: SettingTypeInt, Default: "10", Category: "general",
|
||||
Label: "全局每分钟注册限制", Description: "全系统每分钟最多注册次数(0表示不限制)", Validation: "min:0,max:100"},
|
||||
{Key: "maintenance_mode", Type: SettingTypeBool, Default: "false", Category: "general",
|
||||
Label: "维护模式", Description: "开启后控制台显示维护提示"},
|
||||
{Key: "maintenance_message", Type: SettingTypeString, Default: "", Category: "general",
|
||||
@@ -67,10 +72,6 @@ var Registry = []SettingDef{
|
||||
Label: "预计维护时长", Description: "维护模式预计持续时间,展示给用户", IsPublic: true},
|
||||
{Key: "api_maintenance_enabled", Type: SettingTypeBool, Default: "false", Category: "general",
|
||||
Label: "全局 API 维护", Description: "开启后 API 代理返回 503,叠加维护模式使用", IsPublic: true},
|
||||
{Key: "demo_mode", Type: SettingTypeBool, Default: "false", Category: "general",
|
||||
Label: "演示模式", Description: "开启后所有写操作被拦截,仅允许只读访问", IsPublic: true},
|
||||
{Key: "demo_message", Type: SettingTypeString, Default: "演示环境,数据不可修改", Category: "general",
|
||||
Label: "演示模式提示", Description: "演示模式下返回给用户的提示信息", IsPublic: true},
|
||||
{Key: "tenant_console_url", Type: SettingTypeString, Default: "", Category: "general",
|
||||
Label: "租户控制台地址", Description: "租户控制台的完整 URL,如 https://console.example.com,用于生成邀请链接等"},
|
||||
|
||||
@@ -253,14 +254,6 @@ var Registry = []SettingDef{
|
||||
Label: "GDPR删除请求完成天数", Validation: "min:7,max:90"},
|
||||
{Key: "file_retention_enabled", Type: SettingTypeBool, Default: "true", Category: "data_governance",
|
||||
Label: "启用文件保留期检查"},
|
||||
|
||||
// Update settings
|
||||
{Key: "update_auto_check_enabled", Type: SettingTypeBool, Default: "true", Category: "update",
|
||||
Label: "自动检查更新", Description: "启用后系统会定期检查是否有新版本"},
|
||||
{Key: "update_check_interval_hours", Type: SettingTypeInt, Default: "24", Category: "update",
|
||||
Label: "检查间隔(小时)", Description: "自动检查更新的时间间隔", Validation: "min:1,max:168"},
|
||||
{Key: "update_channel", Type: SettingTypeString, Default: "stable", Category: "update",
|
||||
Label: "更新通道", Description: "选择更新通道", Validation: "enum:stable,beta"},
|
||||
}
|
||||
|
||||
// registryMap is a lookup index for fast access by key.
|
||||
|
||||
@@ -540,6 +540,7 @@ func (p *DataProviderImpl) RecordAudit(ctx context.Context, record *common.Audit
|
||||
StatusCode: record.StatusCode,
|
||||
ClientIp: record.ClientIP,
|
||||
UserAgent: record.UserAgent,
|
||||
Model: record.Model,
|
||||
RequestBody: sysReq,
|
||||
ResponseBody: sysResp,
|
||||
TenantRequestBody: tntReq,
|
||||
|
||||
@@ -3,6 +3,7 @@ package settings
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/service"
|
||||
@@ -42,6 +43,14 @@ func (s *sSettings) PublicSettingsGet(ctx context.Context, _ *v1.PublicSettingsG
|
||||
}
|
||||
}
|
||||
|
||||
// Inject demo mode from config file (not database — avoids the self-lock bug)
|
||||
settings["demo_mode"] = g.Cfg().MustGet(ctx, "demo.enabled").Bool()
|
||||
demoMsg := g.Cfg().MustGet(ctx, "demo.message").String()
|
||||
if demoMsg == "" {
|
||||
demoMsg = "演示环境,数据不可修改"
|
||||
}
|
||||
settings["demo_message"] = demoMsg
|
||||
|
||||
return &v1.PublicSettingsGetRes{Settings: settings}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
return nil, common.NewBusinessError(consts.CodeInvalidUsername, err.Error())
|
||||
}
|
||||
|
||||
// Check register rate limit (IP + global)
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
if err := common.CheckRegisterRateLimit(ctx, ipAddress); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 根据配置选择验证方式
|
||||
emailVerificationEnabled := common.Config().GetBool(ctx, "register_email_verification")
|
||||
if emailVerificationEnabled {
|
||||
@@ -52,7 +58,12 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// 滑块验证模式
|
||||
// 人机验证:优先 Turnstile,否则滑块验证
|
||||
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Turnstile 未启用时,使用滑块验证
|
||||
if err := common.CheckCaptchaRequired(ctx, "tenant_register", req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -80,8 +91,6 @@ func (s *sTenant) Register(ctx context.Context, req *v1.TenantRegisterReq) (*v1.
|
||||
|
||||
var tenantID, ownerUserID int64
|
||||
|
||||
ipAddress := g.RequestFromCtx(ctx).GetClientIp()
|
||||
|
||||
err = dao.TntTenants.Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
|
||||
// Create tenant
|
||||
tenantResult, err := tx.Model("tnt_tenants").Ctx(ctx).Data(do.TntTenants{
|
||||
@@ -184,6 +193,10 @@ func (s *sTenant) Login(ctx context.Context, req *v1.TenantLoginReq) (*v1.Tenant
|
||||
account := strings.TrimSpace(req.Account)
|
||||
|
||||
// Check captcha if required
|
||||
if err := common.CheckTurnstileRequired(ctx, req.TurnstileToken); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := common.CheckCaptchaRequired(ctx, "tenant_login", req.CaptchaKey, req.CaptchaX); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
do "github.com/qianfree/team-api/internal/model/do"
|
||||
"github.com/qianfree/team-api/internal/utility/crypto"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
@@ -61,6 +62,34 @@ func getOAuthSetting(ctx context.Context, key string) string {
|
||||
return common.Config().GetOption(ctx, key)
|
||||
}
|
||||
|
||||
// encryptOAuthToken encrypts a token string for secure storage.
|
||||
func encryptOAuthToken(ctx context.Context, token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
encKey := getEncKey(ctx)
|
||||
encrypted, err := crypto.EncryptString(encKey, token)
|
||||
if err != nil {
|
||||
g.Log().Warningf(ctx, "[OAuth] encrypt token failed: %v", err)
|
||||
return token
|
||||
}
|
||||
return encrypted
|
||||
}
|
||||
|
||||
// decryptOAuthToken decrypts a stored token string.
|
||||
func decryptOAuthToken(ctx context.Context, encrypted string) string {
|
||||
if encrypted == "" {
|
||||
return ""
|
||||
}
|
||||
encKey := getEncKey(ctx)
|
||||
decrypted, err := crypto.DecryptString(encKey, encrypted)
|
||||
if err != nil {
|
||||
// 可能是未加密的历史数据,直接返回原值
|
||||
return encrypted
|
||||
}
|
||||
return decrypted
|
||||
}
|
||||
|
||||
// GetOAuthAuthorizeURL 获取 OAuth 授权跳转 URL
|
||||
func (s *sTenant) GetOAuthAuthorizeURL(ctx context.Context, req *v1.OAuthAuthorizeReq) (*v1.OAuthAuthorizeRes, error) {
|
||||
provider, ok := providers[req.Provider]
|
||||
@@ -155,8 +184,8 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) (
|
||||
_, err = dao.TntOauthIdentities.Ctx(ctx).
|
||||
Where("id", identity.Id).
|
||||
Data(do.TntOauthIdentities{
|
||||
AccessToken: oauthToken.AccessToken,
|
||||
RefreshToken: oauthToken.RefreshToken,
|
||||
AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken),
|
||||
RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken),
|
||||
TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)),
|
||||
ProviderUsername: userInfo.Username,
|
||||
}).Update()
|
||||
@@ -241,8 +270,8 @@ func (s *sTenant) OAuthCallback(ctx context.Context, req *v1.OAuthCallbackReq) (
|
||||
ProviderUsername: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
AvatarUrl: userInfo.AvatarURL,
|
||||
AccessToken: oauthToken.AccessToken,
|
||||
RefreshToken: oauthToken.RefreshToken,
|
||||
AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken),
|
||||
RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken),
|
||||
TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)),
|
||||
RawData: string(rawData),
|
||||
}).Insert()
|
||||
@@ -308,6 +337,11 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut
|
||||
return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用")
|
||||
}
|
||||
|
||||
enabled := getOAuthSetting(ctx, fmt.Sprintf("oauth_%s_enabled", req.Provider))
|
||||
if enabled != "true" {
|
||||
return nil, common.NewBusinessError(10059, "该 OAuth 供应商未启用")
|
||||
}
|
||||
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
|
||||
@@ -342,8 +376,8 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut
|
||||
_, err = dao.TntOauthIdentities.Ctx(ctx).
|
||||
Where("id", existing.Id).
|
||||
Data(do.TntOauthIdentities{
|
||||
AccessToken: oauthToken.AccessToken,
|
||||
RefreshToken: oauthToken.RefreshToken,
|
||||
AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken),
|
||||
RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken),
|
||||
TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)),
|
||||
RawData: string(rawData),
|
||||
}).Update()
|
||||
@@ -359,8 +393,8 @@ func (s *sTenant) LinkOAuth(ctx context.Context, req *v1.OAuthLinkReq) (*v1.OAut
|
||||
ProviderUsername: userInfo.Username,
|
||||
Email: userInfo.Email,
|
||||
AvatarUrl: userInfo.AvatarURL,
|
||||
AccessToken: oauthToken.AccessToken,
|
||||
RefreshToken: oauthToken.RefreshToken,
|
||||
AccessToken: encryptOAuthToken(ctx, oauthToken.AccessToken),
|
||||
RefreshToken: encryptOAuthToken(ctx, oauthToken.RefreshToken),
|
||||
TokenExpiresAt: gtime.NewFromTime(time.Now().Add(time.Duration(oauthToken.ExpiresIn) * time.Second)),
|
||||
RawData: string(rawData),
|
||||
}).Insert()
|
||||
@@ -498,6 +532,11 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU
|
||||
name = login
|
||||
}
|
||||
|
||||
// GitHub 用户邮箱私密时 /user 返回空邮箱,需要调用 /user/emails 获取主邮箱
|
||||
if email == "" {
|
||||
email = githubFetchPrimaryEmail(token)
|
||||
}
|
||||
|
||||
return &OAuthUserInfo{
|
||||
ProviderUserID: id,
|
||||
Username: login,
|
||||
@@ -508,6 +547,36 @@ func (p *GitHubProvider) GetUserInfo(ctx context.Context, token string) (*OAuthU
|
||||
}, nil
|
||||
}
|
||||
|
||||
// githubFetchPrimaryEmail fetches the primary verified email from GitHub /user/emails API.
|
||||
func githubFetchPrimaryEmail(token string) string {
|
||||
req, _ := http.NewRequest("GET", "https://api.github.com/user/emails", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var emails []struct {
|
||||
Email string `json:"email"`
|
||||
Primary bool `json:"primary"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range emails {
|
||||
if e.Primary {
|
||||
return e.Email
|
||||
}
|
||||
}
|
||||
if len(emails) > 0 {
|
||||
return emails[0].Email
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// === Google OAuth Provider ===
|
||||
|
||||
type GoogleProvider struct{}
|
||||
@@ -524,7 +593,11 @@ func (p *GoogleProvider) ExchangeToken(ctx context.Context, code string) (*OAuth
|
||||
v.Set("code", code)
|
||||
v.Set("client_id", getOAuthSetting(ctx, "oauth_google_client_id"))
|
||||
v.Set("client_secret", getOAuthSetting(ctx, "oauth_google_client_secret"))
|
||||
v.Set("redirect_uri", getOAuthSetting(ctx, "site_url"))
|
||||
siteURL := getOAuthSetting(ctx, "site_url")
|
||||
if siteURL == "" {
|
||||
siteURL = "http://localhost:3000"
|
||||
}
|
||||
v.Set("redirect_uri", siteURL+"/api/tenant/oauth/google/callback")
|
||||
v.Set("grant_type", "authorization_code")
|
||||
|
||||
resp, err := http.Post("https://oauth2.googleapis.com/token", "application/x-www-form-urlencoded", strings.NewReader(v.Encode()))
|
||||
|
||||
@@ -2,11 +2,17 @@ package tenant
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/os/gtime"
|
||||
|
||||
v1 "github.com/qianfree/team-api/api/tenant/v1"
|
||||
"github.com/qianfree/team-api/internal/dao"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/middleware"
|
||||
"github.com/qianfree/team-api/internal/utility/export"
|
||||
)
|
||||
|
||||
// TenantTaskList 租户异步任务列表
|
||||
@@ -168,3 +174,144 @@ func (s *sTenant) TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetail
|
||||
Task: item,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExportTasks 导出异步任务日志
|
||||
// owner/admin 可导出租户所有任务,member 只能导出自己的任务
|
||||
func (s *sTenant) ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) (*v1.TenantTaskExportRes, error) {
|
||||
tenantID := middleware.GetTenantID(ctx)
|
||||
userID := middleware.GetUserID(ctx)
|
||||
role := middleware.GetUserRole(ctx)
|
||||
|
||||
statusLabel := map[string]string{
|
||||
"NOT_START": "未开始",
|
||||
"SUBMITTED": "已提交",
|
||||
"IN_PROGRESS": "进行中",
|
||||
"SUCCESS": "成功",
|
||||
"FAILURE": "失败",
|
||||
"TIMEOUT": "超时",
|
||||
}
|
||||
|
||||
platformLabel := map[string]string{
|
||||
"sora": "Sora",
|
||||
"kling": "Kling",
|
||||
"midjourney": "Midjourney",
|
||||
"suno": "Suno",
|
||||
}
|
||||
|
||||
columns := []export.Column{
|
||||
{Field: "public_task_id", Header: "任务ID"},
|
||||
{Field: "platform_name", Header: "平台"},
|
||||
{Field: "status_name", Header: "状态"},
|
||||
{Field: "action", Header: "动作"},
|
||||
{Field: "model_name", Header: "模型"},
|
||||
{Field: "progress", Header: "进度"},
|
||||
{Field: "username", Header: "用户"},
|
||||
{Field: "pre_deduct_amount", Header: "预扣金额(USD)"},
|
||||
{Field: "actual_cost", Header: "实际费用(USD)"},
|
||||
{Field: "billing_settled", Header: "是否已结算"},
|
||||
{Field: "fail_reason", Header: "失败原因"},
|
||||
{Field: "submit_time", Header: "提交时间"},
|
||||
{Field: "finish_time", Header: "完成时间"},
|
||||
{Field: "created_at", Header: "创建时间"},
|
||||
}
|
||||
|
||||
config := export.Config{
|
||||
Format: req.Format,
|
||||
Filename: "任务日志_" + gtime.Now().Format("Ymd_His"),
|
||||
Columns: columns,
|
||||
}
|
||||
|
||||
var conditions []string
|
||||
var args []any
|
||||
|
||||
conditions = append(conditions, "t.tenant_id = ?")
|
||||
args = append(args, tenantID)
|
||||
|
||||
// member 只能导出自己的任务
|
||||
if role == "member" {
|
||||
conditions = append(conditions, "t.user_id = ?")
|
||||
args = append(args, userID)
|
||||
}
|
||||
if req.Status != "" {
|
||||
conditions = append(conditions, "t.status = ?")
|
||||
args = append(args, req.Status)
|
||||
}
|
||||
if req.Platform != "" {
|
||||
conditions = append(conditions, "t.platform = ?")
|
||||
args = append(args, req.Platform)
|
||||
}
|
||||
if req.PublicTaskID != "" {
|
||||
conditions = append(conditions, "t.public_task_id = ?")
|
||||
args = append(args, req.PublicTaskID)
|
||||
}
|
||||
|
||||
where := ""
|
||||
for i, c := range conditions {
|
||||
if i > 0 {
|
||||
where += " AND "
|
||||
}
|
||||
where += c
|
||||
}
|
||||
|
||||
fromClause := "tsk_model_tasks t LEFT JOIN tnt_users u ON t.user_id = u.id AND t.tenant_id = u.tenant_id"
|
||||
|
||||
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
|
||||
offset := 0
|
||||
for {
|
||||
dataSQL := fmt.Sprintf(
|
||||
`SELECT t.public_task_id, t.platform, t.status, t.action, t.model_name, t.progress,
|
||||
COALESCE(u.username, '') AS username,
|
||||
t.pre_deduct_amount, t.actual_cost, t.billing_settled,
|
||||
COALESCE(t.fail_reason, '') AS fail_reason,
|
||||
t.submit_time, t.finish_time, t.created_at
|
||||
FROM %s WHERE %s ORDER BY t.id DESC LIMIT 1000 OFFSET ?`,
|
||||
fromClause, where,
|
||||
)
|
||||
exportArgs := make([]any, len(args)+1)
|
||||
copy(exportArgs, args)
|
||||
exportArgs[len(args)] = offset
|
||||
result, err := g.DB().Ctx(ctx).Query(ctx, dataSQL, exportArgs...)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, row := range result {
|
||||
m := make(map[string]any, len(row))
|
||||
for k, v := range row {
|
||||
switch raw := v.Val().(type) {
|
||||
case []byte:
|
||||
s := string(raw)
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
m[k] = f
|
||||
} else {
|
||||
m[k] = s
|
||||
}
|
||||
default:
|
||||
m[k] = raw
|
||||
}
|
||||
}
|
||||
// 翻译平台和状态为中文
|
||||
if platform, ok := m["platform"].(string); ok {
|
||||
if label, exists := platformLabel[platform]; exists {
|
||||
m["platform_name"] = label
|
||||
} else {
|
||||
m["platform_name"] = platform
|
||||
}
|
||||
}
|
||||
if status, ok := m["status"].(string); ok {
|
||||
if label, exists := statusLabel[status]; exists {
|
||||
m["status_name"] = label
|
||||
} else {
|
||||
m["status_name"] = status
|
||||
}
|
||||
}
|
||||
if !yield(m) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(result) < 1000 {
|
||||
break
|
||||
}
|
||||
offset += 1000
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,10 +3,10 @@ package middleware
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/net/ghttp"
|
||||
|
||||
"github.com/qianfree/team-api/internal/consts"
|
||||
"github.com/qianfree/team-api/internal/logic/common"
|
||||
"github.com/qianfree/team-api/internal/response"
|
||||
)
|
||||
|
||||
@@ -21,10 +21,11 @@ var demoWhitelistPaths = map[string]bool{
|
||||
}
|
||||
|
||||
// DemoMode blocks all write operations when demo mode is enabled.
|
||||
// Controlled via config file: demo.enabled = true.
|
||||
// Read-only requests (GET/HEAD/OPTIONS) and whitelisted paths are allowed.
|
||||
func DemoMode(r *ghttp.Request) {
|
||||
ctx := r.Context()
|
||||
if !common.Config().GetBool(ctx, "demo_mode") {
|
||||
if !g.Cfg().MustGet(ctx, "demo.enabled").Bool() {
|
||||
r.Middleware.Next()
|
||||
return
|
||||
}
|
||||
@@ -50,7 +51,7 @@ func DemoMode(r *ghttp.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
message := common.Config().GetString(ctx, "demo_message")
|
||||
message := g.Cfg().MustGet(ctx, "demo.message").String()
|
||||
if message == "" {
|
||||
message = consts.MsgDemoModeRestricted
|
||||
}
|
||||
|
||||
@@ -43,4 +43,5 @@ type AudRequestLogs struct {
|
||||
TaskUpstreamHeaders any // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt *gtime.Time // 异步任务达到终态的时间
|
||||
AttachmentIds any // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
|
||||
Model any // 请求使用的模型名称(从请求体或 URL 中提取)
|
||||
}
|
||||
@@ -41,4 +41,5 @@ type AudRequestLogs struct {
|
||||
TaskUpstreamHeaders string `json:"task_upstream_headers" orm:"task_upstream_headers" description:"异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)"` // 异步任务完成时上游返回的响应头(仅审计级别为 full 时记录)
|
||||
TaskCompletedAt *gtime.Time `json:"task_completed_at" orm:"task_completed_at" description:"异步任务达到终态的时间"` // 异步任务达到终态的时间
|
||||
AttachmentIds string `json:"attachment_ids" orm:"attachment_ids" description:"提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])"` // 提取的媒体附件 fil_files ID 数组(JSONB: [1,2,3])
|
||||
Model string `json:"model" orm:"model" description:"请求使用的模型名称(从请求体或 URL 中提取)"` // 请求使用的模型名称(从请求体或 URL 中提取)
|
||||
}
|
||||
@@ -141,6 +141,8 @@ type (
|
||||
GetWalletTransactions(ctx context.Context, req *v1.AdminWalletTransactionListReq) (*v1.AdminWalletTransactionListRes, error)
|
||||
// SetWarningThreshold 设置租户钱包预警阈值(管理后台)
|
||||
SetWarningThreshold(ctx context.Context, req *v1.AdminWalletSetWarningThresholdReq) (*v1.AdminWalletSetWarningThresholdRes, error)
|
||||
// GetAllTransactions 获取所有租户交易流水(管理后台)
|
||||
GetAllTransactions(ctx context.Context, req *v1.AdminTransactionListReq) (*v1.AdminTransactionListRes, error)
|
||||
// GetDashboardChannelHealth 获取渠道健康概览(最不健康的5个活跃渠道)
|
||||
GetDashboardChannelHealth(ctx context.Context, req *v1.AdminDashboardChannelHealthReq) (*v1.AdminDashboardChannelHealthRes, error)
|
||||
// GetDashboardRecentAlerts 获取最近5条告警
|
||||
|
||||
@@ -294,6 +294,9 @@ type (
|
||||
// TenantTaskDetail 租户异步任务详情
|
||||
// owner/admin 可查看租户所有任务,member 只能查看自己的任务
|
||||
TenantTaskDetail(ctx context.Context, req *v1.TenantTaskDetailReq) (*v1.TenantTaskDetailRes, error)
|
||||
// ExportTasks 导出异步任务日志
|
||||
// owner/admin 可导出租户所有任务,member 只能导出自己的任务
|
||||
ExportTasks(ctx context.Context, req *v1.TenantTaskExportReq) (*v1.TenantTaskExportRes, error)
|
||||
// TicketCreate 创建工单
|
||||
TicketCreate(ctx context.Context, req *v1.TenantTicketCreateReq) (*v1.TenantTicketCreateRes, error)
|
||||
// TicketList 获取租户工单列表
|
||||
|
||||
@@ -65,5 +65,11 @@ email:
|
||||
password: ""
|
||||
from: ""
|
||||
|
||||
# Demo mode (read-only mode, configured here — not in admin UI)
|
||||
# Set enabled: true to block all write operations and show a watermark
|
||||
demo:
|
||||
enabled: false
|
||||
message: "演示环境,数据不可修改"
|
||||
|
||||
# Channel proxy (for accessing Google/Anthropic etc. from China)
|
||||
channel_proxy_url: ""
|
||||
@@ -81,3 +81,9 @@ email:
|
||||
username: ""
|
||||
password: ""
|
||||
from: ""
|
||||
|
||||
# 演示模式(通过配置文件控制,不在管理后台设置)
|
||||
# 开启后所有写操作被拦截,仅允许只读访问,并在控制台显示水印
|
||||
demo:
|
||||
enabled: false
|
||||
message: "演示环境,数据不可修改"
|
||||
@@ -119,10 +119,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -108,10 +108,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -133,10 +133,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -129,10 +129,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -125,10 +125,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
return client.Do(httpReq)
|
||||
|
||||
@@ -103,10 +103,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
if timeout <= 0 {
|
||||
timeout = 120 // Coze 可能响应较慢,给更长超时
|
||||
}
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 {
|
||||
timeout = 600
|
||||
}
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -108,9 +108,13 @@ func injectStreamOptions(rawMap map[string]json.RawMessage, info *common.RelayIn
|
||||
|
||||
// injectThinkingParams 根据 RelayInfo 中的思考后缀注入 DeepSeek 思考模式参数。
|
||||
//
|
||||
// DeepSeek 思考模式参数说明:
|
||||
// - thinking.type: "enabled"(默认) / "disabled"
|
||||
// - reasoning_effort: "high"(默认) / "max",兼容映射:low/medium→high, xhigh→max
|
||||
// DeepSeek 两类模型的参数差异:
|
||||
// - V3 思考版(deepseek-chat):通过 thinking.type: "enabled"/"disabled" 控制;启用时需要 budget_tokens
|
||||
// - R1 系列(deepseek-reasoner):不识别 thinking 参数,通过 reasoning_effort: "none"/"low"/"high"/"max" 控制
|
||||
//
|
||||
// 关闭思考时同时注入两个参数,兼容两类模型:
|
||||
// - thinking.type: "disabled"(V3 识别)
|
||||
// - reasoning_effort: "none"(R1 识别)
|
||||
//
|
||||
// 注入优先级:客户端已显式设置 > 后缀路由注入 > 不干预(保留 DeepSeek 默认行为)
|
||||
func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayInfo) map[string]json.RawMessage {
|
||||
@@ -120,16 +124,18 @@ func injectThinkingParams(rawMap map[string]json.RawMessage, info *common.RelayI
|
||||
}
|
||||
|
||||
// -nothinking 后缀:显式关闭思考
|
||||
// V3 模型:thinking.type: "disabled" 生效
|
||||
// R1 模型:thinking 参数被忽略,需 reasoning_effort: "none" 才能关闭思考
|
||||
if info.ThinkingDisabled {
|
||||
rawMap["thinking"] = json.RawMessage(`{"type":"disabled"}`)
|
||||
// 关闭思考时清理 reasoning_effort
|
||||
delete(rawMap, "reasoning_effort")
|
||||
rawMap["reasoning_effort"] = json.RawMessage(`"none"`)
|
||||
return rawMap
|
||||
}
|
||||
|
||||
// -thinking 后缀:显式开启思考 + 可选 effort
|
||||
// budget_tokens 为 V3 思考版必需字段,R1 模型忽略此参数
|
||||
if info.ThinkingEnabled {
|
||||
rawMap["thinking"] = json.RawMessage(`{"type":"enabled"}`)
|
||||
rawMap["thinking"] = json.RawMessage(`{"type":"enabled","budget_tokens":16000}`)
|
||||
rawMap = injectReasoningEffort(rawMap, info)
|
||||
return rawMap
|
||||
}
|
||||
@@ -208,6 +214,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
}
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 {
|
||||
timeout = 600
|
||||
}
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -93,6 +93,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
if timeout <= 0 {
|
||||
timeout = 120 // Dify 应用可能包含复杂工作流,给更长超时
|
||||
}
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 {
|
||||
timeout = 600
|
||||
}
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -297,10 +297,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -118,6 +118,9 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
if timeout <= 0 {
|
||||
timeout = 120 // 图像生成通常较慢
|
||||
}
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 {
|
||||
timeout = 600
|
||||
}
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -105,10 +105,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -94,10 +94,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -102,10 +102,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -109,11 +109,15 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
// Ollama 默认 300s 超时(大模型推理可能很慢)
|
||||
// Ollama 默认 300s 超时(大模型推理可能很慢)
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
}
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations && timeout < 600 {
|
||||
timeout = 600
|
||||
}
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -207,10 +207,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
}
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -66,10 +66,7 @@ func (p *RealtimeProxy) DialUpstream() error {
|
||||
targetURL := buildRealtimeURL(p.info.ChannelMeta.BaseURL)
|
||||
header := buildRealtimeHeaders(p.info)
|
||||
|
||||
timeout := p.info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := p.info.ChannelMeta.Settings.GetTimeoutSeconds(p.info.RelayMode)
|
||||
|
||||
dialer := websocket.Dialer{
|
||||
HandshakeTimeout: time.Duration(timeout) * time.Second,
|
||||
|
||||
@@ -117,10 +117,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
return client.Do(httpReq)
|
||||
|
||||
@@ -88,10 +88,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -159,10 +159,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
httpReq.Header.Set("Authorization", authorization)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -154,10 +154,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -112,10 +112,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -125,10 +125,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -96,10 +96,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -192,13 +192,7 @@ func (a *Adaptor) DoRequest(ctx context.Context, info *common.RelayInfo, request
|
||||
override.MergeHeaderOverrides(httpReq.Header, hdrOverrides)
|
||||
}
|
||||
|
||||
timeout := info.ChannelMeta.Settings.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 60
|
||||
if constant.RelayMode(info.RelayMode) == constant.RelayModeImagesGenerations {
|
||||
timeout = 300
|
||||
}
|
||||
}
|
||||
timeout := info.ChannelMeta.Settings.GetTimeoutSeconds(info.RelayMode)
|
||||
|
||||
client := common.NewPooledClient(timeout, info.ChannelMeta.Settings.UseProxy, info.IsStream)
|
||||
|
||||
|
||||
@@ -217,6 +217,7 @@ type AuditRecord struct {
|
||||
StatusCode int
|
||||
ClientIP string
|
||||
UserAgent string
|
||||
Model string // 请求使用的模型名称
|
||||
RequestBody string
|
||||
ResponseBody string
|
||||
LatencyMs int
|
||||
|
||||
@@ -20,6 +20,23 @@ type ChannelMeta struct {
|
||||
Settings ChannelSettings
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultTimeoutSeconds = 60
|
||||
ImagesGenerationTimeoutSecs = 600
|
||||
)
|
||||
|
||||
// GetTimeoutSeconds 返回请求超时秒数。
|
||||
// 渠道自定义优先,未配置时根据请求模式返回默认值(图片生成 600s,其余 60s)。
|
||||
func (s ChannelSettings) GetTimeoutSeconds(relayMode int) int {
|
||||
if s.TimeoutSeconds > 0 {
|
||||
return s.TimeoutSeconds
|
||||
}
|
||||
if constant.RelayMode(relayMode) == constant.RelayModeImagesGenerations {
|
||||
return ImagesGenerationTimeoutSecs
|
||||
}
|
||||
return DefaultTimeoutSeconds
|
||||
}
|
||||
|
||||
// ChannelSettings 渠道配置(来自 chn_channels.settings JSONB)
|
||||
type ChannelSettings struct {
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // 请求超时秒数,默认 60
|
||||
|
||||
@@ -211,12 +211,18 @@ watch(() => props.visible, (val) => {
|
||||
<template>
|
||||
<ADrawer
|
||||
:visible="visible"
|
||||
:width="720"
|
||||
:width="580"
|
||||
:title="modelId ? `定价设置 - ${modelIdStr}` : '定价设置'"
|
||||
:mask-closable="false"
|
||||
:footer="true"
|
||||
@cancel="emit('update:visible', false)"
|
||||
>
|
||||
<template #footer>
|
||||
<AButton @click="emit('update:visible', false)">取消</AButton>
|
||||
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
|
||||
</template>
|
||||
<ASpin :loading="editorLoading" style="width: 100%">
|
||||
<AForm :model="{}" layout="vertical">
|
||||
<div class="pricing-editor">
|
||||
<!-- Official Pricing Reference -->
|
||||
<div class="editor-section">
|
||||
@@ -449,13 +455,8 @@ watch(() => props.visible, (val) => {
|
||||
<AButton type="dashed" long @click="addTier" class="mt-3">+ 添加梯度</AButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Save -->
|
||||
<div class="editor-actions">
|
||||
<AButton @click="emit('update:visible', false)">取消</AButton>
|
||||
<AButton type="primary" :loading="editorSaving" @click="savePricing">保存定价</AButton>
|
||||
</div>
|
||||
</div>
|
||||
</AForm>
|
||||
</ASpin>
|
||||
</ADrawer>
|
||||
</template>
|
||||
@@ -513,14 +514,6 @@ watch(() => props.visible, (val) => {
|
||||
color: var(--ta-text-secondary);
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
|
||||
.official-pricing-card {
|
||||
padding: 12px 16px;
|
||||
background: var(--color-fill-1);
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
const siteName = ref('')
|
||||
let fetched = false
|
||||
|
||||
async function fetchSiteName(): Promise<void> {
|
||||
if (fetched) return
|
||||
fetched = true
|
||||
try {
|
||||
const res = await axios.get('/api/settings/public', { timeout: 5000 })
|
||||
const settings = res.data?.data?.settings
|
||||
if (settings?.site_name) {
|
||||
siteName.value = settings.site_name
|
||||
}
|
||||
} catch {
|
||||
// silent — fallback to default
|
||||
}
|
||||
}
|
||||
|
||||
export function useSiteName() {
|
||||
return { siteName, fetchSiteName }
|
||||
}
|
||||
@@ -73,10 +73,10 @@ const adminRoutes: RouteRecordRaw[] = [
|
||||
meta: { title: '用量日志' },
|
||||
},
|
||||
{
|
||||
path: 'billing',
|
||||
name: 'AdminBilling',
|
||||
component: () => import('@/views/finance/BillingPage.vue'),
|
||||
meta: { title: '计费管理' },
|
||||
path: 'transactions',
|
||||
name: 'AdminTransactions',
|
||||
component: () => import('@/views/finance/TransactionsPage.vue'),
|
||||
meta: { title: '交易流水' },
|
||||
},
|
||||
{
|
||||
path: 'plans',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import adminRoutes from './admin'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { shouldRefresh, getRefreshToken } from '@/utils/request'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
import axios from 'axios'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
@@ -42,12 +43,16 @@ async function checkSetupStatus(): Promise<boolean> {
|
||||
return systemInitialized === true
|
||||
}
|
||||
|
||||
const { siteName, fetchSiteName } = useSiteName()
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
authStore.loadFromStorage()
|
||||
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title as string} — Team-API`
|
||||
fetchSiteName()
|
||||
const name = siteName.value || 'Team-API'
|
||||
document.title = `${to.meta.title as string} — ${name}`
|
||||
}
|
||||
|
||||
if (to.name === 'AdminSetup') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import axios from 'axios'
|
||||
import { marked } from 'marked'
|
||||
import request from '@/utils/request'
|
||||
import { useWatermark } from '@/composables/useWatermark'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
import {
|
||||
IconDashboard,
|
||||
IconUserGroup,
|
||||
@@ -31,12 +32,14 @@ import {
|
||||
IconLayers,
|
||||
IconHome,
|
||||
IconUser,
|
||||
IconCheckCircleFill,
|
||||
} from '@arco-design/web-vue/es/icon'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const { siteName } = useSiteName()
|
||||
|
||||
const collapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
@@ -298,8 +301,7 @@ const menuGroups = [
|
||||
items: [
|
||||
{ name: 'AdminPlans', label: '套餐管理', icon: IconIdcard },
|
||||
{ name: 'AdminOrders', label: '订单管理', icon: IconCodeBlock },
|
||||
{ name: 'AdminBilling', label: '计费管理', icon: IconCommand },
|
||||
{ name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage },
|
||||
{ name: 'AdminTransactions', label: '交易流水', icon: IconCommand },
|
||||
{ name: 'AdminRedemptions', label: '兑换码管理', icon: IconGift },
|
||||
{ name: 'AdminPromoCodes', label: '优惠码管理', icon: IconTag },
|
||||
],
|
||||
@@ -351,6 +353,7 @@ const menuGroups = [
|
||||
{ name: 'AdminUsers', label: '用户管理', icon: IconUserGroup },
|
||||
{ name: 'AdminPlugins', label: '插件管理', icon: IconCodeBlock },
|
||||
{ name: 'AdminSettings', label: '系统设置', icon: IconSettings },
|
||||
{ name: 'AdminPaymentSettings', label: '支付设置', icon: IconStorage },
|
||||
{ name: 'AdminHelpCategories', label: '帮助分类', icon: IconLayers },
|
||||
{ name: 'AdminHelpArticles', label: '帮助文章', icon: IconFile },
|
||||
{ name: 'AdminChangelogs', label: '更新日志', icon: IconFile },
|
||||
@@ -445,10 +448,10 @@ onUnmounted(() => {
|
||||
<!-- Desktop Sidebar -->
|
||||
<div class="admin-sidebar" :class="{ 'admin-sidebar--collapsed': collapsed }">
|
||||
<div class="admin-sidebar__logo" @click="openUpdateModal" style="cursor: pointer;">
|
||||
<div class="admin-sidebar__logo-icon">T</div>
|
||||
<img src="/favicon.png" alt="Logo" class="admin-sidebar__logo-icon" />
|
||||
<Transition name="fade">
|
||||
<span v-if="!collapsed" class="admin-sidebar__logo-text">
|
||||
Team-API
|
||||
{{ siteName || 'Team-API' }}
|
||||
<span class="admin-sidebar__version">
|
||||
{{ appVersion === 'dev' ? 'dev' : 'v' + appVersion }}
|
||||
<span v-if="hasUpdate" class="admin-sidebar__update-dot" title="有新版本可用"></span>
|
||||
@@ -526,8 +529,8 @@ onUnmounted(() => {
|
||||
<Transition name="slide">
|
||||
<div v-if="mobileMenuOpen" class="admin-sidebar admin-sidebar--mobile">
|
||||
<div class="admin-sidebar__logo">
|
||||
<div class="admin-sidebar__logo-icon">T</div>
|
||||
<span class="admin-sidebar__logo-text">Team-API</span>
|
||||
<img src="/favicon.png" alt="Logo" class="admin-sidebar__logo-icon" />
|
||||
<span class="admin-sidebar__logo-text">{{ siteName || 'Team-API' }}</span>
|
||||
</div>
|
||||
<div class="admin-sidebar__menu">
|
||||
<div v-for="group in menuGroups" :key="group.key">
|
||||
@@ -775,17 +778,10 @@ onUnmounted(() => {
|
||||
|
||||
.admin-sidebar__logo-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--ta-primary-gradient);
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 16px rgba(13, 148, 136, 0.4);
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.admin-sidebar__logo-text {
|
||||
|
||||
@@ -309,8 +309,8 @@ function renderModelChart() {
|
||||
|
||||
modelChart.setOption({
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
||||
grid: { left: 130, right: 50, top: 10, bottom: 20 },
|
||||
xAxis: { type: 'value', name: '费用 ($)' },
|
||||
grid: { left: 130, right: 50, top: 10, bottom: 36 },
|
||||
xAxis: { type: 'value', name: '费用 ($)', nameLocation: 'middle', nameGap: 22 },
|
||||
yAxis: {
|
||||
type: 'category',
|
||||
data: names,
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { extractApiError } from '@/utils/request'
|
||||
import request from '@/utils/request'
|
||||
import SlideCaptcha from '@/components/SlideCaptcha.vue'
|
||||
import { useSiteName } from '@/composables/useSiteName'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const formRef = ref<FormInstance | null>(null)
|
||||
const loading = ref(false)
|
||||
const { siteName } = useSiteName()
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
@@ -94,8 +96,8 @@ async function handle2FAVerify() {
|
||||
<div class="login-orb login-orb--3" />
|
||||
|
||||
<div class="login-brand__content">
|
||||
<img src="/favicon.png" alt="Team-API" class="login-brand__logo" />
|
||||
<h1 class="login-brand__title">Team-API</h1>
|
||||
<img src="/favicon.png" :alt="siteName" class="login-brand__logo" />
|
||||
<h1 class="login-brand__title">{{ siteName }}</h1>
|
||||
<p class="login-brand__desc">多租户 大模型 API 网关管理平台</p>
|
||||
|
||||
<div class="login-brand__features">
|
||||
@@ -132,8 +134,8 @@ async function handle2FAVerify() {
|
||||
<div class="login-form-wrapper">
|
||||
<!-- Mobile Brand (hidden on desktop) -->
|
||||
<div class="login-mobile-brand">
|
||||
<img src="/favicon.png" alt="Team-API" class="login-brand__logo" />
|
||||
<span class="login-mobile-brand__name">Team-API</span>
|
||||
<img src="/favicon.png" :alt="siteName" class="login-brand__logo" />
|
||||
<span class="login-mobile-brand__name">{{ siteName }}</span>
|
||||
</div>
|
||||
|
||||
<div class="login-form glass animate-slideUp">
|
||||
|
||||
@@ -302,10 +302,17 @@ const { exporting, exportFile } = useExport({
|
||||
</ACard>
|
||||
|
||||
<!-- Create Channel Modal -->
|
||||
<AModal v-model:visible="showModal" title="创建渠道" :width="550" :mask-closable="false" :on-before-ok="handleSubmit" :ok-loading="formLoading">
|
||||
<AModal v-model:visible="showModal" title="创建渠道" :width="640" :mask-closable="false" :on-before-ok="handleSubmit" :ok-loading="formLoading">
|
||||
<AForm ref="formRef" :model="form" :rules="formRules" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem field="name" label="渠道名称" required><AInput v-model="form.name" placeholder="例如:OpenAI 主力" /></AFormItem>
|
||||
<AFormItem field="type" label="供应商类型" required><ASelect v-model="form.type" :options="providerTypeOptions" placeholder="输入关键词搜索或选择" allow-search :filter-option="filterProviderOption" /></AFormItem>
|
||||
<!-- 基本信息 -->
|
||||
<ARow :gutter="16">
|
||||
<ACol :span="12">
|
||||
<AFormItem field="name" label="渠道名称" required><AInput v-model="form.name" placeholder="例如:OpenAI 主力" /></AFormItem>
|
||||
</ACol>
|
||||
<ACol :span="12">
|
||||
<AFormItem field="type" label="供应商类型" required><ASelect v-model="form.type" :options="providerTypeOptions" placeholder="搜索或选择" allow-search :filter-option="filterProviderOption" /></AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<AFormItem label="Base URL">
|
||||
<AInput v-model="form.base_url" :placeholder="providerDefaultURLs[form.type] || 'https://api.openai.com'" />
|
||||
<template #extra>
|
||||
@@ -317,17 +324,51 @@ const { exporting, exportFile } = useExport({
|
||||
<AFormItem field="api_key" label="API Key" required>
|
||||
<AInput v-model="form.api_key" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="sk-xxxx..." />
|
||||
</AFormItem>
|
||||
<AFormItem label="优先级"><AInputNumber v-model="form.priority" :min="0" placeholder="数字越大优先级越高" class="w-full" /></AFormItem>
|
||||
<AFormItem label="权重"><AInputNumber v-model="form.weight" :min="1" :max="100" class="w-full" /></AFormItem>
|
||||
<AFormItem label="测试模型"><AInput v-model="form.test_model" placeholder="gpt-4o-mini" /></AFormItem>
|
||||
<!-- 调度 & 状态 -->
|
||||
<ARow :gutter="16">
|
||||
<ACol :span="8">
|
||||
<AFormItem label="优先级"><AInputNumber v-model="form.priority" :min="0" placeholder="越高越优先" class="w-full" /></AFormItem>
|
||||
</ACol>
|
||||
<ACol :span="8">
|
||||
<AFormItem label="权重"><AInputNumber v-model="form.weight" :min="1" :max="100" class="w-full" /></AFormItem>
|
||||
</ACol>
|
||||
<ACol :span="8">
|
||||
<AFormItem label="状态">
|
||||
<ASelect v-model="form.status" :options="[{ label: '启用', value: 'active' }, { label: '禁用', value: 'disabled' }, { label: '测试中', value: 'testing' }]" />
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<ARow :gutter="16">
|
||||
<ACol :span="8">
|
||||
<AFormItem label="测试模型"><AInput v-model="form.test_model" placeholder="gpt-4o-mini" /></AFormItem>
|
||||
</ACol>
|
||||
<ACol :span="8">
|
||||
<AFormItem label="使用代理">
|
||||
<ASwitch v-model="form.use_proxy" />
|
||||
<template #extra><span style="color:var(--color-text-3);font-size:12px">需先在系统设置中配置代理</span></template>
|
||||
</AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<AFormItem label="备注"><AInput v-model="form.remark" type="textarea" :auto-size="{ minRows: 2, maxRows: 4 }" placeholder="备注(选填)" /></AFormItem>
|
||||
<AFormItem label="使用代理"><ASwitch v-model="form.use_proxy" /><template #extra><span style="color:var(--color-text-3);font-size:12px">启用后该渠道的请求通过系统代理转发(需先在系统设置中配置代理地址)</span></template></AFormItem>
|
||||
<!-- VIP 设置 -->
|
||||
<ACollapse :bordered="false" :default-active-key="form.is_vip ? ['vip'] : []">
|
||||
<ACollapseItem header="VIP 设置" key="vip" :header-style="{ fontSize: '13px' }">
|
||||
<AFormItem label="VIP 渠道"><ASwitch v-model="form.is_vip" /></AFormItem>
|
||||
<AFormItem v-if="form.is_vip" label="共享阈值"><AInputNumber v-model="form.sharing_threshold" :min="0" :max="100" placeholder="允许共享给非VIP的健康分数阈值" class="w-full" /></AFormItem>
|
||||
<AFormItem v-if="form.is_vip" label="抢占阈值"><AInputNumber v-model="form.preemption_threshold" :min="0" :max="100" placeholder="抢占共享渠道时的优先级阈值" class="w-full" /></AFormItem>
|
||||
<AFormItem v-if="form.is_vip" label="借用冷却(秒)"><AInputNumber v-model="form.borrowing_cooldown_seconds" :min="0" placeholder="非VIP借用后冷却时间" class="w-full" /></AFormItem>
|
||||
<ARow :gutter="16">
|
||||
<ACol :span="8">
|
||||
<AFormItem label="VIP 渠道"><ASwitch v-model="form.is_vip" /></AFormItem>
|
||||
</ACol>
|
||||
<ACol v-if="form.is_vip" :span="8">
|
||||
<AFormItem label="共享阈值"><AInputNumber v-model="form.sharing_threshold" :min="0" :max="100" placeholder="共享健康分阈值" class="w-full" /></AFormItem>
|
||||
</ACol>
|
||||
<ACol v-if="form.is_vip" :span="8">
|
||||
<AFormItem label="抢占阈值"><AInputNumber v-model="form.preemption_threshold" :min="0" :max="100" placeholder="抢占优先级阈值" class="w-full" /></AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
<ARow v-if="form.is_vip" :gutter="16">
|
||||
<ACol :span="8">
|
||||
<AFormItem label="借用冷却(秒)"><AInputNumber v-model="form.borrowing_cooldown_seconds" :min="0" placeholder="非VIP冷却时间" class="w-full" /></AFormItem>
|
||||
</ACol>
|
||||
</ARow>
|
||||
</ACollapseItem>
|
||||
</ACollapse>
|
||||
</AForm>
|
||||
|
||||
@@ -214,7 +214,7 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
},
|
||||
|
||||
{ title: '更新时间', dataIndex: 'updated_at', width: 170 },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
|
||||
@@ -37,6 +37,8 @@ const platformOptions = [
|
||||
{ label: 'Kling', value: 'kling' },
|
||||
{ label: 'Midjourney', value: 'midjourney' },
|
||||
{ label: 'Suno', value: 'suno' },
|
||||
{ label: '火山引擎', value: 'volcengine' },
|
||||
{ label: '阿里', value: 'ali' },
|
||||
]
|
||||
|
||||
const statusTagColor: Record<string, string | undefined> = {
|
||||
@@ -60,6 +62,8 @@ const platformLabel: Record<string, string> = {
|
||||
kling: 'Kling',
|
||||
midjourney: 'Midjourney',
|
||||
suno: 'Suno',
|
||||
volcengine: '火山引擎',
|
||||
ali: '阿里',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import {
|
||||
Tag, Button, Message,
|
||||
Tag, Message,
|
||||
} from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
|
||||
// === Billing Records ===
|
||||
const billLoading = ref(false)
|
||||
const billData = ref<any[]>([])
|
||||
const billPagination = reactive({
|
||||
@@ -69,80 +68,7 @@ async function fetchBillingRecords() {
|
||||
} finally { billLoading.value = false }
|
||||
}
|
||||
|
||||
// === Wallets ===
|
||||
const walletLoading = ref(false)
|
||||
const walletData = ref<any[]>([])
|
||||
const walletPagination = reactive({
|
||||
current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50],
|
||||
})
|
||||
|
||||
const showAdjustModal = ref(false)
|
||||
const adjustForm = reactive({ tenant_id: 0, amount: 0, description: '' })
|
||||
const adjustLoading = ref(false)
|
||||
|
||||
const walletColumns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '租户ID', dataIndex: 'tenant_id', width: 90 },
|
||||
{
|
||||
title: '余额', dataIndex: 'balance', width: 120,
|
||||
render({ record }) { return h('span', { style: { fontWeight: 600 } }, `$${(record.balance || 0).toFixed(2)}`) },
|
||||
},
|
||||
{
|
||||
title: '冻结余额', dataIndex: 'frozen_balance', width: 110,
|
||||
render({ record }) { return `$${(record.frozen_balance || 0).toFixed(2)}` },
|
||||
},
|
||||
{
|
||||
title: '预警阈值', dataIndex: 'warning_threshold', width: 110,
|
||||
render({ record }) {
|
||||
if (record.warning_threshold === null || record.warning_threshold === undefined) return 'N/A'
|
||||
return `$${record.warning_threshold.toFixed(2)}`
|
||||
},
|
||||
},
|
||||
{ title: '货币', dataIndex: 'currency', width: 70 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
{ title: '更新时间', dataIndex: 'updated_at', width: 170 },
|
||||
{
|
||||
title: '操作', dataIndex: 'actions', width: 100, fixed: 'right',
|
||||
render({ record }) {
|
||||
return h(Button, { size: 'small', type: 'primary', onClick: () => openAdjust(record) }, () => '调整余额')
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
async function fetchWallets() {
|
||||
walletLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/admin/wallets', { params: { page: walletPagination.current, page_size: walletPagination.pageSize } })
|
||||
const walletRaw = res.data?.data
|
||||
walletData.value = walletRaw?.list || []
|
||||
walletPagination.total = walletRaw?.total || 0
|
||||
} catch {
|
||||
walletData.value = []
|
||||
walletPagination.total = 0
|
||||
} finally { walletLoading.value = false }
|
||||
}
|
||||
|
||||
function openAdjust(row: any) {
|
||||
adjustForm.tenant_id = row.tenant_id
|
||||
adjustForm.amount = 0
|
||||
adjustForm.description = ''
|
||||
showAdjustModal.value = true
|
||||
}
|
||||
|
||||
async function handleAdjust(done: () => void) {
|
||||
if (adjustForm.amount === 0) { Message.warning('金额不能为零'); return }
|
||||
adjustLoading.value = true
|
||||
try {
|
||||
await request.post(`/admin/wallets/${adjustForm.tenant_id}/adjust`, { amount: adjustForm.amount, description: adjustForm.description })
|
||||
Message.success('余额调整成功')
|
||||
done()
|
||||
fetchWallets()
|
||||
} catch {
|
||||
return false
|
||||
} finally { adjustLoading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { fetchBillingRecords(); fetchWallets() })
|
||||
onMounted(() => { fetchBillingRecords() })
|
||||
|
||||
const { exporting, exportFile } = useExport({
|
||||
url: '/admin/billing-records/export',
|
||||
@@ -154,7 +80,7 @@ const { exporting, exportFile } = useExport({
|
||||
|
||||
<template>
|
||||
<div class="page-table">
|
||||
<PageHeader title="计费管理" description="查看计费记录和管理租户钱包">
|
||||
<PageHeader title="账单记录" description="查看计费记录">
|
||||
<template #actions>
|
||||
<ADropdown trigger="hover">
|
||||
<AButton :loading="exporting">导出</AButton>
|
||||
@@ -166,44 +92,18 @@ const { exporting, exportFile } = useExport({
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<ATabs default-active-key="records" type="line">
|
||||
<ATabPane key="records" title="计费记录">
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<div class="mb-4">
|
||||
<ASpace>
|
||||
<AInput v-model="billFilterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="fetchBillingRecords" />
|
||||
<AButton type="primary" @click="fetchBillingRecords">搜索</AButton>
|
||||
</ASpace>
|
||||
</div>
|
||||
<ATable :columns="billColumns" :data="billData" :loading="billLoading" :scroll="{ x: 1800 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<APagination v-model:current="billPagination.current" v-model:page-size="billPagination.pageSize" :total="billPagination.total" :page-size-options="billPagination.pageSizeOptions" show-page-size @change="fetchBillingRecords" @page-size-change="(s: number) => { billPagination.pageSize = s; billPagination.current = 1; fetchBillingRecords() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
</ATabPane>
|
||||
|
||||
<ATabPane key="wallets" title="钱包管理">
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<ATable :columns="walletColumns" :data="walletData" :loading="walletLoading" :scroll="{ x: 1200 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<APagination v-model:current="walletPagination.current" v-model:page-size="walletPagination.pageSize" :total="walletPagination.total" :page-size-options="walletPagination.pageSizeOptions" show-page-size @change="fetchWallets" @page-size-change="(s: number) => { walletPagination.pageSize = s; walletPagination.current = 1; fetchWallets() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
</ATabPane>
|
||||
</ATabs>
|
||||
|
||||
<!-- Adjust Balance Modal -->
|
||||
<AModal v-model:visible="showAdjustModal" title="调整余额" :width="400" :on-before-ok="handleAdjust" :ok-loading="adjustLoading">
|
||||
<div class="mb-2 text-sm" style="color: var(--ta-text-tertiary)">租户ID:{{ adjustForm.tenant_id }}</div>
|
||||
<AForm :model="adjustForm" :auto-label-width="true" layout="vertical">
|
||||
<AFormItem label="金额">
|
||||
<AInputNumber v-model="adjustForm.amount" :precision="2" placeholder="正数充值,负数扣减" class="w-full" />
|
||||
</AFormItem>
|
||||
<AFormItem label="描述">
|
||||
<AInput v-model="adjustForm.description" placeholder="调整原因(选填)" />
|
||||
</AFormItem>
|
||||
</AForm>
|
||||
</AModal>
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<div class="mb-4">
|
||||
<ASpace>
|
||||
<AInput v-model="billFilterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="fetchBillingRecords" />
|
||||
<AButton type="primary" @click="fetchBillingRecords">搜索</AButton>
|
||||
</ASpace>
|
||||
</div>
|
||||
<ATable :columns="billColumns" :data="billData" :loading="billLoading" :scroll="{ x: 1800 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<APagination v-model:current="billPagination.current" v-model:page-size="billPagination.pageSize" :total="billPagination.total" :page-size-options="billPagination.pageSizeOptions" show-page-size @change="fetchBillingRecords" @page-size-change="(s: number) => { billPagination.pageSize = s; billPagination.current = 1; fetchBillingRecords() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, h } from 'vue'
|
||||
import { Tag } from '@arco-design/web-vue'
|
||||
import type { TableColumnData } from '@arco-design/web-vue'
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import request from '@/utils/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1, pageSize: 20, total: 0, showPageSize: true, pageSizeOptions: [10, 20, 50],
|
||||
})
|
||||
|
||||
const filterTenantId = ref('')
|
||||
const filterType = ref<string | undefined>(undefined)
|
||||
const filterUsername = ref('')
|
||||
const filterStartDate = ref('')
|
||||
const filterEndDate = ref('')
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '消费', value: 'consume' },
|
||||
{ label: '充值', value: 'recharge' },
|
||||
{ label: '调整', value: 'adjust' },
|
||||
]
|
||||
|
||||
const typeTagColor: Record<string, string> = {
|
||||
consume: 'orangered', recharge: 'green', adjust: 'arcoblue',
|
||||
}
|
||||
const typeLabel: Record<string, string> = {
|
||||
consume: '消费', recharge: '充值', adjust: '调整',
|
||||
}
|
||||
|
||||
const columns: TableColumnData[] = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||
{ title: '租户', dataIndex: 'tenant_name', width: 120, ellipsis: true, tooltip: true },
|
||||
{ title: '用户', dataIndex: 'username', width: 120, ellipsis: true, tooltip: true },
|
||||
{
|
||||
title: '类型', dataIndex: 'type', width: 80,
|
||||
render({ record }) {
|
||||
return h(Tag, { color: typeTagColor[record.type], size: 'small' }, () => typeLabel[record.type] || record.type)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '金额', dataIndex: 'amount', width: 130,
|
||||
render({ record }) {
|
||||
const amount = record.amount || 0
|
||||
const color = amount > 0 ? 'rgb(var(--green-6))' : amount < 0 ? 'rgb(var(--red-6))' : undefined
|
||||
const prefix = amount > 0 ? '+' : ''
|
||||
return h('span', { style: { color, fontWeight: 500, fontFamily: 'monospace' } }, `${prefix}$${Math.abs(amount).toFixed(6)}`)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '变动后余额', dataIndex: 'balance_after', width: 130,
|
||||
render({ record }) { return `$${(record.balance_after || 0).toFixed(6)}` },
|
||||
},
|
||||
{ title: '描述', dataIndex: 'description', width: 180, ellipsis: true, tooltip: true },
|
||||
{ title: '模型', dataIndex: 'model_name', width: 140, ellipsis: true, tooltip: true },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 170 },
|
||||
]
|
||||
|
||||
async function fetchData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: pagination.current, page_size: pagination.pageSize }
|
||||
if (filterTenantId.value) params.tenant_id = parseInt(filterTenantId.value)
|
||||
if (filterType.value) params.type = filterType.value
|
||||
if (filterUsername.value) params.username = filterUsername.value
|
||||
if (filterStartDate.value) params.start_date = filterStartDate.value
|
||||
if (filterEndDate.value) params.end_date = filterEndDate.value
|
||||
const res: any = await request.get('/admin/transactions', { params })
|
||||
const raw = res.data?.data
|
||||
data.value = raw?.list || []
|
||||
pagination.total = raw?.total || 0
|
||||
} catch {
|
||||
data.value = []
|
||||
pagination.total = 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetAndFetch() {
|
||||
pagination.current = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
onMounted(fetchData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-table">
|
||||
<PageHeader title="交易流水" description="查看所有租户的钱包交易记录" />
|
||||
|
||||
<ACard :bordered="false" class="mt-4">
|
||||
<div class="mb-4">
|
||||
<ASpace wrap>
|
||||
<AInput v-model="filterTenantId" placeholder="租户ID" allow-clear style="width: 120px" @keydown.enter="resetAndFetch" />
|
||||
<ASelect v-model="filterType" :options="typeOptions" style="width: 120px" allow-clear @change="resetAndFetch" />
|
||||
<AInput v-model="filterUsername" placeholder="用户名" allow-clear style="width: 120px" @keydown.enter="resetAndFetch" />
|
||||
<ADatePicker v-model="filterStartDate" placeholder="开始日期" style="width: 150px" @change="resetAndFetch" />
|
||||
<ADatePicker v-model="filterEndDate" placeholder="结束日期" style="width: 150px" @change="resetAndFetch" />
|
||||
<AButton type="primary" @click="resetAndFetch">搜索</AButton>
|
||||
</ASpace>
|
||||
</div>
|
||||
<ATable :columns="columns" :data="data" :loading="loading" :scroll="{ x: 1200 }" :bordered="false" :stripe="true" size="small" :pagination="false" row-key="id" />
|
||||
<div class="table-footer">
|
||||
<APagination v-model:current="pagination.current" v-model:page-size="pagination.pageSize" :total="pagination.total" :page-size-options="pagination.pageSizeOptions" show-page-size @change="fetchData" @page-size-change="(s: number) => { pagination.pageSize = s; pagination.current = 1; fetchData() }" />
|
||||
</div>
|
||||
</ACard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--ta-border-light);
|
||||
}
|
||||
</style>
|
||||
@@ -117,12 +117,11 @@ onMounted(() => {
|
||||
.settings-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: 800px;
|
||||
}
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -25,14 +25,14 @@ const values = useFormValues()
|
||||
<div class="section">
|
||||
<div class="section-title">日志保留</div>
|
||||
<div class="section-grid">
|
||||
<AFormItem label="审计日志保留天数">
|
||||
<AFormItem label="审计日志保留天数" class="field-full">
|
||||
<AInputNumber
|
||||
:model-value="values['audit_retention_days'] as number"
|
||||
@change="(v: number | undefined) => values['audit_retention_days'] = String(v ?? 90)"
|
||||
:min="7" :max="3650" style="width: 100%"
|
||||
/>
|
||||
</AFormItem>
|
||||
<AFormItem label="操作日志保留天数">
|
||||
<AFormItem label="操作日志保留天数" class="field-full">
|
||||
<AInputNumber
|
||||
:model-value="values['operation_log_retention_days'] as number"
|
||||
@change="(v: number | undefined) => values['operation_log_retention_days'] = String(v ?? 365)"
|
||||
|
||||
@@ -12,16 +12,16 @@ const values = useFormValues()
|
||||
<span class="switch-label">渠道自动探测</span>
|
||||
<span class="switch-desc">定期向活跃渠道发送测试请求,检测连通性并更新健康度(会消耗少量 Token)</span>
|
||||
<ASwitch
|
||||
:model-value="values['channel_auto_test_enabled'] === 'true' || values['channel_auto_test_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['channel_auto_test_enabled'] = String(v)"
|
||||
:model-value="!!values['channel_auto_test_enabled']"
|
||||
@change="(v: string | number | boolean) => values['channel_auto_test_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="switch-row" style="margin-top: 12px">
|
||||
<span class="switch-label">自动恢复探测</span>
|
||||
<span class="switch-desc">定期测试已自动禁用的渠道,测试通过则恢复启用(依赖自动探测开启)</span>
|
||||
<ASwitch
|
||||
:model-value="values['channel_auto_test_recovery_enabled'] === 'true' || values['channel_auto_test_recovery_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['channel_auto_test_recovery_enabled'] = String(v)"
|
||||
:model-value="!!values['channel_auto_test_recovery_enabled']"
|
||||
@change="(v: string | number | boolean) => values['channel_auto_test_recovery_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,8 +33,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">渠道自动禁用</span>
|
||||
<span class="switch-desc">连续失败达到阈值时自动禁用渠道</span>
|
||||
<ASwitch
|
||||
:model-value="values['channel_auto_disable_enabled'] === 'true' || values['channel_auto_disable_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['channel_auto_disable_enabled'] = String(v)"
|
||||
:model-value="!!values['channel_auto_disable_enabled']"
|
||||
@change="(v: string | number | boolean) => values['channel_auto_disable_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-grid" style="margin-top: 12px">
|
||||
|
||||
@@ -47,8 +47,8 @@ function set(key: string, v: number | undefined): void {
|
||||
<div class="switch-row">
|
||||
<span class="switch-label">启用文件保留期检查</span>
|
||||
<ASwitch
|
||||
:model-value="values['file_retention_enabled'] === 'true' || values['file_retention_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['file_retention_enabled'] = String(v)"
|
||||
:model-value="!!values['file_retention_enabled']"
|
||||
@change="(v: string | number | boolean) => values['file_retention_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -40,8 +40,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">启用 TLS</span>
|
||||
<span class="switch-desc">587/465 端口建议开启</span>
|
||||
<ASwitch
|
||||
:model-value="values['email_smtp_tls'] === 'true' || values['email_smtp_tls'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['email_smtp_tls'] = String(v)"
|
||||
:model-value="!!values['email_smtp_tls']"
|
||||
@change="(v: string | number | boolean) => values['email_smtp_tls'] = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const values = useFormValues()
|
||||
<AFormItem label="站点名称">
|
||||
<AInput v-model="values['site_name']" placeholder="Team-API" />
|
||||
</AFormItem>
|
||||
<AFormItem label="站点描述">
|
||||
<AFormItem label="站点描述" class="field-full">
|
||||
<AInput v-model="values['site_description']" placeholder="简短描述平台用途" />
|
||||
</AFormItem>
|
||||
<AFormItem label="租户控制台地址" help="用于生成邀请链接等,如 https://console.example.com" class="field-full">
|
||||
@@ -28,38 +28,20 @@ const values = useFormValues()
|
||||
<span class="switch-label">开放注册</span>
|
||||
<span class="switch-desc">是否允许新用户注册</span>
|
||||
<ASwitch
|
||||
:model-value="values['register_enabled'] === 'true' || values['register_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['register_enabled'] = String(v)"
|
||||
:model-value="!!values['register_enabled']"
|
||||
@change="(v: string | number | boolean) => values['register_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<span class="switch-label">注册邮箱验证</span>
|
||||
<span class="switch-desc">注册时是否需要邮箱验证码,关闭时使用滑块验证</span>
|
||||
<ASwitch
|
||||
:model-value="values['register_email_verification'] === 'true' || values['register_email_verification'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['register_email_verification'] = String(v)"
|
||||
:model-value="!!values['register_email_verification']"
|
||||
@change="(v: string | number | boolean) => values['register_email_verification'] = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 演示模式 -->
|
||||
<div class="section">
|
||||
<div class="section-title">演示模式</div>
|
||||
<div class="switch-row">
|
||||
<span class="switch-label">启用演示模式</span>
|
||||
<span class="switch-desc">开启后所有写操作被拦截,仅允许只读访问</span>
|
||||
<ASwitch
|
||||
:model-value="values['demo_mode'] === 'true' || values['demo_mode'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['demo_mode'] = String(v)"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-grid" style="margin-top: 12px">
|
||||
<AFormItem label="演示模式提示">
|
||||
<AInput v-model="values['demo_message']" placeholder="演示环境,数据不可修改" />
|
||||
</AFormItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维护模式 -->
|
||||
<div class="section">
|
||||
<div class="section-title">维护模式</div>
|
||||
@@ -67,8 +49,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">启用维护模式</span>
|
||||
<span class="switch-desc">开启后控制台显示维护提示</span>
|
||||
<ASwitch
|
||||
:model-value="values['maintenance_mode'] === 'true' || values['maintenance_mode'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['maintenance_mode'] = String(v)"
|
||||
:model-value="!!values['maintenance_mode']"
|
||||
@change="(v: string | number | boolean) => values['maintenance_mode'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-grid" style="margin-top: 12px">
|
||||
@@ -83,8 +65,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">全局 API 维护</span>
|
||||
<span class="switch-desc">开启后 API 代理返回 503</span>
|
||||
<ASwitch
|
||||
:model-value="values['api_maintenance_enabled'] === 'true' || values['api_maintenance_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['api_maintenance_enabled'] = String(v)"
|
||||
:model-value="!!values['api_maintenance_enabled']"
|
||||
@change="(v: string | number | boolean) => values['api_maintenance_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">OAuth 自动注册</span>
|
||||
<span class="switch-desc">首次 OAuth 登录自动创建租户账号</span>
|
||||
<ASwitch
|
||||
:model-value="values['oauth_auto_register'] === 'true' || values['oauth_auto_register'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['oauth_auto_register'] = String(v)"
|
||||
:model-value="!!values['oauth_auto_register']"
|
||||
@change="(v: string | number | boolean) => values['oauth_auto_register'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-grid" style="margin-top: 12px">
|
||||
@@ -30,8 +30,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">启用 GitHub 登录</span>
|
||||
<span class="switch-desc"></span>
|
||||
<ASwitch
|
||||
:model-value="values['oauth_github_enabled'] === 'true' || values['oauth_github_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['oauth_github_enabled'] = String(v)"
|
||||
:model-value="!!values['oauth_github_enabled']"
|
||||
@change="(v: string | number | boolean) => values['oauth_github_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-grid" style="margin-top: 12px">
|
||||
@@ -51,8 +51,8 @@ const values = useFormValues()
|
||||
<span class="switch-label">启用 Google 登录</span>
|
||||
<span class="switch-desc"></span>
|
||||
<ASwitch
|
||||
:model-value="values['oauth_google_enabled'] === 'true' || values['oauth_google_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['oauth_google_enabled'] = String(v)"
|
||||
:model-value="!!values['oauth_google_enabled']"
|
||||
@change="(v: string | number | boolean) => values['oauth_google_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="section-grid" style="margin-top: 12px">
|
||||
|
||||
@@ -65,8 +65,8 @@ function set(key: string, v: number | undefined): void {
|
||||
<div class="compact-row">
|
||||
<label>启用缓存</label>
|
||||
<ASwitch
|
||||
:model-value="values['cache_enabled'] === 'true' || values['cache_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['cache_enabled'] = String(v)"
|
||||
:model-value="!!values['cache_enabled']"
|
||||
@change="(v: string | number | boolean) => values['cache_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="compact-row">
|
||||
@@ -107,8 +107,8 @@ function set(key: string, v: number | undefined): void {
|
||||
<div class="compact-row">
|
||||
<label>启用沙箱</label>
|
||||
<ASwitch
|
||||
:model-value="values['sandbox_enabled'] === 'true' || values['sandbox_enabled'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['sandbox_enabled'] = String(v)"
|
||||
:model-value="!!values['sandbox_enabled']"
|
||||
@change="(v: string | number | boolean) => values['sandbox_enabled'] = v"
|
||||
/>
|
||||
</div>
|
||||
<div class="compact-row">
|
||||
|
||||
@@ -89,7 +89,7 @@ const values = useFormValues()
|
||||
</AFormItem>
|
||||
<AFormItem label="新设备登录通知">
|
||||
<ASwitch
|
||||
:model-value="values['new_device_notification'] !== false"
|
||||
:model-value="!!values['new_device_notification']"
|
||||
@change="(v: string | number | boolean) => values['new_device_notification'] = v"
|
||||
/>
|
||||
</AFormItem>
|
||||
|
||||
@@ -47,8 +47,8 @@ const values = useFormValues()
|
||||
<div class="switch-row" style="margin-top: 8px">
|
||||
<span class="switch-label">启用 SSL</span>
|
||||
<ASwitch
|
||||
:model-value="values['storage_use_ssl'] === 'true' || values['storage_use_ssl'] === '1'"
|
||||
@change="(v: string | number | boolean) => values['storage_use_ssl'] = String(v)"
|
||||
:model-value="!!values['storage_use_ssl']"
|
||||
@change="(v: string | number | boolean) => values['storage_use_ssl'] = v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,12 +19,7 @@ export function useSettings(category: () => string) {
|
||||
const items = res.data?.data?.list || []
|
||||
const vals: Record<string, any> = {}
|
||||
for (const item of items) {
|
||||
let val = item.value ?? item.default ?? ''
|
||||
// Normalize bool values to strings for consistent switch handling
|
||||
if (item.type === 'bool') {
|
||||
val = String(val)
|
||||
}
|
||||
vals[item.key] = val
|
||||
vals[item.key] = item.value ?? item.default ?? ''
|
||||
}
|
||||
Object.keys(formValues).forEach(k => delete formValues[k])
|
||||
Object.assign(formValues, vals)
|
||||
|
||||
@@ -161,9 +161,9 @@ const columns: TableColumnData[] = [
|
||||
},
|
||||
{ title: '所属租户', dataIndex: 'tenant_name', width: 130, ellipsis: true },
|
||||
{ title: '租户代码', dataIndex: 'tenant_code', width: 100 },
|
||||
{ title: '最后登录', dataIndex: 'last_login_at', width: 170 },
|
||||
{ title: '最后登录', dataIndex: 'last_login_at', width: 180 },
|
||||
{ title: '登录IP', dataIndex: 'last_login_ip', width: 130 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
dataIndex: 'actions',
|
||||
|
||||
@@ -221,7 +221,7 @@ const columns: TableColumnData[] = [
|
||||
render({ record }) { return record.api_key_name || record.api_key_id || '-' },
|
||||
},
|
||||
{
|
||||
title: '模型', dataIndex: 'model_name', width: 160,
|
||||
title: '模型', dataIndex: 'model_name', width: 200,
|
||||
render({ record }) {
|
||||
if (hasUpstreamModel(record)) {
|
||||
return h('div', [
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue?: string
|
||||
siteKey?: string
|
||||
theme?: 'light' | 'dark' | 'auto'
|
||||
size?: 'normal' | 'compact'
|
||||
tabindex?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
siteKey: '',
|
||||
theme: 'auto',
|
||||
size: 'normal',
|
||||
tabindex: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'verified', token: string): void
|
||||
(e: 'error', error: any): void
|
||||
}>()
|
||||
|
||||
const containerId = `turnstile-container-${Math.random().toString(36).substr(2, 9)}`
|
||||
const isLoaded = ref(false)
|
||||
const isVerifying = ref(false)
|
||||
const turnstileScriptId = 'turnstile-script'
|
||||
let widgetId: string | null = null
|
||||
|
||||
// Load Cloudflare Turnstile script
|
||||
function loadTurnstileScript(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (window.turnstile) {
|
||||
isLoaded.value = true
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = turnstileScriptId
|
||||
script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.onload = () => {
|
||||
isLoaded.value = true
|
||||
resolve()
|
||||
}
|
||||
script.onerror = () => {
|
||||
reject(new Error('Failed to load Turnstile script'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
// Render Turnstile widget
|
||||
function renderWidget() {
|
||||
if (!window.turnstile || !props.siteKey) return
|
||||
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) return
|
||||
|
||||
// Clear existing widget
|
||||
if (widgetId !== null) {
|
||||
window.turnstile.remove(widgetId)
|
||||
}
|
||||
|
||||
// Render new widget
|
||||
widgetId = window.turnstile.render(container, {
|
||||
sitekey: props.siteKey,
|
||||
theme: props.theme,
|
||||
size: props.size,
|
||||
tabindex: props.tabindex,
|
||||
callback: (token: string) => {
|
||||
isVerifying.value = false
|
||||
emit('update:modelValue', token)
|
||||
emit('verified', token)
|
||||
},
|
||||
'error-callback': (error: any) => {
|
||||
isVerifying.value = false
|
||||
emit('error', error)
|
||||
},
|
||||
'expired-callback': () => {
|
||||
widgetId = null
|
||||
emit('update:modelValue', '')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Reset widget
|
||||
function reset() {
|
||||
if (widgetId !== null && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
}
|
||||
|
||||
// Expose reset method
|
||||
defineExpose({
|
||||
reset,
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadTurnstileScript()
|
||||
renderWidget()
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Turnstile:', error)
|
||||
emit('error', error)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (widgetId !== null && window.turnstile) {
|
||||
window.turnstile.remove(widgetId)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for siteKey changes
|
||||
watch(() => props.siteKey, () => {
|
||||
if (isLoaded.value) {
|
||||
renderWidget()
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for modelValue changes from parent
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (!newValue && widgetId !== null && window.turnstile) {
|
||||
window.turnstile.reset(widgetId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="containerId"
|
||||
class="turnstile-widget"
|
||||
:class="{ 'turnstile-loading': isVerifying }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.turnstile-widget {
|
||||
min-height: 65px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.turnstile-loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
</style>
|
||||
@@ -29,10 +29,10 @@
|
||||
<!-- Logo/Brand -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="flex items-center gap-3 justify-center">
|
||||
<img src="/favicon.png" alt="Team-API" class="h-10 w-10 rounded-lg object-contain" />
|
||||
<h1 class="text-gradient text-2xl font-bold leading-tight">Team API</h1>
|
||||
<img src="/favicon.png" :alt="siteName" class="h-10 w-10 rounded-lg object-contain" />
|
||||
<h1 class="text-gradient text-2xl font-bold leading-tight">{{ siteName }}</h1>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-500">开源自托管的多租户 大模型 API 网关平台</p>
|
||||
<p class="mt-3 text-sm text-gray-500">{{ publicSettings.site_description || '开源自托管的多租户 大模型 API 网关平台' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Container -->
|
||||
@@ -59,7 +59,10 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import Icon from '../common/Icon.vue'
|
||||
import AnnouncementBanner from '../common/AnnouncementBanner.vue'
|
||||
import request from '@/utils/request'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
const { settings: publicSettings } = usePublicSettings()
|
||||
const siteName = publicSettings.value.site_name || 'Team-API'
|
||||
const announcements = ref<any[]>([])
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ const settings = ref<PublicSettings>({
|
||||
site_description: '',
|
||||
demo_mode: false,
|
||||
demo_message: '',
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
})
|
||||
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
|
||||
@@ -3,13 +3,14 @@ import { useSeoMeta, useHead } from '@unhead/vue'
|
||||
interface SeoOptions {
|
||||
title: string
|
||||
description: string
|
||||
siteName?: string
|
||||
keywords?: string
|
||||
canonicalUrl?: string
|
||||
ogImage?: string
|
||||
ogType?: 'website' | 'article' | 'profile' | 'book'
|
||||
}
|
||||
|
||||
const SITE_NAME = 'Team-API'
|
||||
const DEFAULT_SITE_NAME = 'Team-API'
|
||||
const DEFAULT_OG_IMAGE = '/og-image.png'
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export function useSeo(options: SeoOptions) {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
siteName = DEFAULT_SITE_NAME,
|
||||
keywords,
|
||||
canonicalUrl,
|
||||
ogImage = DEFAULT_OG_IMAGE,
|
||||
@@ -33,7 +35,7 @@ export function useSeo(options: SeoOptions) {
|
||||
ogDescription: description,
|
||||
keywords,
|
||||
ogType,
|
||||
ogSiteName: SITE_NAME,
|
||||
ogSiteName: siteName,
|
||||
ogLocale: 'zh_CN',
|
||||
ogImage,
|
||||
twitterCard: 'summary_large_image',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RouteRecordRaw } from 'vue-router'
|
||||
import tenantRoutes from './tenant'
|
||||
import { useTenantAuthStore } from '@/stores/tenant-auth'
|
||||
import { shouldRefresh, getRefreshToken } from '@/utils/request'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -33,7 +34,9 @@ router.beforeEach((to) => {
|
||||
tenantAuthStore.loadFromStorage()
|
||||
|
||||
if (to.meta.title) {
|
||||
document.title = to.name === 'TenantHome' ? to.meta.title : `${to.meta.title} — Team-API`
|
||||
const { settings: publicSettings } = usePublicSettings()
|
||||
const siteName = publicSettings.value.site_name || 'Team-API'
|
||||
document.title = to.name === 'TenantHome' ? `${siteName} — 企业级多租户大模型 API 网关平台 | 开源自托管` : `${to.meta.title} — ${siteName}`
|
||||
}
|
||||
|
||||
// Auth pages — always allow
|
||||
|
||||
@@ -177,6 +177,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
await fetchPublicSettings()
|
||||
if (publicSettings.site_name && route.meta.title) {
|
||||
document.title = `${pageTitle.value} — ${publicSettings.site_name}`
|
||||
}
|
||||
if (publicSettings.demo_mode) {
|
||||
demoMessage.value = publicSettings.demo_message || '演示环境,数据不可修改'
|
||||
mountWatermark(document.body)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useTenantAuthStore } from '@/stores/tenant-auth'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
@@ -8,6 +8,7 @@ import request from '@/utils/request'
|
||||
import AuthLayout from '@/components/layout/AuthLayout.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
|
||||
import Turnstile from '@/components/common/Turnstile.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -35,6 +36,7 @@ const totpLoading = ref(false)
|
||||
|
||||
// Captcha state — always required
|
||||
const captcha = reactive({ captchaKey: '', captchaX: 0 })
|
||||
const turnstileToken = ref('')
|
||||
const captchaRef = ref<InstanceType<typeof SlideCaptcha> | null>(null)
|
||||
|
||||
const emailInput = ref<HTMLInputElement | null>(null)
|
||||
@@ -181,6 +183,23 @@ function clearRamError(field: string) {
|
||||
delete ramErrors[field]
|
||||
if (errorMsg.value) errorMsg.value = ''
|
||||
}
|
||||
|
||||
const hasOAuthProviders = computed(() => {
|
||||
return settings.value['oauth_github_enabled'] || settings.value['oauth_google_enabled']
|
||||
})
|
||||
|
||||
async function handleOAuthLogin(provider: string) {
|
||||
try {
|
||||
const res = await request.get('/tenant/oauth/authorize', { params: { provider } })
|
||||
const { authorize_url } = res.data?.data || {}
|
||||
if (authorize_url) {
|
||||
window.location.href = authorize_url
|
||||
}
|
||||
} catch (e: any) {
|
||||
const apiErr = extractApiError(e)
|
||||
errorMsg.value = apiErr?.message || '第三方登录失败'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -417,6 +436,37 @@ function clearRamError(field: string) {
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- OAuth Login -->
|
||||
<div v-if="hasOAuthProviders" class="mt-6">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-200"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-xs">
|
||||
<span class="bg-white px-3 text-gray-400">或使用第三方账号登录</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<button
|
||||
v-if="settings['oauth_github_enabled']"
|
||||
type="button"
|
||||
class="btn btn-secondary flex-1"
|
||||
@click="handleOAuthLogin('github')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
GitHub
|
||||
</button>
|
||||
<button
|
||||
v-if="settings['oauth_google_enabled']"
|
||||
type="button"
|
||||
class="btn btn-secondary flex-1"
|
||||
@click="handleOAuthLogin('google')"
|
||||
>
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
||||
Google
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="settings.register_enabled" #footer>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTenantAuthStore } from '@/stores/tenant-auth'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
import AuthLayout from '@/components/layout/AuthLayout.vue'
|
||||
import SlideCaptcha from '@/components/common/SlideCaptcha.vue'
|
||||
import Turnstile from '@/components/common/Turnstile.vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import request from '@/utils/request'
|
||||
import { extractApiError } from '@/utils/request'
|
||||
@@ -19,6 +20,7 @@ const codeSending = ref(false)
|
||||
const countdown = ref(0)
|
||||
const emailVerification = ref(true)
|
||||
const captcha = ref<{ captchaKey: string; captchaX: number }>({ captchaKey: '', captchaX: 0 })
|
||||
const turnstileToken = ref('')
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const orgForm = reactive({ orgName: '', orgCode: '' })
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<img src="/favicon.png" alt="Team-API" class="h-8 w-8 rounded-lg" />
|
||||
<span class="text-lg font-bold tracking-tight" :class="isScrolled ? 'text-gray-900' : 'text-white'">Team-API</span>
|
||||
<img src="/favicon.png" :alt="siteName" class="h-8 w-8 rounded-lg" />
|
||||
<span class="text-lg font-bold tracking-tight" :class="isScrolled ? 'text-gray-900' : 'text-white'">{{ siteName }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="https://github.com/qianfree/team-api" target="_blank" rel="noopener noreferrer"
|
||||
@@ -240,7 +240,7 @@
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<img src="/favicon.png" alt="Team-API" class="h-5 w-5 rounded" />
|
||||
<img src="/favicon.png" :alt="siteName" class="h-5 w-5 rounded" />
|
||||
<span class="text-xs text-gray-600">© 2026 qianfree. Released under AGPL-3.0.</span>
|
||||
</div>
|
||||
<a href="https://github.com/qianfree/team-api" target="_blank" rel="noopener noreferrer"
|
||||
@@ -257,6 +257,10 @@
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useSeo } from '@/composables/useSeo'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
const { settings: publicSettings } = usePublicSettings()
|
||||
const siteName = publicSettings.value.site_name || 'Team-API'
|
||||
|
||||
// Nav scroll state
|
||||
const isScrolled = ref(false)
|
||||
@@ -275,8 +279,9 @@ const activeScenario = ref(0)
|
||||
|
||||
// SEO
|
||||
useSeo({
|
||||
title: 'Team-API — 一个 API 接入所有大模型 | 企业级多租户 AI 网关',
|
||||
description: 'Team-API 是开源自托管的企业级多租户大模型 API 网关平台。聚合 OpenAI、Claude、Gemini、DeepSeek 等 40+ 供应商,内置计费引擎、团队管理、用量审计与智能渠道调度。完全兼容 OpenAI SDK,只需修改 base_url 即可接入。',
|
||||
title: `${siteName} — 一个 API 接入所有大模型 | 企业级多租户 AI 网关`,
|
||||
description: publicSettings.value.site_description || `${siteName} 是开源自托管的企业级多租户大模型 API 网关平台。聚合 OpenAI、Claude、Gemini、DeepSeek 等 40+ 供应商,内置计费引擎、团队管理、用量审计与智能渠道调度。完全兼容 OpenAI SDK,只需修改 base_url 即可接入。`,
|
||||
siteName,
|
||||
keywords: 'Team-API, 大模型网关, API Gateway, 多租户, OpenAI, Claude, Gemini, DeepSeek, 阿里云百炼, 百度文心, 腾讯混元, 智谱AI, AI代理, LLM Gateway, 开源, 自托管, 计费引擎, 团队管理, API管理, SSE流式, 渠道调度',
|
||||
canonicalUrl: 'https://team-api.net/',
|
||||
})
|
||||
@@ -288,8 +293,8 @@ useHead({
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Team-API',
|
||||
description: '企业级多租户大模型 API 网关平台,聚合 40+ 大模型供应商,内置计费引擎、团队管理、用量审计与渠道调度。',
|
||||
name: siteName,
|
||||
description: publicSettings.value.site_description || '企业级多租户大模型 API 网关平台,聚合 40+ 大模型供应商,内置计费引擎、团队管理、用量审计与渠道调度。',
|
||||
url: 'https://github.com/qianfree/team-api',
|
||||
applicationCategory: 'DeveloperApplication',
|
||||
operatingSystem: 'Linux, macOS, Windows',
|
||||
@@ -311,7 +316,7 @@ useHead({
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: 'Team-API',
|
||||
name: siteName,
|
||||
url: 'https://github.com/qianfree/team-api',
|
||||
logo: 'https://team-api.net/favicon.png',
|
||||
sameAs: ['https://github.com/qianfree/team-api'],
|
||||
@@ -322,9 +327,9 @@ useHead({
|
||||
innerHTML: JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Team-API',
|
||||
name: siteName,
|
||||
url: 'https://team-api.net/',
|
||||
description: '企业级多租户大模型 API 网关平台',
|
||||
description: publicSettings.value.site_description || '企业级多租户大模型 API 网关平台',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://github.com/qianfree/team-api/search?q={search_term_string}',
|
||||
|
||||
@@ -5,6 +5,7 @@ import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import BaseSelect from '../../components/common/BaseSelect.vue'
|
||||
import request from '@/utils/request'
|
||||
import { useExport } from '@/composables/useExport'
|
||||
|
||||
interface TaskItem {
|
||||
id: number
|
||||
@@ -40,6 +41,15 @@ const showDetail = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailTask = ref<TaskItem | null>(null)
|
||||
|
||||
const { exporting, exportFile } = useExport({
|
||||
url: '/tenant/tasks/export',
|
||||
getFilters: () => ({
|
||||
status: filterStatus.value || undefined,
|
||||
platform: filterPlatform.value || undefined,
|
||||
public_task_id: filterTaskId.value || undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
const statusBadge: Record<string, string> = {
|
||||
NOT_START: 'bg-gray-100 text-gray-800',
|
||||
SUBMITTED: 'bg-blue-100 text-blue-800',
|
||||
@@ -63,6 +73,8 @@ const platformLabel: Record<string, string> = {
|
||||
kling: 'Kling',
|
||||
midjourney: 'Midjourney',
|
||||
suno: 'Suno',
|
||||
volcengine: '火山引擎',
|
||||
ali: '阿里',
|
||||
}
|
||||
|
||||
function formatCost(n: number | undefined): string {
|
||||
@@ -153,11 +165,20 @@ onMounted(() => {
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="page-header flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="page-title">任务日志</h1>
|
||||
<p class="page-description">查看异步生成任务(视频/图片/音乐)的执行记录</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="exportFile('csv')">
|
||||
<Icon v-if="exporting" name="refresh" size="xs" class="animate-spin" />
|
||||
{{ exporting ? '导出中...' : '导出 CSV' }}
|
||||
</button>
|
||||
<button class="btn btn-secondary btn-sm" :disabled="exporting || loading" @click="exportFile('xlsx')">
|
||||
{{ exporting ? '导出中...' : '导出 Excel' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -174,7 +195,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-500 whitespace-nowrap">平台</label>
|
||||
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'}]" container-class="w-[120px]" />
|
||||
<BaseSelect v-model="filterPlatform" :options="[{value:'',label:'全部'},{value:'sora',label:'Sora'},{value:'kling',label:'Kling'},{value:'midjourney',label:'Midjourney'},{value:'suno',label:'Suno'},{value:'volcengine',label:'火山引擎'},{value:'ali',label:'阿里'}]" container-class="w-[120px]" />
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="applyFilters">搜索</button>
|
||||
@@ -199,25 +220,25 @@ onMounted(() => {
|
||||
|
||||
<div v-else>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<table class="table table-fixed w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 ID</th>
|
||||
<th>平台</th>
|
||||
<th>状态</th>
|
||||
<th>模型</th>
|
||||
<th>费用</th>
|
||||
<th>提交时间</th>
|
||||
<th>完成时间</th>
|
||||
<th></th>
|
||||
<th class="w-52">任务 ID</th>
|
||||
<th class="w-24">平台</th>
|
||||
<th class="w-20">状态</th>
|
||||
<th class="w-44">模型</th>
|
||||
<th class="w-25">费用</th>
|
||||
<th class="w-38">提交时间</th>
|
||||
<th class="w-38">完成时间</th>
|
||||
<th class="w-15">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id">
|
||||
<td>
|
||||
<td class="truncate" :title="task.public_task_id">
|
||||
<span class="font-mono text-xs text-gray-600">{{ task.public_task_id }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="truncate">
|
||||
<span class="text-sm font-medium text-gray-700">{{ platformLabel[task.platform] || task.platform }}</span>
|
||||
</td>
|
||||
<td>
|
||||
@@ -225,19 +246,19 @@ onMounted(() => {
|
||||
{{ statusLabel[task.status] || task.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="truncate" :title="task.model_name">
|
||||
<span class="text-sm text-gray-700">{{ task.model_name || '-' }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<td class="truncate">
|
||||
<span v-if="task.billing_settled && task.actual_cost > 0" class="text-sm font-medium text-emerald-600">{{ formatCost(task.actual_cost) }}</span>
|
||||
<span v-else-if="task.pre_deduct_amount > 0" class="text-sm text-gray-500">{{ formatCost(task.pre_deduct_amount) }} <span class="text-xs text-gray-400">(预扣)</span></span>
|
||||
<span v-else class="text-sm text-gray-400">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap">{{ formatTime(task.submit_time) }}</span>
|
||||
<td class="truncate">
|
||||
<span class="text-xs text-gray-500">{{ formatTime(task.submit_time) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-xs text-gray-500 whitespace-nowrap">{{ formatTime(task.finish_time) }}</span>
|
||||
<td class="truncate">
|
||||
<span class="text-xs text-gray-500">{{ formatTime(task.finish_time) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-ghost btn-sm p-1.5" title="查看详情" @click="openDetail(task)">
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import Icon from '@/components/common/Icon.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import request from '@/utils/request'
|
||||
import { usePublicSettings } from '@/composables/usePublicSettings'
|
||||
|
||||
const loading = ref(false)
|
||||
const userInfo = ref<any>(null)
|
||||
const sessions = ref<any[]>([])
|
||||
const { settings } = usePublicSettings()
|
||||
|
||||
// OAuth state
|
||||
const oauthProviders = ref<any[]>([])
|
||||
const oauthLoading = ref(false)
|
||||
const linkProvider = ref('')
|
||||
const linkCode = ref('')
|
||||
const linkLoading = ref(false)
|
||||
const linkError = ref('')
|
||||
|
||||
const editingDisplayName = ref(false)
|
||||
const displayNameForm = reactive({
|
||||
@@ -139,7 +149,88 @@ function parseDeviceInfo(deviceInfo: string): string {
|
||||
onMounted(() => {
|
||||
fetchProfile()
|
||||
fetchSessions()
|
||||
fetchOAuthProviders()
|
||||
})
|
||||
|
||||
async function fetchOAuthProviders() {
|
||||
oauthLoading.value = true
|
||||
try {
|
||||
const res: any = await request.get('/tenant/oauth/providers')
|
||||
oauthProviders.value = res.data?.data?.list || []
|
||||
} catch {
|
||||
oauthProviders.value = []
|
||||
} finally {
|
||||
oauthLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasOAuthEnabled = computed(() => {
|
||||
return settings.value['oauth_github_enabled'] || settings.value['oauth_google_enabled']
|
||||
})
|
||||
|
||||
function isProviderLinked(provider: string): boolean {
|
||||
return oauthProviders.value.some((p: any) => p.provider === provider)
|
||||
}
|
||||
|
||||
function getLinkedProvider(provider: string) {
|
||||
return oauthProviders.value.find((p: any) => p.provider === provider)
|
||||
}
|
||||
|
||||
async function handleOAuthLink(provider: string) {
|
||||
linkLoading.value = true
|
||||
linkError.value = ''
|
||||
try {
|
||||
const res: any = await request.get('/tenant/oauth/authorize', { params: { provider } })
|
||||
const { authorize_url, state } = res.data?.data || {}
|
||||
if (authorize_url) {
|
||||
// Open OAuth in popup window
|
||||
const width = 600
|
||||
const height = 700
|
||||
const left = (window.innerWidth - width) / 2
|
||||
const top = (window.innerHeight - height) / 2
|
||||
const popup = window.open(
|
||||
authorize_url,
|
||||
'oauth',
|
||||
`width=${width},height=${height},left=${left},top=${top}`
|
||||
)
|
||||
// Listen for callback message
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
if (event.data?.type === 'oauth-callback') {
|
||||
window.removeEventListener('message', handleMessage)
|
||||
if (event.data.code) {
|
||||
try {
|
||||
await request.post('/tenant/oauth/link', {
|
||||
provider,
|
||||
code: event.data.code,
|
||||
})
|
||||
await fetchOAuthProviders()
|
||||
} catch (e: any) {
|
||||
const res = e?.response?.data
|
||||
linkError.value = res?.message || '绑定失败'
|
||||
}
|
||||
}
|
||||
popup?.close()
|
||||
linkLoading.value = false
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handleMessage)
|
||||
}
|
||||
} catch (e: any) {
|
||||
const res = e?.response?.data
|
||||
linkError.value = res?.message || '绑定失败'
|
||||
} finally {
|
||||
linkLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuthUnlink(provider: string) {
|
||||
try {
|
||||
await request.post('/tenant/oauth/unlink', { provider })
|
||||
await fetchOAuthProviders()
|
||||
} catch {
|
||||
// error toast shown by interceptor
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -259,6 +350,45 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- OAuth Linked Accounts Card -->
|
||||
<div v-if="hasOAuthEnabled" class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="font-semibold text-gray-900">第三方账号</h2>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-100">
|
||||
<div v-if="settings['oauth_github_enabled']" class="px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-xl bg-gray-100 flex items-center justify-center">
|
||||
<svg class="h-5 w-5 text-gray-700" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">GitHub</p>
|
||||
<p v-if="isProviderLinked('github')" class="text-xs text-gray-500 mt-0.5">已绑定:{{ getLinkedProvider('github')?.provider_username || getLinkedProvider('github')?.provider_user_id }}</p>
|
||||
<p v-else class="text-xs text-gray-500 mt-0.5">未绑定</p>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="isProviderLinked('github')" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" @click="handleOAuthUnlink('github')">解绑</button>
|
||||
<button v-else class="btn btn-secondary btn-sm" :disabled="linkLoading" @click="handleOAuthLink('github')">绑定</button>
|
||||
</div>
|
||||
<div v-if="settings['oauth_google_enabled']" class="px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-xl bg-gray-100 flex items-center justify-center">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900">Google</p>
|
||||
<p v-if="isProviderLinked('google')" class="text-xs text-gray-500 mt-0.5">已绑定:{{ getLinkedProvider('google')?.provider_username || getLinkedProvider('google')?.provider_user_id }}</p>
|
||||
<p v-else class="text-xs text-gray-500 mt-0.5">未绑定</p>
|
||||
</div>
|
||||
</div>
|
||||
<button v-if="isProviderLinked('google')" class="btn btn-ghost btn-sm text-red-600 hover:bg-red-50" @click="handleOAuthUnlink('google')">解绑</button>
|
||||
<button v-else class="btn btn-secondary btn-sm" :disabled="linkLoading" @click="handleOAuthLink('google')">绑定</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="linkError" class="px-6 pb-4 text-xs text-red-500">{{ linkError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
||||
Reference in new issue
Block a user