From f439d600ba833739ff4691332f23fa35d3fcaddc Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Tue, 4 Aug 2026 16:24:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(*):=20=E5=8A=9F=E8=83=BD=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E4=B8=8E=E9=A1=B5=E9=9D=A2=E5=B8=83=E5=B1=80=20-=20=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=BF=90=E7=BB=B4=E5=BC=80=E5=85=B3=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E5=90=8E=E5=8F=B0=E5=8F=8A=E5=88=9B=E4=BD=9C=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E7=A9=BA=E9=97=B4=E5=88=A9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 5 +- backend/internal/handler/auth.go | 19 ++- .../internal/handler/feature_availability.go | 75 ++++++++++ backend/internal/repository/finance.go | 3 + backend/internal/service/credit_policy.go | 7 + .../internal/service/feature_availability.go | 139 ++++++++++++++++++ .../service/feature_availability_test.go | 84 +++++++++++ backend/internal/service/finance.go | 20 +++ .../internal/service/finance_redeem_test.go | 2 +- .../internal/service/task_security_test.go | 2 +- web/src/components/auth/require-feature.tsx | 51 +++++++ .../canvas/canvas-active-task-panel.tsx | 9 +- .../canvas/canvas-agent-panel-chrome.tsx | 5 +- .../canvas/canvas-config-node-panel.tsx | 10 +- .../canvas/canvas-node-prompt-panel.tsx | 4 +- .../canvas/canvas-short-drama-entry.tsx | 15 ++ web/src/components/layout/app-top-nav.tsx | 23 ++- .../layout/workspace-sidebar-footer.tsx | 9 +- web/src/components/model-picker.tsx | 10 +- web/src/lib/user-session.ts | 9 +- web/src/pages/admin/admin-route-pages.tsx | 7 +- .../components/access-settings-panel.tsx | 2 +- .../pages/admin/components/admin-shell.tsx | 7 +- web/src/pages/admin/components/admin-ui.tsx | 14 +- .../components/feature-availability-panel.tsx | 97 ++++++++++++ .../settings/drawing-engine-settings-page.tsx | 31 ++-- .../settings/runtime-policy-settings-page.tsx | 4 +- .../admin/settings/storage-settings-page.tsx | 8 +- .../pages/canvas/canvas-project-top-bar.tsx | 5 +- web/src/pages/canvas/project.tsx | 37 ++++- web/src/pages/home/index.tsx | 22 +-- web/src/pages/settings/model-default-grid.tsx | 4 +- web/src/pages/tasks/index.tsx | 19 ++- web/src/router.tsx | 16 +- web/src/services/api/auth.ts | 14 ++ web/src/stores/use-user-store.ts | 21 ++- 36 files changed, 723 insertions(+), 86 deletions(-) create mode 100644 backend/internal/handler/feature_availability.go create mode 100644 backend/internal/service/feature_availability.go create mode 100644 backend/internal/service/feature_availability_test.go create mode 100644 web/src/components/auth/require-feature.tsx create mode 100644 web/src/pages/admin/components/feature-availability-panel.tsx diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f79846a03..0b5999cfa 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -73,6 +73,7 @@ func main() { }) handler.RegisterOAuthCallbackRoutes(r, svc) handler.RegisterAuthRoutes(api, svc) + handler.RegisterFeatureAvailabilityRoutes(api, svc) handler.RegisterAdminRoutes(api, svc) handler.RegisterAdminAnalyticsRoutes(api, svc) handler.RegisterAnnouncementRoutes(api, svc) @@ -85,7 +86,9 @@ func main() { handler.RegisterSessionRoutes(api, svc) handler.RegisterSkillRoutes(api, svc) handler.RegisterUserDataRoutes(api, svc) - handler.RegisterProjectRoutes(api, svc) + projectAPI := api.Group("") + projectAPI.Use(handler.RequireFeature(svc, service.FeatureShortDrama)) + handler.RegisterProjectRoutes(projectAPI, svc) handler.RegisterCanvasShareRoutes(api, svc) addr := env("CANVAS_BACKEND_ADDR", ":8080") diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 2e3cd8b44..729f4df41 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -127,7 +127,12 @@ func RegisterAuthRoutes(r *gin.RouterGroup, svc *service.Service) { failService(c, err) return } - ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits, "drawingEngine": drawingEngine}) + features, err := svc.FeatureAvailability() + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": publicUser, "systemChannels": channels, "runtimeLimits": limits, "drawingEngine": drawingEngine, "features": features}) }) r.GET("/channels/system", func(c *gin.Context) { if _, err := currentUser(c, svc); err != nil { @@ -750,11 +755,13 @@ func proxySystemRequest(c *gin.Context, svc *service.Service, user *model.User, failService(c, err) return } - billingOrderID = order.ID - if err := svc.MarkBillingRunning(billingOrderID); err != nil { - _ = svc.RefundBilling(billingOrderID, "系统渠道请求尚未发出") - failService(c, err) - return + if order != nil { + billingOrderID = order.ID + if err := svc.MarkBillingRunning(billingOrderID); err != nil { + _ = svc.RefundBilling(billingOrderID, "系统渠道请求尚未发出") + failService(c, err) + return + } } } upstreamReq, err := http.NewRequestWithContext(c.Request.Context(), c.Request.Method, target, bytes.NewReader(body)) diff --git a/backend/internal/handler/feature_availability.go b/backend/internal/handler/feature_availability.go new file mode 100644 index 000000000..5fdfa5269 --- /dev/null +++ b/backend/internal/handler/feature_availability.go @@ -0,0 +1,75 @@ +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func RegisterFeatureAvailabilityRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/features", func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + return + } + setting, err := svc.FeatureAvailability() + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"features": setting}) + }) + + r.GET("/admin/settings/features", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminFeatureAvailability(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"features": setting}) + }) + + r.PATCH("/admin/settings/features", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10) + var req service.FeatureAvailability + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateFeatureAvailability(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"features": setting}) + }) +} + +// 功能守卫先校验登录态再判断开放状态,避免关闭功能时改变未登录请求的认证语义。 +func RequireFeature(svc *service.Service, feature string) gin.HandlerFunc { + return func(c *gin.Context) { + if _, err := currentUser(c, svc); err != nil { + failService(c, err) + c.Abort() + return + } + if err := svc.RequireFeature(feature); err != nil { + failService(c, err) + c.Abort() + return + } + c.Next() + } +} diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index a19b2a598..6de9040be 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -226,6 +226,9 @@ func (r *Repository) RetryTaskWithBilling(userID string, taskID string, order *m } if order != nil { updates["billing_order_id"] = order.ID + } else { + // 免费模式重试必须解除旧订单,避免任务执行阶段复用上一次的计费状态。 + updates["billing_order_id"] = "" } updated := tx.Model(&model.Task{}). Where("id = ? AND user_id = ? AND status IN ?", taskID, userID, []model.TaskStatus{model.TaskStatusFailed, model.TaskStatusCancelled}). diff --git a/backend/internal/service/credit_policy.go b/backend/internal/service/credit_policy.go index ef2b8cba5..133c69f9a 100644 --- a/backend/internal/service/credit_policy.go +++ b/backend/internal/service/credit_policy.go @@ -108,6 +108,10 @@ func (s *Service) UpdateCreditPolicy(actor *model.User, policy CreditPolicy) (Cr } func (s *Service) ensureSignupBonus(userID string) error { + enabled, err := s.FeatureEnabled(FeatureCredits) + if err != nil || !enabled { + return err + } policy, err := s.creditPolicy() if err != nil || policy.SignupBonusMicrocredits == 0 { return err @@ -120,6 +124,9 @@ func (s *Service) CheckinCredits(user *model.User) (*model.CreditAccount, bool, if user == nil { return nil, false, Unauthorized("请先登录") } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, false, err + } policy, err := s.creditPolicy() if err != nil { return nil, false, err diff --git a/backend/internal/service/feature_availability.go b/backend/internal/service/feature_availability.go new file mode 100644 index 000000000..adf2f595c --- /dev/null +++ b/backend/internal/service/feature_availability.go @@ -0,0 +1,139 @@ +package service + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +const featureAvailabilitySettingKey = "feature_availability" + +const ( + FeatureShortDrama = "shortDrama" + FeatureTaskCenter = "taskCenter" + FeatureCredits = "credits" +) + +type FeatureAvailability struct { + ShortDramaEnabled bool `json:"shortDramaEnabled"` + TaskCenterEnabled bool `json:"taskCenterEnabled"` + CreditsEnabled bool `json:"creditsEnabled"` +} + +type PublicFeatureAvailability struct { + FeatureAvailability + Configured bool `json:"configured"` + UpdatedBy string `json:"updatedBy,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` +} + +func defaultFeatureAvailability() FeatureAvailability { + // 缺少配置代表尚未由运维接管,默认保持现有功能全部开放。 + return FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: true} +} + +func (s *Service) FeatureAvailability() (*PublicFeatureAvailability, error) { + setting, value, err := s.readFeatureAvailability() + if err != nil { + return nil, err + } + return publicFeatureAvailability(setting, value), nil +} + +func (s *Service) AdminFeatureAvailability(actor *model.User) (*PublicFeatureAvailability, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + return s.FeatureAvailability() +} + +func (s *Service) UpdateFeatureAvailability(actor *model.User, value FeatureAvailability) (*PublicFeatureAvailability, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + current, before, err := s.readFeatureAvailability() + if err != nil { + return nil, err + } + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + setting := model.SystemSetting{Key: featureAvailabilitySettingKey, ValueJSON: string(encoded), UpdatedBy: actor.ID} + if current != nil { + setting.CreatedAt = current.CreatedAt + } + if err := s.repo.SaveSystemSetting(&setting); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "feature_availability.update", "system_setting", featureAvailabilitySettingKey, "更新功能开放配置", map[string]any{"before": before, "after": value}); err != nil { + return nil, err + } + return publicFeatureAvailability(&setting, value), nil +} + +func (s *Service) FeatureEnabled(feature string) (bool, error) { + _, value, err := s.readFeatureAvailability() + if err != nil { + return false, err + } + switch feature { + case FeatureShortDrama: + return value.ShortDramaEnabled, nil + case FeatureTaskCenter: + return value.TaskCenterEnabled, nil + case FeatureCredits: + return value.CreditsEnabled, nil + default: + return false, errors.New("未知功能开放配置") + } +} + +func (s *Service) RequireFeature(feature string) error { + enabled, err := s.FeatureEnabled(feature) + if err != nil { + return err + } + if enabled { + return nil + } + switch feature { + case FeatureShortDrama: + return Forbidden("短剧创作暂未开放") + case FeatureTaskCenter: + return Forbidden("任务中心暂未开放") + case FeatureCredits: + return Forbidden("积分功能暂未开放") + default: + return Forbidden("该功能暂未开放") + } +} + +func (s *Service) readFeatureAvailability() (*model.SystemSetting, FeatureAvailability, error) { + setting, err := s.repo.SystemSetting(featureAvailabilitySettingKey) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, defaultFeatureAvailability(), nil + } + if err != nil { + return nil, FeatureAvailability{}, err + } + value := FeatureAvailability{} + if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil { + return nil, FeatureAvailability{}, errors.New("功能开放配置格式无效") + } + return setting, value, nil +} + +func publicFeatureAvailability(setting *model.SystemSetting, value FeatureAvailability) *PublicFeatureAvailability { + result := &PublicFeatureAvailability{FeatureAvailability: value, Configured: setting != nil} + if setting != nil { + result.UpdatedBy = setting.UpdatedBy + result.UpdatedAt = setting.UpdatedAt + } + return result +} diff --git a/backend/internal/service/feature_availability_test.go b/backend/internal/service/feature_availability_test.go new file mode 100644 index 000000000..97f8932e9 --- /dev/null +++ b/backend/internal/service/feature_availability_test.go @@ -0,0 +1,84 @@ +package service + +import ( + "errors" + "testing" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestFeatureAvailabilityDefaultsToEnabled(t *testing.T) { + svc, _ := newFeatureAvailabilityTestService(t) + + setting, err := svc.FeatureAvailability() + if err != nil { + t.Fatal(err) + } + if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled { + t.Fatalf("FeatureAvailability() = %#v", setting) + } +} + +func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) { + svc, db := newFeatureAvailabilityTestService(t) + actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + want := FeatureAvailability{ShortDramaEnabled: false, TaskCenterEnabled: true, CreditsEnabled: false} + + setting, err := svc.UpdateFeatureAvailability(actor, want) + if err != nil { + t.Fatal(err) + } + if !setting.Configured || setting.ShortDramaEnabled || !setting.TaskCenterEnabled || setting.CreditsEnabled { + t.Fatalf("UpdateFeatureAvailability() = %#v", setting) + } + if err := svc.RequireFeature(FeatureShortDrama); err == nil { + t.Fatal("RequireFeature(shortDrama) error = nil") + } else { + var authErr *AuthError + if !errors.As(err, &authErr) || authErr.Status != 403 { + t.Fatalf("RequireFeature(shortDrama) error = %#v", err) + } + } + var auditCount int64 + if err := db.Model(&model.AdminAuditEvent{}).Where("action = ?", "feature_availability.update").Count(&auditCount).Error; err != nil { + t.Fatal(err) + } + if auditCount != 1 { + t.Fatalf("audit count = %d, want 1", auditCount) + } +} + +func TestTaskBillingOrderSkipsPricingWhenCreditsDisabled(t *testing.T) { + svc, _ := newFeatureAvailabilityTestService(t) + actor := &model.User{ID: "admin-1", Role: model.UserRoleAdmin} + if _, err := svc.UpdateFeatureAvailability(actor, FeatureAvailability{ShortDramaEnabled: true, TaskCenterEnabled: true, CreditsEnabled: false}); err != nil { + t.Fatal(err) + } + + order, err := svc.taskBillingOrder("user-1", &model.Task{ID: "task-1"}, map[string]any{"config": map[string]any{"channelId": "missing", "model": "missing"}}) + if err != nil { + t.Fatal(err) + } + if order != nil { + t.Fatalf("taskBillingOrder() = %#v, want nil", order) + } +} + +func newFeatureAvailabilityTestService(t *testing.T) (*Service, *gorm.DB) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + } + if err := db.AutoMigrate(&model.SystemSetting{}, &model.AdminAuditEvent{}); err != nil { + t.Fatal(err) + } + return New(repository.New(db), t.TempDir()), db +} diff --git a/backend/internal/service/finance.go b/backend/internal/service/finance.go index 5eee80ac6..ea7ab7f40 100644 --- a/backend/internal/service/finance.go +++ b/backend/internal/service/finance.go @@ -107,6 +107,9 @@ func (s *Service) Wallet(user *model.User, entryType string, page int, limit int if user == nil { return nil, Unauthorized("请先登录") } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } if page <= 0 { page = 1 } @@ -132,6 +135,9 @@ func (s *Service) RedeemCredits(user *model.User, code string, redeemedIP string if user == nil { return nil, Unauthorized("请先登录") } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } code = strings.ToLower(strings.TrimSpace(code)) if len(code) != 32 { return nil, BadAuthRequest("兑换码无效或已使用") @@ -414,6 +420,13 @@ func (s *Service) AdminDisableRedeemCode(actor *model.User, batchID string, code } func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[string]any) (*model.BillingOrder, error) { + enabled, err := s.FeatureEnabled(FeatureCredits) + if err != nil { + return nil, err + } + if !enabled { + return nil, nil + } config, _ := input["config"].(map[string]any) if config == nil { return nil, nil @@ -435,6 +448,13 @@ func (s *Service) taskBillingOrder(userID string, task *model.Task, input map[st } func (s *Service) ReserveProxyBilling(userID string, channelID string, modelKey string, capability string, scene string, idempotencyKey string, quantity int64) (*model.BillingOrder, error) { + enabled, err := s.FeatureEnabled(FeatureCredits) + if err != nil { + return nil, err + } + if !enabled { + return nil, nil + } if strings.TrimSpace(idempotencyKey) == "" { idempotencyKey = newID() } diff --git a/backend/internal/service/finance_redeem_test.go b/backend/internal/service/finance_redeem_test.go index bdb397f66..19c20e25e 100644 --- a/backend/internal/service/finance_redeem_test.go +++ b/backend/internal/service/finance_redeem_test.go @@ -15,7 +15,7 @@ func TestRedeemBatchCanBeReviewedAndRecordsAuditIP(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.User{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.RedeemBatch{}, &model.RedeemCode{}); err != nil { + if err := db.AutoMigrate(&model.SystemSetting{}, &model.User{}, &model.CreditAccount{}, &model.CreditLedgerEntry{}, &model.RedeemBatch{}, &model.RedeemCode{}); err != nil { t.Fatal(err) } admin := &model.User{ID: "admin-1", Username: "admin", DisplayName: "管理员", Role: model.UserRoleAdmin, Status: model.UserStatusActive} diff --git a/backend/internal/service/task_security_test.go b/backend/internal/service/task_security_test.go index 517d4dfe9..00c7708ac 100644 --- a/backend/internal/service/task_security_test.go +++ b/backend/internal/service/task_security_test.go @@ -16,7 +16,7 @@ func TestNormalizeTaskInputMakesTypedProviderConfigBillable(t *testing.T) { if err != nil { t.Fatal(err) } - if err := db.AutoMigrate(&model.ChannelModel{}); err != nil { + if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}); err != nil { t.Fatal(err) } channelModel := model.ChannelModel{ diff --git a/web/src/components/auth/require-feature.tsx b/web/src/components/auth/require-feature.tsx new file mode 100644 index 000000000..9537b04d0 --- /dev/null +++ b/web/src/components/auth/require-feature.tsx @@ -0,0 +1,51 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { Button } from "antd"; +import { useLocation, useNavigate } from "react-router"; + +import { WorkspacePage } from "@/components/layout/workspace-page"; +import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state"; +import { refreshFeatureAvailability } from "@/lib/user-session"; +import { useUserStore } from "@/stores/use-user-store"; + +type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled"; + +const featureNames: Record = { + shortDramaEnabled: "短剧创作", + taskCenterEnabled: "任务中心", + creditsEnabled: "积分中心", +}; + +export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) { + const location = useLocation(); + const navigate = useNavigate(); + const features = useUserStore((state) => state.features); + const [checking, setChecking] = useState(true); + const [error, setError] = useState(""); + + useEffect(() => { + let cancelled = false; + setChecking(true); + setError(""); + refreshFeatureAvailability() + .catch((reason) => { + if (!cancelled) setError(reason instanceof Error ? reason.message : "读取功能开放状态失败"); + }) + .finally(() => { + if (!cancelled) setChecking(false); + }); + return () => { + cancelled = true; + }; + }, [location.pathname]); + + if (checking) return ; + if (error) return navigate("/create", { replace: true })} />; + if (!features[feature]) { + return ( + + navigate("/create", { replace: true })}>返回创作台} /> + + ); + } + return children; +} diff --git a/web/src/components/canvas/canvas-active-task-panel.tsx b/web/src/components/canvas/canvas-active-task-panel.tsx index c6b2f83cc..57fba2c29 100644 --- a/web/src/components/canvas/canvas-active-task-panel.tsx +++ b/web/src/components/canvas/canvas-active-task-panel.tsx @@ -8,9 +8,11 @@ import { formatTaskKind, statusLabel } from "@/lib/generation-task-display"; import { canvasThemes } from "@/lib/canvas-theme"; import type { GenerationTask } from "@/services/api/task-center"; import { useThemeStore } from "@/stores/use-theme-store"; +import { useUserStore } from "@/stores/use-user-store"; export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) { const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const reducedMotion = useReducedMotion(); const [now, setNow] = useState(() => Date.now()); const [open, setOpen] = useState(false); @@ -91,6 +93,7 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) { expanded={expandedTaskId === task.id} onToggle={() => setExpandedTaskId((current) => current === task.id ? null : task.id)} reducedMotion={Boolean(reducedMotion)} + creditsEnabled={creditsEnabled} /> ))} @@ -103,7 +106,7 @@ export function CanvasActiveTaskPanel({ tasks }: { tasks: GenerationTask[] }) { ); } -function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }: { task: GenerationTask; now: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; expanded: boolean; onToggle: () => void; reducedMotion: boolean }) { +function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion, creditsEnabled }: { task: GenerationTask; now: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; expanded: boolean; onToggle: () => void; reducedMotion: boolean; creditsEnabled: boolean }) { const progress = typeof task.progress === "number" ? Math.max(0, Math.min(100, Math.round(task.progress))) : task.status === "queued" ? 0 : undefined; const startedAt = task.startedAt || task.createdAt; const elapsedMs = Math.max(0, now - parseTime(startedAt)); @@ -170,9 +173,9 @@ function ActiveTaskCard({ task, now, theme, expanded, onToggle, reducedMotion }: {progress}% ) : ( -
+
{durationLabel} - {billingLabel} + {creditsEnabled ? {billingLabel} : null}
)} diff --git a/web/src/components/canvas/canvas-agent-panel-chrome.tsx b/web/src/components/canvas/canvas-agent-panel-chrome.tsx index 44fddbd69..fc50788de 100644 --- a/web/src/components/canvas/canvas-agent-panel-chrome.tsx +++ b/web/src/components/canvas/canvas-agent-panel-chrome.tsx @@ -3,6 +3,7 @@ import { BookOpenText, Bot, Clapperboard, Focus, Globe2, LayoutTemplate, Laptop, import type { CanvasContextSummary } from "@/lib/canvas/canvas-context-summary"; import type { CanvasTheme } from "@/lib/canvas-theme"; +import { useUserStore } from "@/stores/use-user-store"; import type { CanvasAgentMode } from "./canvas-agent-chat-ui"; export function AgentPanelChrome({ @@ -90,6 +91,8 @@ const starterActions = [ ]; export function AgentChatEmptyState({ theme, nodeCount, onSelect }: { theme: CanvasTheme; nodeCount: number; onSelect: (value: string) => void }) { + const shortDramaEnabled = useUserStore((state) => state.features.shortDramaEnabled); + const visibleStarterActions = shortDramaEnabled ? starterActions : starterActions.filter((item) => item.label !== "搭建短剧工作流"); return (
@@ -99,7 +102,7 @@ export function AgentChatEmptyState({ theme, nodeCount, onSelect }: { theme: Can

从当前画布开始

- {starterActions.map(({ label, icon: Icon }) => ( + {visibleStarterActions.map(({ label, icon: Icon }) => ( + +
+
+
+ ); +} + function PathCard({ icon, title, description, action, accent, theme, focusStyle, onClick }: { icon: ReactNode; title: string; diff --git a/web/src/components/layout/app-top-nav.tsx b/web/src/components/layout/app-top-nav.tsx index ebfe0ec95..15f086465 100644 --- a/web/src/components/layout/app-top-nav.tsx +++ b/web/src/components/layout/app-top-nav.tsx @@ -6,11 +6,15 @@ import { ModelSetupGuide } from "@/components/layout/model-setup-guide"; import { WorkspaceSidebarFooter } from "@/components/layout/workspace-sidebar-footer"; import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools"; import { cn } from "@/lib/utils"; +import { refreshFeatureAvailability } from "@/lib/user-session"; import { isSpatialWorkbenchPath } from "@/lib/workspace-routes"; +import { useUserStore } from "@/stores/use-user-store"; export function AppWorkspaceShell({ children }: { children: ReactNode }) { const { pathname } = useLocation(); const navigate = useNavigate(); + const user = useUserStore((state) => state.user); + const features = useUserStore((state) => state.features); const [mobileSidebarExpanded, setMobileSidebarExpanded] = useState(false); const scrollRef = useRef(null); const [scrollState, setScrollState] = useState({ @@ -22,7 +26,13 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { const creationWorkspace = pathname === "/create"; const slug = pathname.split("/").filter(Boolean)[0]; const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined; - const visibleNavigationTools = spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间"); + const visibleNavigationTools = (spatialWorkbench ? navigationTools : navigationTools.filter((tool) => tool.section === "创作空间")) + .filter((tool) => { + if (tool.slug === "projects") return features.shortDramaEnabled; + if (tool.slug === "tasks") return features.taskCenterEnabled; + if (tool.slug === "wallet") return features.creditsEnabled; + return true; + }); const handleScroll = () => { const element = scrollRef.current; @@ -49,6 +59,17 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) { handleScroll(); }, [visibleNavigationTools.length, mobileSidebarExpanded]); + useEffect(() => { + if (!user) return; + const refresh = () => void refreshFeatureAvailability().catch((error) => console.warn("功能开放状态刷新失败", error)); + const timer = window.setInterval(refresh, 30_000); + window.addEventListener("focus", refresh); + return () => { + window.clearInterval(timer); + window.removeEventListener("focus", refresh); + }; + }, [user]); + return ( <>
diff --git a/web/src/components/layout/workspace-sidebar-footer.tsx b/web/src/components/layout/workspace-sidebar-footer.tsx index 8908e3fe1..16ed0c4f2 100644 --- a/web/src/components/layout/workspace-sidebar-footer.tsx +++ b/web/src/components/layout/workspace-sidebar-footer.tsx @@ -24,10 +24,11 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, const setTheme = useThemeStore((state) => state.setTheme); const user = useUserStore((state) => state.user); const hydrated = useUserStore((state) => state.hydrated); + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const navigate = useNavigate(); const { message } = App.useApp(); const [menuOpen, setMenuOpen] = useState(false); - const { availableMicrocredits } = useWalletBalance(user?.id, true); + const { availableMicrocredits } = useWalletBalance(user?.id, creditsEnabled); const balance = availableMicrocredits === null ? "--" : (availableMicrocredits / 1_000_000).toLocaleString("zh-CN", { maximumFractionDigits: 2 }); @@ -69,7 +70,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
{user.displayName || user.username}
-
可用 {balance} 积分
+ {creditsEnabled ?
可用 {balance} 积分
: null}
@@ -92,11 +93,11 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName, )} > - diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index c9b279d0e..e0a702240 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -6,6 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme"; import { cn } from "@/lib/utils"; import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; import { useThemeStore } from "@/stores/use-theme-store"; +import { useUserStore } from "@/stores/use-user-store"; type ModelPickerProps = { config: AiConfig; @@ -21,6 +22,7 @@ type ModelPickerProps = { }; export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig, showSelectedPrice = true, variant = "default" }: ModelPickerProps) { + const creditsEnabled = useUserStore((state) => state.features.creditsEnabled); const pickerId = useId(); const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [open, setOpen] = useState(false); @@ -129,7 +131,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu window.requestAnimationFrame(() => triggerRef.current?.focus()); }} > - + {selected ? : null} ); @@ -172,7 +174,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu {current ? creationVariant ? modelDisplayName(config, current) : modelOptionLabel(config, current) : placeholder} - {showSelectedPrice ? : null} + {showSelectedPrice && creditsEnabled ? : null}