From c8b60cea078f8a7093d6186f89febb6c3a10d9b4 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Sun, 30 Aug 2026 11:10:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=AE=A1=E7=90=86=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20-=20=E5=AE=8C=E5=96=84=E9=85=8D=E7=BD=AE=E8=BF=90?= =?UTF-8?q?=E8=90=A5=E9=A1=B5=E9=9D=A2=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E3=80=81=E7=AD=9B=E9=80=89=E4=B8=8E=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/model/models_channel.go | 3 + backend/internal/repository/finance.go | 15 + backend/internal/service/analytics.go | 34 +- .../internal/service/api_log_billing_test.go | 66 ++ web/package.json | 1 + web/src/pages/admin/admin-route-pages.tsx | 6 +- .../components/access-settings-panel.tsx | 222 +++--- .../components/admin-announcement-safety.ts | 4 + .../components/admin-announcements-panel.tsx | 257 ++++++- .../pages/admin/components/admin-shell.tsx | 17 +- web/src/pages/admin/components/admin-ui.tsx | 8 +- .../components/api-log-detail-drawer.tsx | 76 +- .../components/credit-operations-panel.tsx | 1 + .../admin/components/email-settings-panel.tsx | 299 ++++---- .../components/feature-availability-panel.tsx | 311 ++------ .../components/storage-resources-panel.tsx | 282 +++++-- web/src/pages/admin/logs/logs-page.tsx | 249 ++++++- .../ark-private-assets-settings-page.tsx | 275 ++----- .../settings/drawing-engine-settings-page.tsx | 112 +-- .../admin/settings/libtv-settings-page.tsx | 271 ++++--- .../response-interception-settings-page.tsx | 361 ++++----- .../admin/settings/storage-settings-page.tsx | 281 +++++-- web/src/services/api/auth.ts | 18 +- web/src/styles/admin-ui.css | 703 ++++++++++++------ web/test/admin-ui-regressions.test.ts | 170 +++++ web/test/generation-task-local.test.ts | 4 +- 26 files changed, 2468 insertions(+), 1578 deletions(-) create mode 100644 backend/internal/service/api_log_billing_test.go create mode 100644 web/test/admin-ui-regressions.test.ts diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 61894299b..ff15323ce 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -93,6 +93,9 @@ type ApiCallLog struct { TaskID string `json:"taskId,omitempty" gorm:"index;size:36"` TaskStatus TaskStatus `json:"taskStatus,omitempty" gorm:"-"` BillingOrderID string `json:"billingOrderId,omitempty" gorm:"index;size:36"` + BillingStatus BillingStatus `json:"billingStatus,omitempty" gorm:"-"` + BillingAmount int64 `json:"billingAmountMicrocredits" gorm:"-"` + BillingAvailable bool `json:"billingAvailable" gorm:"-"` Source string `json:"source" gorm:"index;size:64"` Capability string `json:"capability" gorm:"index;size:32"` Operation string `json:"operation" gorm:"size:64"` diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 33849be4e..07369c895 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -477,6 +477,21 @@ func (r *Repository) BillingOrder(id string) (*model.BillingOrder, error) { return &order, nil } +func (r *Repository) BillingOrdersByIDs(ids []string) (map[string]model.BillingOrder, error) { + result := make(map[string]model.BillingOrder, len(ids)) + if len(ids) == 0 { + return result, nil + } + var orders []model.BillingOrder + if err := r.db.Where("id IN ?", ids).Find(&orders).Error; err != nil { + return nil, err + } + for _, order := range orders { + result[order.ID] = order + } + return result, nil +} + func (r *Repository) BillingOrdersByTaskIDs(userID string, taskIDs []string) (map[string]model.BillingOrder, error) { result := make(map[string]model.BillingOrder, len(taskIDs)) if len(taskIDs) == 0 { diff --git a/backend/internal/service/analytics.go b/backend/internal/service/analytics.go index 7075d20a1..3f3dac780 100644 --- a/backend/internal/service/analytics.go +++ b/backend/internal/service/analytics.go @@ -230,9 +230,17 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { for _, user := range users { userByID[user.ID] = user } + billingOrderIDs := make([]string, 0, len(logs)) + seenBillingOrderIDs := make(map[string]struct{}, len(logs)) taskIDs := make([]string, 0, len(logs)) seenTaskIDs := make(map[string]struct{}, len(logs)) for _, log := range logs { + if log.BillingOrderID != "" { + if _, exists := seenBillingOrderIDs[log.BillingOrderID]; !exists { + seenBillingOrderIDs[log.BillingOrderID] = struct{}{} + billingOrderIDs = append(billingOrderIDs, log.BillingOrderID) + } + } if (log.Capability != "image" && log.Capability != "video") || log.TaskID == "" { continue } @@ -250,6 +258,10 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { for _, task := range tasks { taskByID[task.ID] = task } + billingOrderByID, err := s.repo.BillingOrdersByIDs(billingOrderIDs) + if err != nil { + return err + } for index := range logs { if logs[index].StartedAt.IsZero() { logs[index].StartedAt = logs[index].CreatedAt @@ -265,6 +277,15 @@ func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { logs[index].UserDisplayName = user.DisplayName logs[index].UserAccount = user.Username } + if order, exists := billingOrderByID[logs[index].BillingOrderID]; exists && order.UserID == logs[index].UserID { + logs[index].BillingAvailable = true + logs[index].BillingStatus = order.Status + if order.Status == model.BillingStatusSettled { + logs[index].BillingAmount = order.ActualAmountMicrocredits + } else if order.Status != model.BillingStatusRefunded { + logs[index].BillingAmount = order.ReservedAmountMicrocredits + } + } if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID { logs[index].TaskStatus = task.Status previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) @@ -373,13 +394,22 @@ func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) var buffer bytes.Buffer buffer.WriteString("\xEF\xBB\xBF") writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "计费", "币种", "错误码", "错误"}) + _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "积分计费(微积分)", "积分计费状态", "上游估算费用(微单位)", "币种", "错误码", "错误"}) for _, log := range logs { startedAt := log.StartedAt if startedAt.IsZero() { startedAt = log.CreatedAt } - _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), strconv.FormatInt(log.EstimatedCostMicros, 10), log.Currency, log.ErrorCode, log.Error}) + billingAmount, billingStatus := "", "" + if log.BillingAvailable { + billingAmount = strconv.FormatInt(log.BillingAmount, 10) + billingStatus = string(log.BillingStatus) + } + upstreamCost := "" + if log.CostAvailable { + upstreamCost = strconv.FormatInt(log.EstimatedCostMicros, 10) + } + _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, upstreamCost, log.Currency, log.ErrorCode, log.Error}) } writer.Flush() if err := writer.Error(); err != nil { diff --git a/backend/internal/service/api_log_billing_test.go b/backend/internal/service/api_log_billing_test.go new file mode 100644 index 000000000..5a739d3cd --- /dev/null +++ b/backend/internal/service/api_log_billing_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestDecorateAPICallLogsUsesBillingOrderSnapshot(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + } + if err := db.AutoMigrate(&model.User{}, &model.ModelChannel{}, &model.BillingOrder{}); err != nil { + t.Fatal(err) + } + if err := db.Create(&model.User{ID: "user-1", Username: "creator", DisplayName: "创作者"}).Error; err != nil { + t.Fatal(err) + } + + orders := []model.BillingOrder{ + {ID: "settled", UserID: "user-1", IdempotencyKey: "settled", Status: model.BillingStatusSettled, ReservedAmountMicrocredits: 1_000_000, ActualAmountMicrocredits: 780_000}, + {ID: "reserved", UserID: "user-1", IdempotencyKey: "reserved", Status: model.BillingStatusReserved, ReservedAmountMicrocredits: 900_000}, + {ID: "uncertain", UserID: "user-1", IdempotencyKey: "uncertain", Status: model.BillingStatusUncertain, ReservedAmountMicrocredits: 1_200_000}, + {ID: "refunded", UserID: "user-1", IdempotencyKey: "refunded", Status: model.BillingStatusRefunded, ReservedAmountMicrocredits: 650_000, RefundedAmountMicrocredits: 650_000}, + {ID: "other-user", UserID: "user-2", IdempotencyKey: "other-user", Status: model.BillingStatusSettled, ActualAmountMicrocredits: 500_000}, + } + if err := db.Create(&orders).Error; err != nil { + t.Fatal(err) + } + + logs := []model.ApiCallLog{ + {ID: "log-settled", UserID: "user-1", BillingOrderID: "settled", Capability: "text"}, + {ID: "log-reserved", UserID: "user-1", BillingOrderID: "reserved", Capability: "text"}, + {ID: "log-uncertain", UserID: "user-1", BillingOrderID: "uncertain", Capability: "text"}, + {ID: "log-refunded", UserID: "user-1", BillingOrderID: "refunded", Capability: "text"}, + {ID: "log-missing", UserID: "user-1", BillingOrderID: "missing", Capability: "text"}, + {ID: "log-other-user", UserID: "user-1", BillingOrderID: "other-user", Capability: "text"}, + } + + svc := &Service{repo: repository.New(db)} + if err := svc.decorateAPICallLogs(logs); err != nil { + t.Fatal(err) + } + + assertBillingSnapshot(t, logs[0], true, model.BillingStatusSettled, 780_000) + assertBillingSnapshot(t, logs[1], true, model.BillingStatusReserved, 900_000) + assertBillingSnapshot(t, logs[2], true, model.BillingStatusUncertain, 1_200_000) + assertBillingSnapshot(t, logs[3], true, model.BillingStatusRefunded, 0) + assertBillingSnapshot(t, logs[4], false, "", 0) + assertBillingSnapshot(t, logs[5], false, "", 0) +} + +func assertBillingSnapshot(t *testing.T, log model.ApiCallLog, available bool, status model.BillingStatus, amount int64) { + t.Helper() + if log.BillingAvailable != available || log.BillingStatus != status || log.BillingAmount != amount { + t.Fatalf("billing snapshot for %s = {available:%v status:%q amount:%d}, want {available:%v status:%q amount:%d}", log.ID, log.BillingAvailable, log.BillingStatus, log.BillingAmount, available, status, amount) + } +} diff --git a/web/package.json b/web/package.json index 021b82f9d..146ba0b5e 100644 --- a/web/package.json +++ b/web/package.json @@ -7,6 +7,7 @@ "build:bridge": "bun ../canvas-agent/scripts/build-comfy-bridge.ts", "dev": "vite --host 0.0.0.0 --port 3000", "build": "bun run build:bridge && tsc --noEmit && vite build", + "pretest": "bun test test/admin-ui-regressions.test.ts", "typecheck": "tsc --noEmit", "test": "bun test test/api-request.test.ts test/video-api-contracts.test.ts test/image-api-contracts.test.ts test/channel-model-catalog.test.ts test/model-protocols.test.ts test/agent-prompt-cache.test.ts test/canvas-agent-context.test.ts test/canvas-batch-connection.test.ts test/canvas-character-reference.test.ts test/canvas-document.test.ts test/canvas-drawing-engine.test.ts test/canvas-emotion-edit-plan.test.ts test/canvas-extension-nodes.test.ts test/canvas-folder.test.ts test/canvas-generation-layout.test.ts test/canvas-generation-submission.test.ts test/canvas-image-batch-retry.test.ts test/canvas-keyboard.test.ts test/canvas-node-generation-mentions.test.ts test/canvas-node-search.test.ts test/canvas-leafer-viewport.test.ts test/canvas-media-download.test.ts test/canvas-local-runtime.test.ts test/canvas-model-policy.test.ts test/canvas-resource-mention-editor.test.ts test/canvas-resource-references.test.ts test/canvas-storyboard-assets.test.ts test/canvas-storyboard-references.test.ts test/canvas-node-config-reset.test.ts test/canvas-node-registry.test.ts test/canvas-video-frame.test.ts test/canvas-video-regeneration.test.ts test/canvas-video-timeline-segments.test.ts test/create-canvas-handoff.test.ts test/create-library-button.test.ts test/creation-references.test.ts test/director-transform-semantics.test.ts test/director-bone-precedence.test.ts test/director-gesture-transaction.test.ts test/director-transaction-lifecycle.test.ts test/director-session-authority.test.ts test/director-placement.test.ts test/director-resources.test.ts test/director-recovery.test.ts test/director-node-preview.test.ts test/generation-asset-repository.node.test.ts test/generation-storage-consistency.test.ts test/generation-task-durability.node.test.ts test/generation-task-local.test.ts test/generation-task-materializer.test.ts test/task-media-fallback.test.ts test/user-scoped-generation-persistence.test.ts test/local-channel-permission.test.ts test/local-channel-runtime-projection.node.test.mjs test/local-channel-task-center.node.test.mjs test/local-channel-ui.node.test.mjs test/local-cli-settings.test.ts test/local-dreamina-cli.test.ts test/local-dreamina-generation.test.ts test/local-dreamina-model-catalog.test.ts test/local-runtime-bootstrap.test.ts test/local-runtime-connection.test.ts test/local-runtime.test.ts test/client-id.test.ts test/grok-image-prompt-limit.test.ts test/image-resolution-tiers.test.ts test/gemini-image.test.ts test/libtv-import.test.ts test/timeline-to-ffmpeg.test.ts test/canvas-video-segment-args.test.ts test/tapnow-import.test.ts test/prompt-optimizer-drawer.test.ts test/director-save-coordinator.test.ts test/director-save-wiring.test.ts test/director-diagnostics.test.ts test/director-repro-runtime.test.ts test/director-diagnostics-recorder.test.ts test/director-keyframe-editing.test.ts test/director-shortcuts.test.ts test/director-keyframe-delete-wiring.test.ts test/director-templates.test.ts test/director-modes.test.ts test/director-template-mode-wiring.test.ts test/director-view-modes.test.ts && bun test test/local-dreamina-cross-runtime.test.ts", "test:director:e2e": "bun scripts/director-p0-chrome-e2e.mjs", diff --git a/web/src/pages/admin/admin-route-pages.tsx b/web/src/pages/admin/admin-route-pages.tsx index f83d4ca08..094d3502b 100644 --- a/web/src/pages/admin/admin-route-pages.tsx +++ b/web/src/pages/admin/admin-route-pages.tsx @@ -87,7 +87,7 @@ export function CreditOperationsPage() { export function AccessSettingsPage() { return ( - + }> @@ -97,7 +97,7 @@ export function AccessSettingsPage() { export function EmailSettingsPage() { return ( - + }> @@ -107,7 +107,7 @@ export function EmailSettingsPage() { export function FeatureAvailabilityPage() { return ( - + }> diff --git a/web/src/pages/admin/components/access-settings-panel.tsx b/web/src/pages/admin/components/access-settings-panel.tsx index cc8214c2e..7a883dae2 100644 --- a/web/src/pages/admin/components/access-settings-panel.tsx +++ b/web/src/pages/admin/components/access-settings-panel.tsx @@ -5,7 +5,7 @@ import { useBlocker } from "react-router"; import { cn } from "@/lib/utils"; import { getAdminLinuxDOSetting, getAdminRegistrationSetting, updateAdminLinuxDOSetting, updateAdminRegistrationSetting, type LinuxDOSetting, type RegistrationSetting } from "@/services/api/wallet"; -import { AdminStatTile, AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui"; +import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui"; type LinuxDOFormValues = Omit; @@ -191,39 +191,17 @@ export default function AccessSettingsPanel() { } }; - const toggleLinuxDO = async (enabled: boolean) => { - if (!linuxdo || savingLinuxDO || enabled === linuxdo.enabled) return; - const values = (enabled ? { ...form.getFieldsValue(true), enabled } : { ...toLinuxDOFormValues(linuxdo), enabled, clientSecret: "" }) as LinuxDOFormValues; - const validationError = validateLinuxDODraft(values, linuxdo); - if (validationError) { - form.setFieldValue("enabled", linuxdo.enabled); - setDraftLinuxDOEnabled(linuxdo.enabled); - message.error(validationError); - return; - } + const toggleLinuxDO = (enabled: boolean) => { + if (!linuxdo || savingLinuxDO) return; form.setFieldValue("enabled", enabled); setDraftLinuxDOEnabled(enabled); - try { - await saveLinuxDO(values); - } catch { - form.setFieldValue("enabled", linuxdo.enabled); - setDraftLinuxDOEnabled(linuxdo.enabled); - } + setDirty(hasLinuxDOChanges({ ...form.getFieldsValue(true), enabled }, linuxdo)); + setSaveError(""); }; if (loading && (!linuxdo || !registration)) { return (
-
- -
-
- {Array.from({ length: 4 }).map((_, index) => ( -
- -
- ))} -
@@ -250,8 +228,6 @@ export default function AccessSettingsPanel() { ); } - const configuredEndpointCount = [linuxdo.authorizationUrl, linuxdo.tokenUrl, linuxdo.userInfoUrl, linuxdo.redirectUrl].filter((value) => value?.trim()).length; - return (
@@ -260,11 +236,8 @@ export default function AccessSettingsPanel() {
@@ -286,19 +259,12 @@ export default function AccessSettingsPanel() {
) : null} -
- - - - -
-