From cd8de97f79d1ae37a59475d33cf18dc8ac4c23f3 Mon Sep 17 00:00:00 2001 From: aid Date: Thu, 9 Apr 2026 18:48:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BC=95=E5=85=A5=E6=9C=AA=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E4=BD=9C=E5=93=81=E7=8A=B6=E6=80=81=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=8C=96=E6=93=8D=E4=BD=9C=E9=9D=A2=E6=9D=BF=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=20UI=20=E7=AC=AC=E4=B8=80=E9=98=B6=E6=AE=B5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 docs/design/public/ugc-work-status-redesign.md 完整设计方案与状态流转图 - UserWork.status 类型化为 WorkStatus 联合类型,加入 unpublished 中间状态 - 作品库 Index.vue 加「未发布」tab + 紫色标签样式 + emptyDescription + dev mock 兜底 - Detail.vue 完整重写:清 emoji + 紫粉化 + 根据 status 切换 5 套操作按钮 · draft → 继续创作 · unpublished → 公开发布 / 编辑信息 · pending_review → 撤回审核 · published → 下架 · rejected → 修改后重交(含拒绝原因卡片) - EditInfoView 三按钮语义调整:「保存」→ unpublished、「直接发布」→ pending_review - 删除独立 Publish.vue 与对应路由(发布功能并入 Detail.vue 公开发布按钮) - 新建 _dev-mock.ts dev 模式数据共享文件,5 条覆盖全状态的 mock 作品 + 13 页详情 - 撤回 / 下架等接口与 leai workId 映射留 TODO,待后端第二阶段联调 详见 docs/design/public/ugc-work-status-redesign.md Co-Authored-By: Claude Opus 4.6 (1M context) --- .../design/public/ugc-work-status-redesign.md | 417 +++++++++++ frontend/src/api/public.ts | 26 +- frontend/src/router/index.ts | 6 - .../public/create/views/EditInfoView.vue | 16 +- frontend/src/views/public/works/Detail.vue | 645 ++++++++++++++++-- frontend/src/views/public/works/Index.vue | 44 +- frontend/src/views/public/works/Publish.vue | 239 ------- frontend/src/views/public/works/_dev-mock.ts | 211 ++++++ 8 files changed, 1270 insertions(+), 334 deletions(-) create mode 100644 docs/design/public/ugc-work-status-redesign.md delete mode 100644 frontend/src/views/public/works/Publish.vue create mode 100644 frontend/src/views/public/works/_dev-mock.ts diff --git a/docs/design/public/ugc-work-status-redesign.md b/docs/design/public/ugc-work-status-redesign.md new file mode 100644 index 0000000..1ae16b1 --- /dev/null +++ b/docs/design/public/ugc-work-status-redesign.md @@ -0,0 +1,417 @@ +# UGC 作品状态机重设计 — 产品设计方案 + +> 所属端:用户端(公众端)+ 超管端 + 后端 +> 状态:方案已确认,前端 UI 优先实施 +> 创建日期:2026-04-09 +> 最后更新:2026-04-09 + +--- + +## 1. 背景 + +### 1.1 当前问题 + +用户作品(`UgcWork`)的状态当前**只有 4 个用户可见状态**: + +``` +draft / pending_review / published / rejected +``` + +这种状态划分把两个**语义完全不同**的场景混在了 `draft` 里: + +| 场景 | 当前归属 | 实际语义 | +|---|---|---| +| AI 还在生成 / 用户没保存编目信息 | draft | 半成品,没做完 | +| 用户做完了完整作品但不想公开 | draft | 成品但私有 | + +混在一起的后果: +- 用户在作品库 "草稿" tab 看到混杂——既有"待继续"的半成品,也有"已完成等待发布"的私有作品 +- 没有"已完成但私有"的归属,用户想暂存自己的成品作品没地方放 +- "公开发布"是隐藏在创作流程末端的一次性决策,用户错过了就只能看着自己的作品停在 draft + +### 1.2 业界对比 + +主流内容平台都做了「**还没做完**」和「**做完了但不想公开**」的区分: + +| 平台 | 半成品 | 成品但私有 | 公开 | +|---|---|---|---| +| 抖音 | 草稿箱 | 仅自己可见 | 公开发布 | +| 小红书 | 草稿 | 私密笔记 | 公开发布 | +| B 站 | 草稿箱 | 仅自己可见 | 已发布(公开) | +| Medium | Draft | Unlisted | Published | + +我们的方案对齐这个共识。 + +### 1.3 目标 + +引入新的中间状态 **`unpublished`(未发布)**,明确语义边界: + +- **草稿(draft)** = 作品技术上还没完成(生成失败 / 还在生成 / 没保存编目) +- **未发布(unpublished)** = 作品技术上完整(编目已保存),但用户没主动公开 +- **审核中(pending_review)** = 用户主动提交审核 +- **已发布(published)** = 审核通过,发现页可见 +- **被拒绝(rejected)** = 审核未通过,可改后重新提交 + +并支持: +- 作品在状态间双向流转(已发布可以下架回未发布、被拒可以改后重交) +- 「公开发布」从一次性决策变成可后悔、可推迟的常驻按钮 + +--- + +## 2. 状态机设计 + +### 2.1 完整状态流转图 + +``` + ┌──────────────────────────┐ + │ 创作流程开始 │ + │ /p/create/upload │ + └────────────┬─────────────┘ + │ + ▼ + ┌───────────────┐ + │ DRAFT │ 半成品:还在生成或未保存编目 + │ 草稿 │ + └───────┬───────┘ + │ + │ 用户在 EditInfoView 点 + │ 保存 / 去配音 / 立即发布 + │ 任意按钮 = 编目完成 + ▼ + ┌───────────────┐ + │ UNPUBLISHED │ 成品私有:已编目,可补配音,可发布 + │ 未发布 │◀──────────────┐ + └───────┬───────┘ │ + │ │ + │ 用户在详情页点 │ + │ "公开发布" │ + ▼ │ + ┌───────────────┐ │ + │PENDING_REVIEW │ 排队待审核 │ + │ 审核中 │ │ + └───┬───────┬───┘ │ + │ │ │ + 超管通过 │ │ 超管拒绝 │ + ▼ ▼ │ + ┌───────────────┐ ┌───────────────┐ │ + │ PUBLISHED │ │ REJECTED │ │ + │ 已发布 │ │ 被拒绝 │ │ + │ 发现页可见 │ │ │ │ + └───────┬───────┘ └───────┬───────┘ │ + │ │ │ + │ 用户/超管下架 │ 用户改完重交 │ + └─────────────────┴─────────────┘ + │ + rejected → pending_review + published → unpublished +``` + +### 2.2 状态流转表 + +| 起始状态 | 触发 | 目标状态 | 触发方 | 接口 | +|---|---|---|---|---| +| (无) | 创建作品 | DRAFT | 系统 | leai 创作流程内部 | +| DRAFT | 编目完成(leai status → CATALOGED) | UNPUBLISHED | 系统(webhook 同步) | LeaiSyncService | +| UNPUBLISHED | 用户点「公开发布」 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` | +| UNPUBLISHED | 用户补充配音/编辑 | UNPUBLISHED | 用户 | 更新内容接口 | +| PENDING_REVIEW | 审核通过 | PUBLISHED | 超管 | `POST /content-review/works/{id}/approve` | +| PENDING_REVIEW | 审核拒绝 | REJECTED | 超管 | `POST /content-review/works/{id}/reject` | +| PENDING_REVIEW | 用户撤回 | UNPUBLISHED | 用户 | `POST /public/works/{id}/withdraw`(新增) | +| PUBLISHED | 用户下架 | UNPUBLISHED | 用户 | `POST /public/works/{id}/unpublish`(新增) | +| PUBLISHED | 超管强制下架 | UNPUBLISHED 或 TAKEN_DOWN | 超管 | `POST /content-review/works/{id}/takedown` | +| REJECTED | 改完重交 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` | + +### 2.3 状态可见性矩阵 + +| 状态 | 用户作品库可见 | 用户详情页可见 | 发现页可见 | 超管端可见 | +|---|---|---|---|---| +| DRAFT | ✓(草稿 tab) | ✓(继续创作) | ✗ | ✗ | +| UNPUBLISHED | ✓(未发布 tab) | ✓(带"公开发布"按钮) | ✗ | ✗ | +| PENDING_REVIEW | ✓(审核中 tab) | ✓(带"撤回"按钮) | ✗ | ✓(待审核队列) | +| PUBLISHED | ✓(已发布 tab) | ✓(带"下架"按钮) | ✓ | ✓(已发布管理) | +| REJECTED | ✓(被拒绝 tab) | ✓(带"修改后重交"按钮) | ✗ | ✓(历史) | + +--- + +## 3. 当前现状调研 + +### 3.1 后端现状 + +#### `UgcWork.status` 字段 + +- 类型:`Integer`(非 String) +- 文件:`backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java:45` +- 语义:**当前直接复用了 leai 进度状态**,没有独立的发布状态字段 + +```java +// 当前注释 +// -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, +// 3=COMPLETED, 4=CATALOGED, 5=DUBBED +``` + +#### Migration 历史映射 + +`backend-java/src/main/resources/db/migration/V5__leai_integration.sql` 把旧 varchar status 转换成 INT 时的映射: + +``` +draft → 0 +pending_review → 1 (与 leai PENDING 复用!) +processing → 2 (与 leai PROCESSING 复用) +published → 3 (与 leai COMPLETED 复用!) +rejected → -1 (与 leai FAILED 复用!) +taken_down → -2 +``` + +**关键问题**:本地的 `published` 和 leai 的 `COMPLETED` 共享 `status=3`,本地的 `pending_review` 和 leai 的 `PENDING` 共享 `status=1`,本地的 `rejected` 和 leai 的 `FAILED` 共享 `status=-1`。这种语义复用是历史包袱,也是这次重设计的根本原因。 + +#### Webhook 同步逻辑 + +- 文件:`backend-java/.../leai/service/LeaiSyncService.java` +- 当前规则:`remoteStatus > localStatus` 时全量更新 +- **缺失**:leai status=4(CATALOGED)到本地 unpublished 的映射尚未实现 + +#### 现有审核 / 管理接口 + +| 接口 | 路径 | 当前行为 | +|---|---|---| +| 公众端发布 | `POST /public/works/{id}/publish` | draft/rejected → 1(被复用为 pending_review) | +| 超管审核通过 | `POST /content-review/works/{id}/approve` | → 3(被复用为 published) | +| 超管审核拒绝 | `POST /content-review/works/{id}/reject` | → -1(被复用为 rejected) | +| 超管下架 | `POST /content-review/works/{id}/takedown` | → -2(taken_down) | +| 超管恢复 | `POST /content-review/works/{id}/restore` | → 3(published) | +| 超管撤销审核 | `POST /content-review/works/{id}/revoke` | → 1(pending_review) | + +### 3.2 前端现状 + +#### 类型定义 + +- 文件:`frontend/src/api/public.ts:380-405` +- `UserWork.status: string`,枚举:`draft / pending_review / published / rejected / taken_down` +- **跟后端 Integer 不一致**——必有中间转换层(待验证),暂未确认转换发生在 controller 还是 mapper + +#### 公众端页面 + +| 文件 | 当前作用 | 改造需求 | +|---|---|---| +| `views/public/works/Index.vue` | 作品列表(5 tab) | 加 unpublished tab、加状态颜色映射 | +| `views/public/works/Detail.vue` | 作品详情 | 加「公开发布」「下架」「撤回」按钮,根据 status 切换显示 | +| `views/public/works/Publish.vue` | 单独的发布页(draft → pending_review) | 删除(功能并入 EditInfoView 和 Detail.vue) | +| `views/public/create/views/EditInfoView.vue` | 创作流程内编辑信息 | 三按钮语义调整:保存 / 去配音 / 立即发布 | + +#### 超管端页面 + +| 文件 | 当前作用 | 改造需求 | +|---|---|---| +| `views/content/WorkReview.vue` | 审核 pending_review 作品 | 状态映射加 unpublished(虽然超管看不到) | +| `views/content/WorkManagement.vue` | 管理 published / taken_down 作品 | 下架目标改为 unpublished(替代 taken_down) | + +--- + +## 4. 后端 schema 改动 + +### 4.1 推荐方案:拆字段(leai 进度 vs 本地发布状态解耦) + +**核心思想**:把 leai 进度状态和本地发布状态分到两个独立字段,彻底解决语义复用。 + +```sql +-- 现有:status INT(混淆 leai 进度 + 本地发布状态) +-- 改为: +ALTER TABLE t_ugc_work + ADD COLUMN leai_status INT NOT NULL DEFAULT 0 + COMMENT 'leai 创作进度: -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED'; + +-- status 字段重定义为本地发布状态(VARCHAR 跟前端对齐) +ALTER TABLE t_ugc_work + MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft' + COMMENT '本地发布状态: draft/unpublished/pending_review/published/rejected'; +``` + +### 4.2 数据迁移 + +新建 `V9__split_work_status.sql`: + +```sql +-- 1. 加 leai_status 字段 +ALTER TABLE t_ugc_work ADD COLUMN leai_status INT NOT NULL DEFAULT 0; + +-- 2. 把现有 status (INT) 的值复制到 leai_status +UPDATE t_ugc_work SET leai_status = status; + +-- 3. 改 status 字段类型为 VARCHAR +ALTER TABLE t_ugc_work MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft'; + +-- 4. 按 leai_status 回填本地 status +UPDATE t_ugc_work SET status = CASE + WHEN leai_status = -2 THEN 'unpublished' -- 原 taken_down 视为未发布 + WHEN leai_status = -1 THEN 'draft' -- 原 rejected 视为草稿(无法判断是创作失败还是审核拒绝) + WHEN leai_status = 0 THEN 'draft' + WHEN leai_status = 1 THEN 'unpublished' -- 原 pending_review 兼容数据视为未发布(保守,让用户重新主动发布) + WHEN leai_status = 2 THEN 'draft' + WHEN leai_status = 3 THEN 'unpublished' -- 原 published 兼容数据视为未发布(同上,避免老数据自动公开) + WHEN leai_status = 4 THEN 'unpublished' + WHEN leai_status = 5 THEN 'unpublished' + ELSE 'draft' +END; + +-- 5. 加索引(按状态筛选会很常用) +CREATE INDEX idx_ugc_work_status ON t_ugc_work(status); +CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status); +``` + +**保守策略**:现有 `published` / `pending_review` 数据回填为 `unpublished` 而不是直接保留 `published`。原因是状态复用导致无法区分"用户真的发布过的"和"刚创作完成的",让用户重新主动发布最安全。 + +### 4.3 不推荐的备选方案:保留单字段 + +不改 schema,沿用 `status INT`,在 0-5 之外加 `10=unpublished, 11=pending_review_local, 12=published_local`。 + +**缺点**: +- 语义混乱继续存在 +- 同事维护时容易把 leai 数字和本地数字搞混 +- migration 简单但留长期技术债 + +--- + +## 5. 改动清单 + +### 5.1 后端(P0) + +- [ ] **新建 `WorkPublishStatus` 枚举类** `backend-java/.../ugc/enums/WorkPublishStatus.java` + ```java + public enum WorkPublishStatus { + DRAFT, UNPUBLISHED, PENDING_REVIEW, PUBLISHED, REJECTED + } + ``` +- [ ] **`UgcWork` 实体改造** + - 加 `leaiStatus: Integer`(保持 leai 进度) + - 改 `status: String`(本地发布状态,对应新枚举) +- [ ] **新建 migration `V9__split_work_status.sql`**(见 4.2) +- [ ] **`LeaiSyncService.updateStatusForward`**:当 leai status 推进到 CATALOGED 时,自动把本地 status 从 draft 升到 unpublished(如果当前不是更高级状态) +- [ ] **`PublicUserWorkService.publish()`**:检查作品 `status == 'unpublished'`,改为 `unpublished → pending_review`;同时支持 `rejected → pending_review` +- [ ] **新增 `POST /public/works/{id}/unpublish`**:用户主动下架,`published → unpublished` +- [ ] **新增 `POST /public/works/{id}/withdraw`**:用户撤回审核,`pending_review → unpublished` +- [ ] **`PublicContentReviewService` 审核动作语义对齐**: + - approve: pending_review → published + - reject: pending_review → rejected + - takedown: published → unpublished(参见 6.C 关于 taken_down 的决策) + +### 5.2 公众端前端(P0) + +- [ ] **`UserWork.status` 类型加 `unpublished`** `frontend/src/api/public.ts` +- [ ] **`works/Index.vue` 作品库列表** + - tabs 数组加 `{ key: 'unpublished', label: '未发布' }` + - statusTextMap 加 `unpublished: '未发布'` + - 卡片状态标签样式加 `&.unpublished`(紫色淡) + - 按 status 显示空状态文案 +- [ ] **`works/Detail.vue` 作品详情** + - 整体配色清理紫粉化(emoji + 橙色清理) + - **核心**:根据 status 显示不同操作按钮: + - `unpublished` → 「公开发布」(主操作,紫粉渐变) + 「编辑信息」 + 「补充配音」 + 「删除」 + - `pending_review` → 「撤回审核」 + 「删除」 + - `published` → 「下架」 + 「删除」 + - `rejected` → 「修改后重交」 + 「删除」 +- [ ] **`create/views/EditInfoView.vue` 三按钮语义调整** + - 「保存」 → unpublished,跳作品库 `?tab=unpublished` + - 「去配音」 → DubbingView,配音完成后还是 unpublished + - 「立即发布」 → 一次性走完 unpublished + 公开发布两步,跳作品库 `?tab=pending_review` +- [ ] **删除 `views/public/works/Publish.vue`**(功能并入 EditInfoView 和 Detail.vue) +- [ ] **作品库 `query.tab` 已支持 `unpublished` 参数**(已在前次 commit 完成) + +### 5.3 超管端前端(P1) + +- [ ] **`views/content/WorkReview.vue`**:状态映射加 `unpublished` 显示(虽然超管的待审核 tab 看不到 unpublished,但作品历史详情可能展示) +- [ ] **`views/content/WorkManagement.vue`**:下架动作目标改为 unpublished(如果保留 taken_down 概念则不改) + +--- + +## 6. 待拍板细节 + +### 6.A schema 改动幅度 → ✅ 已确认 + +采用**方案 1(拆字段)**:`leai_status INT` + `status VARCHAR`,语义彻底解耦。 + +### 6.B Publish.vue 处理 → ✅ 已确认删除 + +独立的发布页在新逻辑下没有存在意义,删除。功能拆到: +- 编辑信息 → EditInfoView(创作流程内)+ Detail.vue「编辑信息」按钮(流程外回头改) +- 公开发布 → Detail.vue「公开发布」按钮 + +### 6.C 超管下架的目标状态 → ⏳ 待确认 + +两种方案: + +**方案 1(合并)**:超管下架 = 用户下架 = `published → unpublished`,作品回到用户私有空间,用户可以再次发布 + +**方案 2(区分)**:超管下架 = `published → taken_down`(独立状态),用户下架 = `published → unpublished`。`taken_down` 的作品**不允许用户再次发布**,相当于"被禁言" + +**讨论建议**:除非有"超管强制永久下架"的硬性需求,方案 1 更简洁。如果担心被超管下架的违规作品被用户再次提交,可以在审核环节让超管选择「拒绝并禁止重交」。 + +### 6.D 已确认的产品决策 + +| 问题 | 决策 | +|---|---| +| 已发布作品下架 → 未发布 | ✅ 需要 | +| 被拒绝作品改完重交 | ✅ 可以 | +| 草稿能否直接发布 | ❌ 不能(必须先编目完整成为 unpublished) | +| 超管端不显示 unpublished | ✅ 用户私有,超管不可见 | + +--- + +## 7. 执行顺序 + +### 7.1 第一阶段:前端 UI 层(不改后端) + +**目标**:让产品/设计/PM 能看到完整的新交互体验,前端用 dev mock 模拟 unpublished 状态。 + +- [ ] 作品库 `Index.vue` 加 unpublished tab + 颜色 + 文案 +- [ ] 作品库 `Detail.vue` 重做:清 emoji + 紫粉配色 + 根据 status 切换按钮 +- [ ] EditInfoView 三按钮语义调整 + dev mock 跳转 +- [ ] 删除 `Publish.vue` +- [ ] 前端 `UserWork.status` 类型扩展 + +**交付物**:完整的可点击 UI 流程(dev 模式无后端可走通) + +### 7.2 第二阶段:后端 schema + service + +**前置依赖**:第一阶段 UI 已确认,产品验收通过 + +- [ ] 新建 `WorkPublishStatus` 枚举 +- [ ] 新建 `V9__split_work_status.sql` migration +- [ ] `UgcWork` 实体加 `leaiStatus` + 改 `status` 类型 +- [ ] `LeaiSyncService` 加 leai → 本地 status 映射逻辑 +- [ ] `PublicUserWorkService` publish/unpublish/withdraw 接口实现 +- [ ] `PublicContentReviewService` 审核动作 status 语义对齐 + +### 7.3 第三阶段:联调 + 超管端调整 + +- [ ] 前端去 dev mock,接入真实接口 +- [ ] 超管端 WorkReview / WorkManagement 状态映射调整 +- [ ] 端到端测试:完整跑通从创作 → 编目 → 未发布 → 公开发布 → 审核 → 已发布 → 下架 → 重新发布的全流程 + +--- + +## 8. 相关文档 + +- [用户端 UGC 社区升级](./ugc-platform-upgrade.md) — 上层产品定位 +- [UGC 开发计划](./ugc-development-plan.md) — 整体开发节奏 +- [超管端内容管理](../super-admin/content-management.md) — 超管审核流程 +- [超管端作品数据优化](../super-admin/works-data-optimization.md) — 超管端作品列表 + +--- + +## 附录 A:术语对照 + +| 术语 | 含义 | 备注 | +|---|---|---| +| **leai** | 乐读派外部创作服务 | 提供 AI 绘本生成能力 | +| **leai status** | 乐读派的创作进度状态 | 数字 -1 ~ 5 | +| **本地发布状态** | 我们自己定义的作品发布状态 | 字符串,5 个值 | +| **CATALOGED** | leai 状态 4,编目完成 | 用户在 EditInfoView 保存信息后到达 | +| **DUBBED** | leai 状态 5,配音完成 | 配音是可选步骤 | +| **未发布 unpublished** | 本次新增状态 | 已编目完成但未公开 | + +## 附录 B:状态命名为什么是 `unpublished` 而不是 `private` + +- `private` 在很多平台是"永久不公开"的语义(比如 GitHub private repo) +- `unpublished` 强调"还没发布"的暂时性,跟"已发布 published"形成对立 +- 跟 Medium 的 Draft / Unlisted / Published 三态命名风格一致 +- 本地化为"未发布"也是中文用户最直观的理解 diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index 54598a5..efad08d 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -377,6 +377,30 @@ export const publicInteractionApi = { // ==================== 用户作品库 ==================== +/** + * 用户作品发布状态 + * + * 状态流转: draft → unpublished → pending_review → published / rejected + * published → unpublished(下架) + * rejected → pending_review(改后重交) + * + * - draft 草稿:作品技术上还没完成(生成失败 / 还在生成 / 没保存编目) + * - unpublished 未发布:作品技术上完整(编目已保存),但用户没主动公开 + * - pending_review 审核中:用户主动提交审核 + * - published 已发布:审核通过,发现页可见 + * - rejected 被拒绝:审核未通过 + * - taken_down 已下架:超管强制下架(历史兼容,新逻辑下用 unpublished 替代) + * + * 详见 docs/design/public/ugc-work-status-redesign.md + */ +export type WorkStatus = + | 'draft' + | 'unpublished' + | 'pending_review' + | 'published' + | 'rejected' + | 'taken_down' + export interface UserWork { id: number userId: number @@ -384,7 +408,7 @@ export interface UserWork { coverUrl: string | null description: string | null visibility: string - status: string + status: WorkStatus reviewNote: string | null originalImageUrl: string | null voiceInputUrl: string | null diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 6ef60df..f2447c4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -155,12 +155,6 @@ const baseRoutes: RouteRecordRaw[] = [ component: () => import("@/views/public/works/Detail.vue"), meta: { title: "作品详情" }, }, - { - path: "works/:id/publish", - name: "PublicWorkPublish", - component: () => import("@/views/public/works/Publish.vue"), - meta: { title: "发布作品" }, - }, ], }, // ========== 管理端路由 ========== diff --git a/frontend/src/views/public/create/views/EditInfoView.vue b/frontend/src/views/public/create/views/EditInfoView.vue index c686572..01e6ac1 100644 --- a/frontend/src/views/public/create/views/EditInfoView.vue +++ b/frontend/src/views/public/create/views/EditInfoView.vue @@ -1,6 +1,6 @@ @@ -91,16 +195,34 @@ import { ref, computed, onMounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { message } from 'ant-design-vue' import { - ArrowLeftOutlined, LeftOutlined, RightOutlined, - HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined, + LeftOutlined, RightOutlined, + HeartOutlined, HeartFilled, + StarOutlined, StarFilled, + EyeOutlined, + PictureOutlined, + WarningFilled, + InfoCircleOutlined, + SendOutlined, + EditOutlined, + UndoOutlined, + InboxOutlined, + DeleteOutlined, } from '@ant-design/icons-vue' -import { publicUserWorksApi, publicGalleryApi, publicInteractionApi, type UserWork } from '@/api/public' +import { + publicUserWorksApi, + publicGalleryApi, + publicInteractionApi, + type UserWork, +} from '@/api/public' +import { getMockWorkDetail, isMockWorkId } from './_dev-mock' import dayjs from 'dayjs' const route = useRoute() const router = useRouter() const workId = Number(route.params.id) +const isDev = import.meta.env.DEV + const work = ref(null) const loading = ref(true) const currentPageIndex = ref(0) @@ -112,6 +234,8 @@ const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.valu const isLoggedIn = computed(() => !!localStorage.getItem('public_token')) const isOwner = computed(() => { + // dev mock 模式:mock 作品默认是当前用户作品 + if (isDev && work.value && isMockWorkId(work.value.id)) return true const u = localStorage.getItem('public_user') if (!u || !work.value) return false try { return JSON.parse(u).id === work.value.userId } catch { return false } @@ -121,10 +245,12 @@ const displayLikeCount = computed(() => work.value?.likeCount || 0) const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0) const statusTextMap: Record = { - draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架', -} -const statusColorMap: Record = { - draft: 'default', pending_review: 'orange', published: 'green', rejected: 'red', taken_down: 'default', + draft: '草稿', + unpublished: '未发布', + pending_review: '审核中', + published: '已发布', + rejected: '被拒绝', + taken_down: '已下架', } const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD') @@ -132,11 +258,11 @@ const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD') const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- } const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ } +// ─── 互动 ─── const handleLike = async () => { if (!isLoggedIn.value) { router.push('/p/login'); return } if (actionLoading.value) return actionLoading.value = true - // 乐观更新 const wasLiked = interaction.value.liked interaction.value.liked = !wasLiked if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1) @@ -145,7 +271,6 @@ const handleLike = async () => { interaction.value.liked = res.liked if (work.value) work.value.likeCount = res.likeCount } catch { - // 回滚 interaction.value.liked = wasLiked if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1) message.error('操作失败') @@ -174,8 +299,167 @@ const handleFavorite = async () => { } } +// ─── 作者操作 ─── + +const isMock = computed(() => isDev && work.value && isMockWorkId(work.value.id)) + +/** 公开发布:unpublished → pending_review */ +async function handlePublish() { + if (!work.value) return + actionLoading.value = true + try { + if (isMock.value) { + await new Promise(r => setTimeout(r, 300)) + } else { + await publicUserWorksApi.publish(workId) + } + work.value.status = 'pending_review' + message.success('已提交审核,等待超管确认') + } catch (e: any) { + message.error(e.message || '发布失败') + } finally { + actionLoading.value = false + } +} + +/** 修改后重交:rejected → 跳到编辑信息页 */ +function handleResubmit() { + // TODO: 真实场景需要 leai workId 跳到 EditInfoView,等后端 work.leaiWorkId 字段确认后接入 + message.info('编辑功能待后端联调,dev 模式暂无法跳转') +} + +/** 继续创作:draft → 跳回创作流程 */ +function handleContinue() { + router.push('/p/create') +} + +/** 撤回审核:pending_review → unpublished */ +function handleWithdraw() { + showConfirm( + '撤回审核', + '撤回后作品将回到「未发布」状态,可继续编辑或重新提交审核', + '确认撤回', + async () => { + if (!work.value) return + actionLoading.value = true + try { + if (isMock.value) { + await new Promise(r => setTimeout(r, 300)) + } else { + // TODO: 后端需要新增 POST /public/works/{id}/withdraw 接口 + message.warning('撤回接口待后端联调') + return + } + work.value.status = 'unpublished' + message.success('已撤回审核') + } catch (e: any) { + message.error(e.message || '撤回失败') + } finally { + actionLoading.value = false + } + }, + ) +} + +/** 下架作品:published → unpublished */ +function handleUnpublish() { + showConfirm( + '下架作品', + '下架后作品将从「发现」页移除,回到「未发布」状态。下架后仍可重新提交审核', + '确认下架', + async () => { + if (!work.value) return + actionLoading.value = true + try { + if (isMock.value) { + await new Promise(r => setTimeout(r, 300)) + } else { + // TODO: 后端需要新增 POST /public/works/{id}/unpublish 接口 + message.warning('下架接口待后端联调') + return + } + work.value.status = 'unpublished' + message.success('已下架到「未发布」') + } catch (e: any) { + message.error(e.message || '下架失败') + } finally { + actionLoading.value = false + } + }, + ) +} + +/** 编辑信息:跳到 EditInfoView */ +function handleEditInfo() { + // TODO: 真实场景需要 work.leaiWorkId 字段,等后端确认后接入 + message.info('编辑信息功能待后端联调') +} + +/** 删除作品 */ +function handleDelete() { + showConfirm( + '删除作品', + '删除后无法恢复,确认要删除这个作品吗?', + '确认删除', + async () => { + actionLoading.value = true + try { + if (isMock.value) { + await new Promise(r => setTimeout(r, 300)) + } else { + await publicUserWorksApi.delete(workId) + } + message.success('已删除') + router.push('/p/works') + } catch (e: any) { + message.error(e.message || '删除失败') + } finally { + actionLoading.value = false + } + }, + ) +} + +// ─── 二次确认弹窗 ─── +const confirmVisible = ref(false) +const confirmTitle = ref('') +const confirmContent = ref('') +const confirmOkText = ref('确认') +let confirmHandler: (() => Promise) | null = null + +function showConfirm(title: string, content: string, okText: string, handler: () => Promise) { + confirmTitle.value = title + confirmContent.value = content + confirmOkText.value = okText + confirmHandler = handler + confirmVisible.value = true +} +async function handleConfirmOk() { + if (confirmHandler) { + await confirmHandler() + confirmHandler = null + } + confirmVisible.value = false +} +function handleConfirmCancel() { + confirmVisible.value = false + confirmHandler = null +} + +// ─── 加载作品 ─── const fetchWork = async () => { loading.value = true + + // dev 兜底:mock id 直接用 mock 数据 + if (isDev && isMockWorkId(workId)) { + const mock = getMockWorkDetail(workId) + if (mock) { + work.value = mock + loading.value = false + return + } + } + try { // 优先尝试广场接口(公开作品),失败再尝试自己作品库 try { @@ -183,14 +467,23 @@ const fetchWork = async () => { } catch { work.value = await publicUserWorksApi.detail(workId) } - // 已登录时获取交互状态 if (isLoggedIn.value) { try { interaction.value = await publicInteractionApi.getInteraction(workId) } catch { /* 忽略 */ } } } catch { - message.error('获取作品详情失败') + // dev 兜底:真实接口失败时尝试 mock 数据 + if (isDev) { + const mock = getMockWorkDetail(workId) || getMockWorkDetail(101) + if (mock) { + work.value = mock + } else { + message.error('获取作品详情失败') + } + } else { + message.error('获取作品详情失败') + } } finally { loading.value = false } @@ -201,45 +494,126 @@ onMounted(fetchWork) diff --git a/frontend/src/views/public/works/Index.vue b/frontend/src/views/public/works/Index.vue index 3c89687..af699a7 100644 --- a/frontend/src/views/public/works/Index.vue +++ b/frontend/src/views/public/works/Index.vue @@ -22,7 +22,7 @@
- + 开始创作
@@ -65,13 +65,16 @@ - - diff --git a/frontend/src/views/public/works/_dev-mock.ts b/frontend/src/views/public/works/_dev-mock.ts new file mode 100644 index 0000000..3a8cf4e --- /dev/null +++ b/frontend/src/views/public/works/_dev-mock.ts @@ -0,0 +1,211 @@ +/** + * 用户作品库 dev 模式 mock 数据 + * + * 仅用于前端 UI 调试。生产环境通过 import.meta.env.DEV 判断不引用此文件。 + * + * 提供 5 条覆盖全部状态的 mock 作品 + 单条作品的完整详情(含 13 页内容)。 + * 详见 docs/design/public/ugc-work-status-redesign.md + */ +import type { UserWork, UserWorkPage } from '@/api/public' + +/** 3:4 SVG 渐变占位封面(作品库卡片用) */ +function mockCover(hue: number): string { + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ) +} + +/** 16:9 SVG 渐变占位插图(详情页内页用) */ +function mockPage(hue: number): string { + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + `` + + `` + + `` + + `` + + `` + + `` + + `` + ) +} + +const baseTime = '2026-04-09T10:00:00Z' +const mockCreator = { id: 1, nickname: '小创作者', avatar: null, username: 'demo' } + +/** 5 条 mock 作品列表,每种状态各一条 */ +export const MOCK_USER_WORKS: UserWork[] = [ + { + id: 101, + userId: 1, + title: '森林大冒险', + coverUrl: mockCover(280), + description: '小主角和小狐狸在魔法森林里寻找会发光的大树', + visibility: 'private', + status: 'unpublished', + reviewNote: null, + originalImageUrl: null, + voiceInputUrl: null, + textInput: null, + aiMeta: null, + viewCount: 0, + likeCount: 0, + favoriteCount: 0, + commentCount: 0, + shareCount: 0, + publishTime: null, + createTime: baseTime, + modifyTime: baseTime, + creator: mockCreator, + _count: { pages: 13, likes: 0, favorites: 0, comments: 0 }, + }, + { + id: 102, + userId: 1, + title: '正在创作中…', + coverUrl: mockCover(180), + description: null, + visibility: 'private', + status: 'draft', + reviewNote: null, + originalImageUrl: null, + voiceInputUrl: null, + textInput: null, + aiMeta: null, + viewCount: 0, + likeCount: 0, + favoriteCount: 0, + commentCount: 0, + shareCount: 0, + publishTime: null, + createTime: baseTime, + modifyTime: baseTime, + creator: mockCreator, + _count: { pages: 0, likes: 0, favorites: 0, comments: 0 }, + }, + { + id: 103, + userId: 1, + title: '海底奇遇', + coverUrl: mockCover(200), + description: '小章鱼和小海马的友谊故事', + visibility: 'public', + status: 'pending_review', + reviewNote: null, + originalImageUrl: null, + voiceInputUrl: null, + textInput: null, + aiMeta: null, + viewCount: 0, + likeCount: 0, + favoriteCount: 0, + commentCount: 0, + shareCount: 0, + publishTime: null, + createTime: baseTime, + modifyTime: baseTime, + creator: mockCreator, + _count: { pages: 12, likes: 0, favorites: 0, comments: 0 }, + }, + { + id: 104, + userId: 1, + title: '太空之旅', + coverUrl: mockCover(260), + description: '小宇航员驾驶飞船去探索遥远星球', + visibility: 'public', + status: 'published', + reviewNote: null, + originalImageUrl: null, + voiceInputUrl: null, + textInput: null, + aiMeta: null, + viewCount: 128, + likeCount: 23, + favoriteCount: 8, + commentCount: 5, + shareCount: 2, + publishTime: '2026-04-08T15:30:00Z', + createTime: '2026-04-08T14:00:00Z', + modifyTime: '2026-04-08T15:30:00Z', + creator: mockCreator, + _count: { pages: 14, likes: 23, favorites: 8, comments: 5 }, + }, + { + id: 105, + userId: 1, + title: '魔法学校', + coverUrl: mockCover(330), + description: '小巫师入学魔法学校的第一天', + visibility: 'public', + status: 'rejected', + reviewNote: '内容包含疑似版权角色,请修改后重新提交', + originalImageUrl: null, + voiceInputUrl: null, + textInput: null, + aiMeta: null, + viewCount: 0, + likeCount: 0, + favoriteCount: 0, + commentCount: 0, + shareCount: 0, + publishTime: null, + createTime: baseTime, + modifyTime: baseTime, + creator: mockCreator, + _count: { pages: 11, likes: 0, favorites: 0, comments: 0 }, + }, +] + +/** 13 页绘本内容(用于详情页阅读器) */ +const mockPageTexts = [ + '一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。', + '它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。', + '小主角轻轻抱起小鸟,决定送它回家。', + '路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。', + '小狐狸说它认识森林里所有的小路,愿意做大家的向导。', + '三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。', + '小鱼们告诉他们,那棵会发光的大树就在前方不远处。', + '森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。', + '原来这就是小鸟的家,妈妈正在树枝上焦急地张望。', + '小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。', + '夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。', + '小主角带着这份美好回到家,心里也开出了一朵花。', +] + +/** 生成 mock 详情页(含 13 页绘本内容) */ +function generateMockPages(workId: number): UserWorkPage[] { + return mockPageTexts.map((text, i) => ({ + id: workId * 100 + i + 1, + workId, + pageNo: i + 1, + imageUrl: mockPage((280 + i * 27) % 360), + text, + audioUrl: null as any, + })) as UserWorkPage[] +} + +/** 根据 id 获取 mock 作品的完整详情(含 pages) */ +export function getMockWorkDetail(id: number): UserWork | null { + const base = MOCK_USER_WORKS.find(w => w.id === id) + if (!base) return null + + return { + ...base, + pages: base.status === 'draft' ? [] : generateMockPages(id), + tags: [ + { tag: { id: 1, name: '冒险', category: 'theme' } }, + { tag: { id: 2, name: '友谊', category: 'theme' } }, + { tag: { id: 3, name: '成长', category: 'theme' } }, + ], + } +} + +/** 判断一个 id 是否是 mock 作品 id(100-200 之间) */ +export function isMockWorkId(id: number): boolean { + return id >= 100 && id < 200 +}