diff --git a/backend/internal/database/migrations.go b/backend/internal/database/migrations.go index 09dcace10..785d94f62 100644 --- a/backend/internal/database/migrations.go +++ b/backend/internal/database/migrations.go @@ -178,13 +178,6 @@ func migrateSchemaV6(tx *gorm.DB) error { return nil } -func migrateSchemaV7(tx *gorm.DB) error { - if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil { - return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err) - } - return nil -} - func migrateSchemaV8(tx *gorm.DB) error { if !tx.Migrator().HasTable(&model.LogicalModel{}) { return nil @@ -198,6 +191,12 @@ func migrateSchemaV8(tx *gorm.DB) error { return nil } +func migrateSchemaV7(tx *gorm.DB) error { + if err := tx.AutoMigrate(&model.Asset{}, &model.AssetFolder{}); err != nil { + return fmt.Errorf("创建个人素材分类并扩展素材目录字段:%w", err) + } + return nil +} func MigrateSchema(db *gorm.DB) error { return db.Transaction(func(tx *gorm.DB) error { if tx.Dialector.Name() == "postgres" { diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 5557b2d79..e62653806 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -375,6 +375,18 @@ func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"ok": true}) }) + r.DELETE("/admin/users/:id/permanent", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.PurgeUser(user, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) r.GET("/admin/channels", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 998008a2c..53f057e6c 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -285,6 +285,20 @@ func (r *Repository) DeleteUserAuthSessions(userID string) error { return r.db.Delete(&model.AuthSession{}, "user_id = ?", userID).Error } +// CountUserRowsByTable 统计目标用户在给定表(均须含 user_id 列)中的行数,供删除前守卫使用。 +func (r *Repository) CountUserRowsByTable(userID string, tables []string) (map[string]int64, error) { + counts := make(map[string]int64, len(tables)) + for _, table := range tables { + var n int64 + if err := r.db.Table(table).Where("user_id = ?", userID).Count(&n).Error; err != nil { + return nil, err + } + if n > 0 { + counts[table] = n + } + } + return counts, nil +} func (r *Repository) LatestEmailVerificationCode(email string, purpose string) (*model.EmailVerificationCode, error) { var code model.EmailVerificationCode if err := r.db.Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose).Order("created_at desc").First(&code).Error; err != nil { diff --git a/backend/internal/repository/user_purge.go b/backend/internal/repository/user_purge.go new file mode 100644 index 000000000..3d0471451 --- /dev/null +++ b/backend/internal/repository/user_purge.go @@ -0,0 +1,213 @@ +package repository + +import ( + "fmt" + "strings" + + "infinite-canvas/backend/internal/database" + "infinite-canvas/backend/internal/model" + + "gorm.io/gorm" +) + +// UserPurgeResult 汇总彻底删除用户时清掉的行数(按表)。 +type UserPurgeResult struct { + DeletedByTable map[string]int64 `json:"deletedByTable"` + DeletedRowsTotal int64 `json:"deletedRowsTotal"` +} + +type purgeSchema struct { + model any + table string + dbNameOfGo map[string]string // Go 字段名 → 真实列名(尊重 gorm tag / 命名策略) +} + +func (p purgeSchema) column(goName string) string { + return p.dbNameOfGo[goName] +} + +// purgeFKTemplates 描述“指向用户子数据的列”如何回溯到用户。 +// 统一以 user_id = ? 收口;模板中 %s 为列名占位,? 为用户 ID 参数。 +// 由于所有模板都回溯到“带 user_id 的根表”,而根表行只在第二阶段删除, +// 第一阶段内部任意表间顺序都不会破坏子查询可见性。 +var purgeFKTemplates = map[string]string{ + "TaskID": "%s IN (SELECT id FROM tasks WHERE user_id = ?)", + "SessionID": "%s IN (SELECT id FROM sessions WHERE user_id = ?)", + "ProjectID": "%s IN (SELECT id FROM projects WHERE user_id = ?)", + "AssetID": "%s IN (SELECT id FROM assets WHERE user_id = ?)", + "CanvasID": "%s IN (SELECT id FROM canvas_projects WHERE user_id = ?)", + "ResourceID": "%s IN (SELECT id FROM resources WHERE user_id = ?)", + "StorageSettingID": "%s IN (SELECT id FROM user_oss_settings WHERE user_id = ?)", + "BillingOrderID": "%s IN (SELECT id FROM billing_orders WHERE user_id = ?)", + "PaymentOrderID": "%s IN (SELECT id FROM payment_orders WHERE user_id = ?)", + "AssetVersionID": "%s IN (SELECT id FROM asset_versions WHERE asset_id IN (SELECT id FROM assets WHERE user_id = ?))", + "ShotID": "%s IN (SELECT id FROM shots WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", + "UnitID": "%s IN (SELECT id FROM project_units WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", + "WorkflowInstanceID": "%s IN (SELECT id FROM workflow_instances WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", + "WorkflowStepID": "%s IN (SELECT id FROM workflow_step_instances WHERE workflow_instance_id IN (SELECT id FROM workflow_instances WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)))", + "RevisionID": "%s IN (SELECT id FROM shot_revisions WHERE shot_id IN (SELECT id FROM shots WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?)))", + "FolderID": "%s IN (SELECT id FROM project_asset_folders WHERE project_id IN (SELECT id FROM projects WHERE user_id = ?))", + "VoiceProfileID": "%s IN (SELECT id FROM voice_profiles WHERE user_id = ?)", +} + +// purgeOwnColumns 第二阶段直接归属用户的字段(Go 字段名)。 +var purgeOwnColumns = []string{"UserID", "OwnerID", "RedeemedBy"} + +// PurgeUserData 在单个事务内彻底删除一个用户及其名下业务数据,并(可选) +// 在事务尾部原子写入一条管理员审计记录(如 user.purge)。 +// 阶段一:删除所有能通过 FK 回溯到该用户的子数据(根表行仍在,子查询始终可见); +// 阶段二:删除直接归属该用户的行(user_id / owner_id / redeemed_by); +// 阶段三:审计记录(保留本次传入的审计)、邮箱验证码与用户主行。 +// 用户主行最后删,保证事务内全程可回溯。 +func (r *Repository) PurgeUserData(user *model.User, keepAudit *model.AdminAuditEvent) (*UserPurgeResult, error) { + userID := strings.TrimSpace(user.ID) + if userID == "" { + return nil, fmt.Errorf("缺少用户 ID") + } + email := strings.TrimSpace(user.Email) + + result := &UserPurgeResult{DeletedByTable: map[string]int64{}} + keepAction := "" + if keepAudit != nil { + keepAction = strings.TrimSpace(keepAudit.Action) + } + err := r.db.Transaction(func(tx *gorm.DB) error { + schemas, err := parsePurgeSchemas(tx) + if err != nil { + return err + } + + // 阶段一:子表级联(不含用户主行,也不含 user_id 直属行) + for _, schema := range schemas { + if _, isUser := schema.model.(*model.User); isUser { + continue + } + conds, args := buildFKPurgeConds(schema, userID) + if len(conds) == 0 { + continue + } + if err := deletePurgedRows(tx, schema, conds, args, result); err != nil { + return err + } + } + // 阶段二:直属行 + for _, schema := range schemas { + if _, isUser := schema.model.(*model.User); isUser { + continue + } + conds := make([]string, 0, 1) + args := make([]any, 0, 1) + for _, goName := range purgeOwnColumns { + if column := schema.column(goName); column != "" { + conds = append(conds, column+" = ?") + args = append(args, userID) + } + } + if len(conds) == 0 { + continue + } + if err := deletePurgedRows(tx, schema, conds, args, result); err != nil { + return err + } + } + // 阶段三:审计(该用户作操作者/被操作对象)、邮箱验证码、用户主行 + for _, schema := range schemas { + if _, ok := schema.model.(*model.AdminAuditEvent); ok { + if err := deleteAdminAuditRows(tx, schema, userID, keepAction, result); err != nil { + return err + } + } + if _, ok := schema.model.(*model.EmailVerificationCode); ok && email != "" { + if column := schema.column("Email"); column != "" { + if err := deletePurgedWhere(tx, schema, column+" = ?", []any{email}, result); err != nil { + return err + } + } + } + } + for _, schema := range schemas { + if _, isUser := schema.model.(*model.User); isUser { + if err := deletePurgedWhere(tx, schema, "id = ?", []any{userID}, result); err != nil { + return err + } + } + } + // 本次删除动作的审计记录在用户主行删除后写入,避免被上面的清理误删。 + if keepAudit != nil { + if err := tx.Create(keepAudit).Error; err != nil { + return fmt.Errorf("写入管理员审计失败:%w", err) + } + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func parsePurgeSchemas(db *gorm.DB) ([]purgeSchema, error) { + models := database.Models() + schemas := make([]purgeSchema, 0, len(models)) + for _, dst := range models { + stmt := &gorm.Statement{DB: db, Dest: dst} + if err := stmt.Parse(dst); err != nil { + return nil, fmt.Errorf("解析表结构失败(%T):%w", dst, err) + } + names := make(map[string]string, len(stmt.Schema.Fields)) + for _, field := range stmt.Schema.Fields { + names[field.Name] = field.DBName + } + schemas = append(schemas, purgeSchema{model: dst, table: stmt.Schema.Table, dbNameOfGo: names}) + } + return schemas, nil +} + +// buildFKPurgeConds 汇总一张表中命中“用户子数据 FK”的删除条件。 +func buildFKPurgeConds(schema purgeSchema, userID string) ([]string, []any) { + conds := make([]string, 0, 2) + args := make([]any, 0, 2) + for goName, template := range purgeFKTemplates { + column := schema.column(goName) + if column == "" { + continue + } + conds = append(conds, fmt.Sprintf(template, column)) + args = append(args, userID) + } + return conds, args +} + +func deletePurgedRows(tx *gorm.DB, schema purgeSchema, conds []string, args []any, result *UserPurgeResult) error { + combined := "(" + strings.Join(conds, " OR ") + ")" + return deletePurgedWhere(tx, schema, combined, args, result) +} + +func deletePurgedWhere(tx *gorm.DB, schema purgeSchema, where string, args []any, result *UserPurgeResult) error { + db := tx.Table(schema.table).Where(where, args...).Delete(schema.model) + if db.Error != nil { + return fmt.Errorf("清理 %s 失败:%w", schema.table, db.Error) + } + if db.RowsAffected > 0 { + result.DeletedByTable[schema.table] += db.RowsAffected + result.DeletedRowsTotal += db.RowsAffected + } + return nil +} + +func deleteAdminAuditRows(tx *gorm.DB, schema purgeSchema, userID string, keepAction string, result *UserPurgeResult) error { + actorColumn := schema.column("ActorUserID") + targetTypeColumn := schema.column("TargetType") + targetIDColumn := schema.column("TargetID") + actionColumn := schema.column("Action") + if actorColumn == "" || targetTypeColumn == "" || targetIDColumn == "" || actionColumn == "" { + return nil + } + where := "(" + actorColumn + " = ? OR (" + targetTypeColumn + " = 'user' AND " + targetIDColumn + " = ?))" + args := []any{userID, userID} + if keepAction != "" { + where += " AND " + actionColumn + " <> ?" + args = append(args, keepAction) + } + return deletePurgedWhere(tx, schema, where, args, result) +} diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 5297fd7e0..b5ef5f39a 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -389,6 +389,47 @@ func (s *Service) DeleteUser(actor *model.User, userID string) error { return s.appendAdminAudit(actor, "user.disable", "user", user.ID, "停用用户并清除登录态", nil) } +// purgeFinanceTables 存在即拒绝彻底删除的账务数据:真实资金流水/订单必须长期保留。 +var purgeFinanceTables = []string{"billing_orders", "payment_orders"} + +// PurgeUser 彻底删除(硬删)用户:账号与全部关联数据在单个事务内级联物理移除,不可恢复。 +// 守卫:仅管理员、不能删自己、管理员需保底一个;存在资金流水/账单等需保留的账务数据时拒绝,提示改用停用。 +// 业务内容(任务/项目/素材等)由 repo.PurgeUserData 按 FK 链在事务内级联删除,删除动作以 user.purge 审计落库。 +func (s *Service) PurgeUser(actor *model.User, userID string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + if actor.ID == userID { + return BadAuthRequest("不能删除当前登录的管理员账号") + } + user, err := s.repo.User(userID) + if err != nil { + return err + } + if user.Role == model.UserRoleAdmin { + count, err := s.repo.ActiveAdminCountExcluding(user.ID) + if err != nil { + return err + } + if count == 0 { + return BadAuthRequest("至少需要保留一个管理员") + } + } + financeCounts, err := s.repo.CountUserRowsByTable(user.ID, purgeFinanceTables) + if err != nil { + return err + } + if len(financeCounts) > 0 { + return BadAuthRequest("该用户存在资金流水/账单数据,无法彻底删除,请改用停用") + } + audit, err := newAdminAuditEvent(actor, "user.purge", "user", user.ID, "彻底删除用户账号及全部关联数据", nil) + if err != nil { + return err + } + _, err = s.repo.PurgeUserData(user, audit) + return err +} + func (s *Service) BulkDisableUsers(actor *model.User, req BulkDisableUsersRequest) (*BulkDisableUsersResult, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err diff --git a/docs/plans/ui-kit-consumption-audit.md b/docs/plans/ui-kit-consumption-audit.md new file mode 100644 index 000000000..1dcd19e21 --- /dev/null +++ b/docs/plans/ui-kit-consumption-audit.md @@ -0,0 +1,157 @@ +# UI-kit 消费一致性审计报告(页面 × 管理后台) + +> 审计时间:2026-09-06 | 审计范围:`web/src`(660 个 ts/tsx 文件全量静态扫描;其中 `pages/` 树 146 个文件按页面域逐一核对) +> 审计方法:按文件解析对 `antd` 与 `@/components/ui/*` 的 import,做"同角色残留对比"——即 ui 组件库已提供对标族的控件(Switch / Checkbox / Segmented / Select / Tooltip / Empty / Alert / Badge / 状态 Tag),页面仍直接 import 原生 antd 同款控件的,判为样式不一致风险点。 +> 判定依据:`docs/adr/0008-ui-component-layer-antd-replacement.md`、`docs/plans/ui-kit-rollout-plan.mdx`(B1–B5 批次与 2026-09-05 执行状态)、`web/src/components/ui/README.md`。文档声明均与本次代码扫描结果交叉核对。 + +## 一、结论速览 + +1. **文档声明的归零项属实**:antd `Empty`(0 处)、`Badge`(0 处)、状态语义 `Tag`(已清零)、`Alert`(全库仅剩 1 处 = `pages/projects/detail.tsx` 的项目已归档 banner,与 rollout 日志"唯一保留 detail.tsx 归档 banner"完全一致)。ui kit 的 EmptyState / Callout / StatusBadge 确实接过了这些角色。 +2. **页面与后台确实存在"未统一消费"的同角色双观感**,集中在 **5 个族**,按风险排序: + - **Select**:全库 53 个文件仍用 antd Select(后台 19、主界面页面 17、画布组件 10、共享组件 7),而 ui `select` 仅被 1 个文件消费; + - **Tooltip**:全库 29 个文件仍用 antd Tooltip(画布组件 16 为主),ui `tooltip` 仅 3 个文件消费; + - **Segmented**:画布组件 11 处 antd Segmented,与设置页/后台已铺开的 ui `SegmentedControl`(14 文件)并存; + - **Switch**:画布浮层/节点面板 8 处 + 图片/视频设置面板 2 处仍是 antd Switch,与 ui `switch`(29 文件,主要分布在设置页与后台)并存; + - **Checkbox**:`canvas-script-node.tsx` 1 处 antd `Checkbox.Group`(列显隐选择器),ui `checkbox` 已在后台 2 个文件消费。 +3. **主界面页面区(`main/*`)与后台区(`admin/*`)都有页面完全零消费 ui**,其中部分零消费且零同角色残留(如登录注册 `auth/`),是通过 antd 全局 token 主题统一观感的;部分则存在可直接迁移的同角色残留(如 `create/` 的 antd Tooltip、`canvas/index.tsx` 的朴素 Select)。 +4. **结构性判断(与 rollout 计划一致)**:剩余 16 处 `Tag` 全部为分类 chip/彩标(无状态徽章语义),属有意保留,不判为不一致;`Spin` 4 处无 ui 对标,仅记录。ui kit 尚未提供的 Modal/Form/Table/Drawer/Input 等原生控件不在本次同角色判定范围。 + +## 二、判定映射表 + +| antd 控件 | 对标 ui 族 | 现状(全库 import 文件数) | +| --- | --- | --- | +| `Switch` | `ui/base/switch` | antd 10 处 vs ui 29 文件 | +| `Checkbox(+Group)` | `ui/base/checkbox` | antd 1 处 vs ui 2 文件 | +| `Segmented` | `ui/base/segmented-control` | antd 11 处 vs ui 14 文件 | +| `Select` | `ui/base/select`(单选壳;搜索/多选未覆盖) | antd 53 处 vs ui 1 文件 | +| `Tooltip` | `ui/base/tooltip` | antd 29 处 vs ui 3 文件 | +| `Empty` | `ui/product/empty-state` | antd **0** 处 vs ui 11 文件(已归零 ✓) | +| `Alert` | `ui/product/callout` | antd **1** 处(detail.tsx 归档 banner,有意保留)vs ui 9 文件 | +| `Badge` | `ui/base/badges(StatusBadge)` | antd **0** 处 vs ui 13 文件(已归零 ✓) | +| `Tag`(状态语义) | `ui/base/badges` | 状态场景已清零;剩余 16 处均为分类 chips(有意保留) | +| `Spin` | (无 ui 对标) | 4 处,仅信息项 | + +## 三、页面域 × ui 消费矩阵(pages 树,146 文件) + +图例:`ui=` 直接 import ui 族的文件数;`ui族` 该区用到的 ui 族;`残留` 该区 antd 同角色 import 计数("—"=无)。 + +### 主界面页面区(main/*,106 文件) + +| 页面域 | 文件 | ui | ui 族 | 同角色残留 | +| --- | --- | --- | --- | --- | +| auth(登录/注册) | 4 | 0 | — | — | +| assets(素材库) | 4 | 2 | IconButton/ToolButton, StatusBadge | Select×2, Tag×1(chip) | +| canvas(创作画布页) | 43 | 3 | Kbd, fx | Select×1(朴素), Tag×2(chip) | +| create(新建) | 4 | 0 | — | Tooltip×1, Spin×1 | +| dev | 2 | 1 | StatusBadge, Switch | — | +| not-found / route-error | 2 | 2 | fx | — | +| plugins(插件中心) | 5 | 2 | EmptyState, IconButton/ToolButton, Switch | Select×1, Tag×1(chip), Spin×1 | +| projects(项目管理) | 20 | 5 | Callout, EmptyState, SegmentedControl, StatusBadge | Select×4, Tooltip×3, Alert×1(有意保留) | +| settings(用户设置) | 9 | 6 | Callout, SegmentedControl, Select, StatusBadge, Switch | Select×5, Tooltip×1, Tag×2(chip) | +| skills(技能中心) | 5 | 3 | SegmentedControl, Switch | Select×3, Tooltip×2 | +| tasks(任务中心) | 6 | 4 | IconButton/ToolButton, SegmentedControl, Switch, Tooltip | Select×1 | +| wallet(钱包) | 1 | 1 | SegmentedControl | Tag×1(chip) | +| test-voice-recording | 1 | 0 | — | — | + +### 管理后台区(admin/*,40 文件) + +| 页面域 | 文件 | ui | ui 族 | 同角色残留 | +| --- | --- | --- | --- | --- | +| channels(渠道管理) | 1 | 1 | Switch | Select×1 | +| components(后台共享面板) | 15 | 8 | Checkbox, EmptyState, IconButton/ToolButton, SegmentedControl, StatusBadge, Switch, fx | Select×8, Tooltip×2, Tag×1(chip) | +| logical-models(逻辑模型) | 2 | 2 | Callout, StatusBadge, Switch | Select×2, Tag×1(chip) | +| logs(操作日志) | 1 | 1 | IconButton/ToolButton | Select×1 | +| payments(账单) | 1 | 1 | Callout, Switch | Select×1 | +| plugins(后台插件) | 1 | 1 | Switch | Select×1 | +| redemption-codes(兑换码) | 1 | 0 | — | — | +| settings(后台设置,9 文件) | 9 | 6 | IconButton/ToolButton, SegmentedControl, Switch, Tooltip | Select×2 | +| storyboard-prompts | 1 | 1 | Callout, StatusBadge | Select×1 | +| users(用户管理) | 4 | 1 | Checkbox | Select×2 | +| 非页面文件(context/route-pages/index/lib) | 4 | 0 | — | — | + +### 零消费 ui 的页面(需重点关注) + +| 页面 | 说明 | 风险 | +| --- | --- | --- | +| `main/auth`(登录/注册/找回) | 0 ui、0 残留 | 低:表单/命令类控件 ui 未提供对标,靠 antd token 主题统一 | +| `main/create` | 0 ui,残留 antd Tooltip×1 | **中**:ui tooltip 已可用,直接可迁移 | +| `admin/redemption-codes` | 文件本身 0 ui 0 残留 | 低:实际 UI 由 `admin/components/*`(8/15 文件消费 ui)渲染,属间接继承 | +| `main/canvas/index.tsx` | 残留朴素 Select×1 | 中:画布主工作区大量 UI 在 `components/canvas/*`,见下 | + +## 四、同角色残留明细(含组件树,供整改引用) + +### 1. Select — 53 处(ui select 仅 1 文件消费) + +- 分布:后台页 19、主界面页面 17、画布组件 10、共享组件 7。 +- 用法分类(pages 树 36 处):**24 处带 `mode=multiple/tags` 或 `showSearch`**(ui 单选壳不覆盖,属已知能力缺口,需 ComboBox/多选落地后才能替换);**12 处为朴素单选**,是可直接评估切换 ui `select` 的候选: + `admin/components/email-settings-panel.tsx`、`admin/settings/components/skin-theme-editor.tsx`、`admin/settings/storage-settings-page.tsx`、`admin/users/users-drawer.tsx`、`pages/canvas/index.tsx`、`pages/projects/detail/canvases.tsx`、`pages/projects/detail/settings.tsx`、`pages/settings/diagnostics-panel.tsx`、`pages/settings/index.tsx`、`pages/settings/prompt-preferences-pane.tsx`、`pages/skills/skill-editor-drawer.tsx`、`pages/skills/skill-install-modal.tsx` +- 其余代表性文件:`pages/admin/components/analytics-panel.tsx`、`admin/users/users-panel.tsx`、`pages/settings/channel-settings-pane.tsx`(mode 多选)、`pages/tasks/index.tsx`(showSearch)等。 + +### 2. Tooltip — 29 处(ui tooltip 仅 3 文件消费) + +- 画布组件 16:`components/canvas/canvas-agent-chat-ui.tsx`、`canvas-config-node-panel.tsx`、`canvas-toolbar.tsx` 系列、`director/*` 等; +- 主界面页面 7:`pages/create/index.tsx`、`pages/projects/detail.tsx`、`pages/projects/detail/canvases.tsx`、`pages/projects/detail/chapters.tsx`、`pages/settings/channel-settings-pane.tsx`、`pages/skills/index.tsx`、`pages/skills/skill-detail-drawer.tsx`; +- 共享组件 4:`channel-headers-editor.tsx`、`conversation/voice-recording-*.tsx`×2、`workflow-field-mapping-editor.tsx`; +- 后台 2:`pages/admin/components/admin-shell.tsx`、`analytics-panel.tsx`。 +- 结论:ui `tooltip` 已可覆盖 help/说明类场景,属**最高性价比迁移项**。 + +### 3. Segmented — 11 处,全部在画布组件树 + +`canvas-appearance-controls.tsx`、`canvas-assistant-panel.tsx`、`canvas-config-node-panel.tsx`、`canvas-local-agent-panel.tsx`、`canvas-node-angle-dialog.tsx`、`canvas-node-upscale-dialog.tsx`、`canvas-script-node.tsx`、`canvas-subtitle-dialog.tsx`、`canvas-video-segment-dialog.tsx`、`portrait-clearance/portrait-clearance-modal.tsx`、`style-profile-editor-modal.tsx`。 + +- 同一角色:用户设置页与后台已用 ui `SegmentedControl`(14 文件)。**画布面板控件是"两套分段控件观感"最集中的区域。** + +### 4. Switch — 10 处,全部在 pages 树外 + +画布 8:`canvas-agent-panel-chrome.tsx`、`canvas-config-node-panel.tsx`、`canvas-subtitle-dialog.tsx`、`canvas-toolbar.tsx`、`director/canvas-director-workbench.tsx`、`portrait-clearance/portrait-clearance-modal.tsx`、`style-asset-binding-modal.tsx`、`toolbars/toolbar-settings-modal.tsx`; +共享 2:`components/image-settings-panel.tsx`、`components/video-settings-panel.tsx`。 + +- 同一角色:ui `switch` 已消费 29 文件(后台 15、主界面 8、共享 6)。开关控件同页面混用观感差异集中在画布浮层/设置面板。 + +### 5. Checkbox — 1 处 + +`components/canvas/canvas-script-node.tsx` 的 `Checkbox.Group`(脚本列显隐选择)。ui `checkbox` 已在后台 2 个文件消费,ui 侧含 CheckboxGroup 时可直接迁移。 + +### 6. 有意保留 / 信息项(不判为不一致) + +- `Tag`×16(含画布 import 对话框、设置项、后台面板等):抽查均为分类 chip/彩标,无状态语义; +- `Spin`×4:`pages/create/index.tsx`、`pages/plugins/eagle.tsx`、`components/canvas/canvas-share-modal.tsx`、`components/conversation/voice-recording-inline.tsx`——ui 暂无 loading 对标(loading-state 若立项需同步处理); +- `Alert`×1:`pages/projects/detail.tsx` 归档 banner(有意保留)。 + +## 五、与 rollout 计划执行状态的一致性核对 + +| rollout 日志声明(2026-09-05) | 本次扫描结果 | 一致 | +| --- | --- | --- | +| AntD Empty 全量归零(12 处) | 全库 0 处 | ✓ | +| Alert → Callout 全量归零(18 处,唯一保留 detail.tsx 归档 banner) | 全库仅 detail.tsx 1 处(已核实为"项目已归档"banner) | ✓ | +| Tag 状态徽章 → StatusBadge 清零(22 处) | 剩余 16 处均为 chips | ✓ | +| Badge 全库零使用 | 0 处 | ✓ | +| Segmented 结构性保留 | 11 处全在画布组件;但 ui SegmentedControl 已 14 文件在用 | ⚠ 双实现并存,需决策 | +| B3(Switch/Tooltip RAC 化)待推进 | ui switch/tooltip 已建成且部分页面提前接线,但 antd 版本残留 10/29 处 | ⚠ 与"待推进"一致 | +| ui select 仅单选壳 | 53 处 antd Select 中 24 处需搜索/多选能力,尚未可替换 | ✓(覆盖缺口) | + +## 六、整改建议(按优先级) + +### P0 — 同角色直接替换(ui 能力已具备,改动面小) + +1. **Tooltip 迁移(29 处)**:ui `tooltip` 已覆盖 help 场景,按"共享组件 → 画布 → 页面"顺序替换,含 `admin-shell.tsx`、`pages/projects/detail*.tsx`、`pages/skills/*` 等。 +2. **画布浮层 Switch 迁移(10 处)**:`canvas-toolbar.tsx`、`canvas-config-node-panel.tsx` 等 8 + 2 处换 ui `switch`,与设置/后台观感统一。 +3. **朴素单选 Select 迁移(12 处)**:上列候选文件逐个评估语义后切 ui `select`(含 `pages/canvas/index.tsx`、`pages/settings/index.tsx` 等高频页面)。 +4. **Checkbox.Group(1 处)**:ui checkbox 具备 Group 能力后迁移 `canvas-script-node.tsx`。 + +### P1 — 画布结构性决策(需产品/视觉确认) + +5. **Segmented 双实现统一**:画布 11 处 antd Segmented vs 设置/后台 ui SegmentedControl。若统一到 ui,需先比对两者在画布高密度工具条中的尺寸/键盘行为差异,建议以画布外观控件为试点做视觉回归。 +6. **Select 能力缺口补齐**:为 ui select 族补齐搜索(ComboBox)与多选模式,再批量替换剩余 24+ 处;在此之前后台 19 处与画布 10 处的搜索/多选下拉保持 antd 是**合理降级**,建议在 rollout 计划中显式标注"已知缺口",避免被误判为回退。 + +### P2 — 一致性基建 + +7. **补 loading 对标**:`Spin` 4 处若 ui 立项 loading-state,同步替换。 +8. **规则化防回退**:建议在 lint 层(如 eslint no-restricted-imports)禁止 pages/components 新代码直接 import `antd` 的 Switch/Segmented/Tooltip/Checkbox/Empty/Alert/Badge,防止归零项反弹。 +9. **计划文档同步**:将本报告的分布数据与上述缺口标注回填 `docs/plans/ui-kit-rollout-plan.mdx` 的 B3/B4 批次范围。 + +## 附录:范围说明 + +- 本报告为 **import 级静态审计**,判定的是"页面是否直接继承 ui 族控件";组合渲染路径(如 admin 页面经 `admin/components/*` 面板间接继承 ui)已在矩阵中单列说明。 +- 视觉差异的最终确认仍需浏览器回归(antd 主题 token 与 ui 族同为 token 驱动,但控件结构/交互细节不同,如 RAC 的 Switch 与 antd Switch 的轨道尺寸与焦点态)。 +- 审计脚本存放于 `.local/audit-ui-kit/`(git 忽略,可复跑);仅本报告文件纳入版本库。 diff --git a/docs/plans/ui-kit-rollout-plan.mdx b/docs/plans/ui-kit-rollout-plan.mdx index e7b57f28b..59be0d58a 100644 --- a/docs/plans/ui-kit-rollout-plan.mdx +++ b/docs/plans/ui-kit-rollout-plan.mdx @@ -188,6 +188,13 @@ NodeTypeBadge、TextTooltip、LoadingState、SectionCard、PropertyRow、IconGro - UI kit 引用文件 20+;每批 `bun run typecheck` + dev server 编译(HTTP 200)验证通过。git 改动未提交,批次提交按 §ADR 约定进行。 - **下一闸口**:composio Stage A token 迁移(globals.css 数值翻译 + antd skin 同步),改动会全局位移视觉,需用户目验确认后再动。 +**执行状态(2026-09-06)——ui-kit 消费审计 + P0 同角色迁移**: + +- 审计:出具 `docs/plans/ui-kit-consumption-audit.md`(660 文件全量扫描 + pages 146 文件逐域矩阵),核验 B1 归零声明与代码一致;剩余同角色残留集中在 Tooltip / Select / Switch / Segmented(画布工作区 vs 设置/后台)。 +- P0 实施:Tooltip 29 处 → ui `base/tooltip`(含 admin-shell、projects/detail 的 `mouseEnterDelay` 秒→`delay` ms、`rootClassName` 死类删除),antd Tooltip 全库归零;Switch 10 处 → ui `base/switch`(`size="small"→"sm"`),antd Switch 全库归零;朴素单选 Select 12 处 → ui `base/select`(`id`→`ariaLabel`、options 类型标注收窄 onChange),其中 `detail/canvases.tsx` 因 `suffixIcon`+`loading` 能力缺口保留 antd;Checkbox.Group 1 处(canvas-script-node 列选择器)→ ui `base/checkbox`,清理 globals.css 三段 antd 死选择器。 +- 保留与缺口:搜索/多选/远程 Select(约 24 处,含后台 19 处中多数)ui 单选壳不覆盖,待 ComboBox/多选能力补足后迁移(计划显式标注为已知缺口而非回退);画布 11 处 Segmented 维持结构性决策;detail.tsx 归档 Alert banner 维持保留。 +- 验证:`tsc --noEmit` + `vite build` 通过(跳过与本次无关的 build:bridge 预构建);浏览器目验清单见 `docs/content/docs/progress/pending-test.mdx`(UI 组件同角色迁移节)。git 改动未提交。 + ### 阶段 E:application 产品块(P3,按需立项) 对齐 BoardUI `application/`:agent-chat(composer/history/message)、agent-thinking 指示、notification-center、app-shell、data-table 等,映射影策 AI 工作台场景(画布 AI 助手面板、生成/转写日志、通知中心)。每块立项前先有真实页面需求与 ≥2 处重复证据。