feat: 引入未发布作品状态与状态化操作面板(前端 UI 第一阶段)
- 新增 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) <noreply@anthropic.com>
This commit is contained in:
parent
951346a7a8
commit
cd8de97f79
417
docs/design/public/ugc-work-status-redesign.md
Normal file
417
docs/design/public/ugc-work-status-redesign.md
Normal file
@ -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 三态命名风格一致
|
||||
- 本地化为"未发布"也是中文用户最直观的理解
|
||||
@ -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
|
||||
|
||||
@ -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: "发布作品" },
|
||||
},
|
||||
],
|
||||
},
|
||||
// ========== 管理端路由 ==========
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="edit-page page-fullscreen">
|
||||
<PageHeader title="编辑绘本信息" subtitle="完善信息后选择保存、发布或继续配音" :showBack="true" />
|
||||
<PageHeader title="编辑绘本信息" subtitle="完善信息,作品将进入「未发布」可随时发布" :showBack="true" />
|
||||
|
||||
<div v-if="loading" class="loading-state">
|
||||
<loading-outlined class="loading-icon" spin />
|
||||
@ -120,9 +120,9 @@
|
||||
<!-- 底部三按钮 -->
|
||||
<div v-if="!loading" class="page-bottom">
|
||||
<div class="action-row">
|
||||
<button class="action-btn draft-btn" :disabled="processing" @click="handleSaveDraft">
|
||||
<button class="action-btn draft-btn" :disabled="processing" @click="handleSave">
|
||||
<inbox-outlined />
|
||||
<span>保存草稿</span>
|
||||
<span>保存</span>
|
||||
</button>
|
||||
<button class="action-btn dubbing-btn" :disabled="processing" @click="handleGoDubbing">
|
||||
<audio-outlined />
|
||||
@ -130,11 +130,11 @@
|
||||
</button>
|
||||
<button class="action-btn publish-btn" :disabled="processing" @click="handlePublish">
|
||||
<send-outlined />
|
||||
<span>发布作品</span>
|
||||
<span>直接发布</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="action-hint">
|
||||
草稿可在作品库继续编辑 · 配音是可选步骤 · 发布后进入审核
|
||||
保存后进入「未发布」可随时发布 · 配音是可选步骤 · 发布后进入审核
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -305,14 +305,14 @@ async function saveFormToServer() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存草稿 → 跳作品库草稿 tab */
|
||||
async function handleSaveDraft() {
|
||||
/** 保存(编目完成 → unpublished)→ 跳作品库未发布 tab */
|
||||
async function handleSave() {
|
||||
if (!validate()) return
|
||||
processing.value = true
|
||||
try {
|
||||
if (await saveFormToServer()) {
|
||||
store.workDetail = null
|
||||
router.push('/p/works?tab=draft')
|
||||
router.push('/p/works?tab=unpublished')
|
||||
}
|
||||
} finally {
|
||||
processing.value = false
|
||||
|
||||
@ -2,17 +2,39 @@
|
||||
<div class="work-detail-page">
|
||||
<a-spin :spinning="loading">
|
||||
<template v-if="work">
|
||||
<!-- 顶部信息 -->
|
||||
<!-- 顶部 -->
|
||||
<div class="detail-header">
|
||||
<a-button type="text" @click="$router.back()">
|
||||
<arrow-left-outlined /> 返回
|
||||
</a-button>
|
||||
<button class="back-btn" @click="$router.back()">
|
||||
<left-outlined />
|
||||
</button>
|
||||
<h1>{{ work.title }}</h1>
|
||||
<div class="header-actions" v-if="isOwner">
|
||||
<a-button v-if="work.status === 'draft' || work.status === 'rejected'" type="primary" shape="round" size="small" @click="$router.push(`/p/works/${work.id}/publish`)">
|
||||
发布作品
|
||||
</a-button>
|
||||
<a-tag v-else :color="statusColorMap[work.status]">{{ statusTextMap[work.status] }}</a-tag>
|
||||
<span :class="['status-tag', work.status]">{{ statusTextMap[work.status] }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 拒绝原因(仅作者 + rejected)-->
|
||||
<div v-if="isOwner && work.status === 'rejected' && work.reviewNote" class="reject-card">
|
||||
<warning-filled class="reject-icon" />
|
||||
<div class="reject-body">
|
||||
<div class="reject-title">未通过审核</div>
|
||||
<div class="reject-content">{{ work.reviewNote }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 草稿提示(仅作者 + draft)-->
|
||||
<div v-else-if="isOwner && work.status === 'draft'" class="info-card draft-card">
|
||||
<info-circle-outlined class="info-icon" />
|
||||
<div class="info-body">
|
||||
<div class="info-title">这是一个未完成的草稿</div>
|
||||
<div class="info-desc">继续完成创作后才能公开发布</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 未发布提示(仅作者 + unpublished)-->
|
||||
<div v-else-if="isOwner && work.status === 'unpublished'" class="info-card unpublished-card">
|
||||
<info-circle-outlined class="info-icon" />
|
||||
<div class="info-body">
|
||||
<div class="info-title">作品仅你自己可见</div>
|
||||
<div class="info-desc">点击下方「公开发布」提交审核,通过后将在「发现」页展示</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,22 +42,24 @@
|
||||
<div class="book-reader" v-if="work.pages && work.pages.length > 0">
|
||||
<div class="page-display">
|
||||
<img v-if="currentPageData?.imageUrl" :src="currentPageData.imageUrl" :alt="`第${currentPageIndex + 1}页`" class="page-image" />
|
||||
<div v-else class="page-placeholder">暂无插图</div>
|
||||
<div v-else class="page-placeholder">
|
||||
<picture-outlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-text" v-if="currentPageData?.text">
|
||||
<p>{{ currentPageData.text }}</p>
|
||||
{{ currentPageData.text }}
|
||||
</div>
|
||||
<div class="page-audio" v-if="currentPageData?.audioUrl">
|
||||
<audio :src="currentPageData.audioUrl" controls class="audio-player"></audio>
|
||||
</div>
|
||||
<div class="page-nav">
|
||||
<a-button :disabled="currentPageIndex === 0" @click="prevPage" shape="round">
|
||||
<left-outlined /> 上一页
|
||||
</a-button>
|
||||
<button class="nav-btn" :disabled="currentPageIndex === 0" @click="prevPage">
|
||||
<left-outlined />
|
||||
</button>
|
||||
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ work.pages.length }}</span>
|
||||
<a-button :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage" shape="round">
|
||||
下一页 <right-outlined />
|
||||
</a-button>
|
||||
<button class="nav-btn" :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage">
|
||||
<right-outlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -52,24 +76,18 @@
|
||||
</div>
|
||||
<div v-if="work.description" class="description">{{ work.description }}</div>
|
||||
<div v-if="work.tags?.length" class="tags-row">
|
||||
<a-tag v-for="t in work.tags" :key="t.tag.id" color="purple">{{ t.tag.name }}</a-tag>
|
||||
<span v-for="t in work.tags" :key="t.tag.id" class="info-tag">{{ t.tag.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 互动栏 -->
|
||||
<div class="interaction-bar">
|
||||
<div
|
||||
:class="['action-btn', { active: interaction.liked }]"
|
||||
@click="handleLike"
|
||||
>
|
||||
<!-- 互动栏:仅在已发布作品上显示 -->
|
||||
<div v-if="work.status === 'published'" class="interaction-bar">
|
||||
<div :class="['action-btn', { active: interaction.liked }]" @click="handleLike">
|
||||
<heart-filled v-if="interaction.liked" />
|
||||
<heart-outlined v-else />
|
||||
<span>{{ displayLikeCount }}</span>
|
||||
</div>
|
||||
<div
|
||||
:class="['action-btn', { active: interaction.favorited }]"
|
||||
@click="handleFavorite"
|
||||
>
|
||||
<div :class="['action-btn', { active: interaction.favorited }]" @click="handleFavorite">
|
||||
<star-filled v-if="interaction.favorited" />
|
||||
<star-outlined v-else />
|
||||
<span>{{ displayFavoriteCount }}</span>
|
||||
@ -79,10 +97,96 @@
|
||||
<span>{{ work.viewCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作者私有操作 -->
|
||||
<div v-if="isOwner" class="owner-actions">
|
||||
<!-- 主操作:根据 status 切换 -->
|
||||
<button
|
||||
v-if="work.status === 'unpublished'"
|
||||
class="op-btn primary"
|
||||
:disabled="actionLoading"
|
||||
@click="handlePublish"
|
||||
>
|
||||
<send-outlined />
|
||||
<span>公开发布</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="work.status === 'rejected'"
|
||||
class="op-btn primary"
|
||||
:disabled="actionLoading"
|
||||
@click="handleResubmit"
|
||||
>
|
||||
<send-outlined />
|
||||
<span>修改后重交</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="work.status === 'draft'"
|
||||
class="op-btn primary"
|
||||
@click="handleContinue"
|
||||
>
|
||||
<edit-outlined />
|
||||
<span>继续创作</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="work.status === 'pending_review'"
|
||||
class="op-btn outline"
|
||||
:disabled="actionLoading"
|
||||
@click="handleWithdraw"
|
||||
>
|
||||
<undo-outlined />
|
||||
<span>撤回审核</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-else-if="work.status === 'published'"
|
||||
class="op-btn outline"
|
||||
:disabled="actionLoading"
|
||||
@click="handleUnpublish"
|
||||
>
|
||||
<inbox-outlined />
|
||||
<span>下架</span>
|
||||
</button>
|
||||
|
||||
<!-- 编辑信息(unpublished 状态)-->
|
||||
<button
|
||||
v-if="work.status === 'unpublished'"
|
||||
class="op-btn outline-soft"
|
||||
@click="handleEditInfo"
|
||||
>
|
||||
<edit-outlined />
|
||||
<span>编辑信息</span>
|
||||
</button>
|
||||
|
||||
<!-- 删除(所有状态)-->
|
||||
<button
|
||||
class="op-btn ghost-danger"
|
||||
:disabled="actionLoading"
|
||||
@click="handleDelete"
|
||||
>
|
||||
<delete-outlined />
|
||||
<span>删除</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<a-empty v-else-if="!loading" description="作品不存在" style="padding: 80px 0" />
|
||||
</a-spin>
|
||||
|
||||
<!-- 二次确认弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="confirmVisible"
|
||||
:title="confirmTitle"
|
||||
:ok-text="confirmOkText"
|
||||
cancel-text="取消"
|
||||
:confirm-loading="actionLoading"
|
||||
@ok="handleConfirmOk"
|
||||
@cancel="handleConfirmCancel"
|
||||
>
|
||||
<p>{{ confirmContent }}</p>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -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<UserWork | null>(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<string, string> = {
|
||||
draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架',
|
||||
}
|
||||
const statusColorMap: Record<string, string> = {
|
||||
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<void>) | null = null
|
||||
|
||||
function showConfirm(title: string, content: string, okText: string, handler: () => Promise<void>) {
|
||||
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)
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
$accent: #ec4899;
|
||||
|
||||
.work-detail-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
/* ---------- 顶部 ---------- */
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
h1 { font-size: 18px; font-weight: 700; color: #1e1b4b; margin: 0; flex: 1; }
|
||||
h1 {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1e1b4b;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: rgba($primary, 0.08);
|
||||
color: $primary;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.anticon) { font-size: 15px; }
|
||||
|
||||
&:hover { background: rgba($primary, 0.14); }
|
||||
}
|
||||
.status-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
letter-spacing: 0.3px;
|
||||
|
||||
&.draft { background: rgba(107, 114, 128, 0.85); }
|
||||
&.unpublished { background: rgba(99, 102, 241, 0.9); }
|
||||
&.pending_review { background: rgba(245, 158, 11, 0.92); }
|
||||
&.published { background: rgba(16, 185, 129, 0.92); }
|
||||
&.rejected { background: rgba(239, 68, 68, 0.92); }
|
||||
&.taken_down { background: rgba(107, 114, 128, 0.85); }
|
||||
}
|
||||
|
||||
/* ---------- 拒绝原因 / 信息提示卡片 ---------- */
|
||||
.reject-card,
|
||||
.info-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.reject-card {
|
||||
background: rgba(239, 68, 68, 0.06);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
.reject-icon {
|
||||
font-size: 18px;
|
||||
color: #ef4444;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.reject-body { flex: 1; }
|
||||
.reject-title { font-size: 13px; font-weight: 700; color: #b91c1c; margin-bottom: 4px; }
|
||||
.reject-content { font-size: 13px; color: #4b5563; line-height: 1.6; }
|
||||
|
||||
.info-card {
|
||||
background: linear-gradient(135deg, rgba($primary, 0.08), rgba($accent, 0.05));
|
||||
border: 1px solid rgba($primary, 0.15);
|
||||
}
|
||||
.info-icon {
|
||||
font-size: 18px;
|
||||
color: $primary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.info-body { flex: 1; }
|
||||
.info-title { font-size: 13px; font-weight: 700; color: #1e1b4b; margin-bottom: 3px; }
|
||||
.info-desc { font-size: 12px; color: #6b7280; line-height: 1.6; }
|
||||
|
||||
/* ---------- 绘本阅读器 ---------- */
|
||||
.book-reader {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 14px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||
|
||||
// 不固定宽高比:横图/竖图均以「长边」受 max 约束,避免 3:4 框导致横图上下大片留白
|
||||
.page-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 160px;
|
||||
padding: 8px 12px;
|
||||
box-sizing: border-box;
|
||||
background: #f8f7fc;
|
||||
min-height: 200px;
|
||||
background: #1e1b4b;
|
||||
|
||||
.page-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
// 长边优先:横图限宽、竖图限高,另一维按比例收缩
|
||||
max-height: min(72vh, 85vw);
|
||||
object-fit: contain;
|
||||
}
|
||||
@ -250,14 +624,17 @@ $primary: #6366f1;
|
||||
justify-content: center;
|
||||
min-height: 200px;
|
||||
width: 100%;
|
||||
color: #d1d5db;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
font-size: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.page-text {
|
||||
padding: 16px 20px;
|
||||
p { font-size: 14px; line-height: 1.8; color: #374151; margin: 0; }
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #374151;
|
||||
border-top: 1px solid rgba($primary, 0.06);
|
||||
}
|
||||
|
||||
.page-audio {
|
||||
@ -270,17 +647,51 @@ $primary: #6366f1;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
border-top: 1px solid rgba($primary, 0.06);
|
||||
|
||||
.page-indicator { font-size: 13px; color: #6b7280; font-weight: 600; }
|
||||
.nav-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
background: #fff;
|
||||
color: $primary;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
|
||||
:deep(.anticon) { font-size: 14px; }
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
border-color: $primary;
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 作品信息 ---------- */
|
||||
.info-section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 18px 20px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||
margin-bottom: 14px;
|
||||
|
||||
.author-row {
|
||||
display: flex;
|
||||
@ -293,36 +704,55 @@ $primary: #6366f1;
|
||||
.create-time { font-size: 11px; color: #9ca3af; }
|
||||
}
|
||||
|
||||
.description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; }
|
||||
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.description {
|
||||
font-size: 13px;
|
||||
color: #4b5563;
|
||||
line-height: 1.7;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.tags-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info-tag {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba($primary, 0.08);
|
||||
color: $primary;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba($primary, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 互动栏 ==========
|
||||
/* ---------- 互动栏 ---------- */
|
||||
.interaction-bar {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 14px;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px 0;
|
||||
padding: 12px 0;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 20px;
|
||||
border-radius: 24px;
|
||||
padding: 8px 18px;
|
||||
border-radius: 22px;
|
||||
font-size: 18px;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
user-select: none;
|
||||
|
||||
span {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
span { font-size: 13px; font-weight: 500; }
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.04);
|
||||
@ -330,14 +760,10 @@ $primary: #6366f1;
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #ec4899;
|
||||
|
||||
&:hover {
|
||||
background: rgba(236, 72, 153, 0.06);
|
||||
}
|
||||
color: $accent;
|
||||
&:hover { background: rgba($accent, 0.06); }
|
||||
}
|
||||
|
||||
// 点赞动效
|
||||
&.active :deep(.anticon) {
|
||||
animation: pop 0.3s ease;
|
||||
}
|
||||
@ -349,4 +775,81 @@ $primary: #6366f1;
|
||||
50% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ---------- 作者私有操作区 ---------- */
|
||||
.owner-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||
}
|
||||
.op-btn {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 11px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.anticon) { font-size: 13px; }
|
||||
|
||||
&:active { transform: scale(0.97); }
|
||||
&:disabled { opacity: 0.4; pointer-events: none; }
|
||||
}
|
||||
|
||||
.op-btn.primary {
|
||||
background: linear-gradient(135deg, $primary 0%, $accent 100%);
|
||||
color: #fff;
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba($primary, 0.32);
|
||||
|
||||
&:hover { transform: translateY(-1px); box-shadow: 0 6px 18px rgba($primary, 0.4); }
|
||||
}
|
||||
|
||||
.op-btn.outline {
|
||||
background: #fff;
|
||||
color: $primary;
|
||||
border: 1.5px solid rgba($primary, 0.4);
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
background: rgba($primary, 0.04);
|
||||
}
|
||||
}
|
||||
|
||||
.op-btn.outline-soft {
|
||||
background: rgba($primary, 0.04);
|
||||
color: $primary;
|
||||
border: 1px solid rgba($primary, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: rgba($primary, 0.08);
|
||||
border-color: rgba($primary, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.op-btn.ghost-danger {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
padding: 11px 16px;
|
||||
background: transparent;
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
|
||||
&:hover {
|
||||
background: rgba(239, 68, 68, 0.04);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||
|
||||
<div v-else-if="works.length === 0" class="empty-wrap">
|
||||
<a-empty :description="activeTab === 'draft' ? '还没有草稿' : activeTab === 'published' ? '还没有发布的作品' : '还没有作品'">
|
||||
<a-empty :description="emptyDescription">
|
||||
<a-button type="primary" shape="round" @click="$router.push('/p/create')">开始创作</a-button>
|
||||
</a-empty>
|
||||
</div>
|
||||
@ -65,13 +65,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { PlusOutlined, PictureOutlined } from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
||||
import { MOCK_USER_WORKS } from './_dev-mock'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@ -82,7 +85,7 @@ const pageSize = 12
|
||||
const total = ref(0)
|
||||
|
||||
// 合法的 tab key,防止 query 注入非法值
|
||||
const VALID_TABS = ['', 'draft', 'pending_review', 'published', 'rejected']
|
||||
const VALID_TABS = ['', 'draft', 'unpublished', 'pending_review', 'published', 'rejected']
|
||||
const initialTab = typeof route.query.tab === 'string' && VALID_TABS.includes(route.query.tab)
|
||||
? route.query.tab
|
||||
: ''
|
||||
@ -91,6 +94,7 @@ const activeTab = ref(initialTab)
|
||||
const tabs = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'draft', label: '草稿' },
|
||||
{ key: 'unpublished', label: '未发布' },
|
||||
{ key: 'pending_review', label: '审核中' },
|
||||
{ key: 'published', label: '已发布' },
|
||||
{ key: 'rejected', label: '被拒绝' },
|
||||
@ -98,6 +102,7 @@ const tabs = [
|
||||
|
||||
const statusTextMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
unpublished: '未发布',
|
||||
pending_review: '审核中',
|
||||
published: '已发布',
|
||||
rejected: '被拒绝',
|
||||
@ -106,6 +111,17 @@ const statusTextMap: Record<string, string> = {
|
||||
|
||||
const formatDate = (d: string) => dayjs(d).format('MM-DD HH:mm')
|
||||
|
||||
const emptyDescription = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'draft': return '还没有草稿,开始你的第一本绘本'
|
||||
case 'unpublished': return '还没有未发布的作品'
|
||||
case 'pending_review': return '没有正在审核的作品'
|
||||
case 'published': return '还没有发布的作品'
|
||||
case 'rejected': return '没有被拒绝的作品'
|
||||
default: return '还没有作品,开始你的第一本绘本'
|
||||
}
|
||||
})
|
||||
|
||||
const fetchWorks = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -117,7 +133,16 @@ const fetchWorks = async () => {
|
||||
works.value = res.list
|
||||
total.value = res.total
|
||||
} catch {
|
||||
message.error('获取作品列表失败')
|
||||
// dev 兜底:真实接口失败时显示 mock 数据,方便 UI 调试
|
||||
if (isDev) {
|
||||
const filtered = activeTab.value
|
||||
? MOCK_USER_WORKS.filter(w => w.status === activeTab.value)
|
||||
: MOCK_USER_WORKS
|
||||
works.value = filtered
|
||||
total.value = filtered.length
|
||||
} else {
|
||||
message.error('获取作品列表失败')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -236,11 +261,12 @@ $primary: #6366f1;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
||||
&.draft { background: rgba(107,114,128,0.8); color: #fff; }
|
||||
&.pending_review { background: rgba(245,158,11,0.9); color: #fff; }
|
||||
&.published { background: rgba(16,185,129,0.9); color: #fff; }
|
||||
&.rejected { background: rgba(239,68,68,0.9); color: #fff; }
|
||||
&.taken_down { background: rgba(107,114,128,0.8); color: #fff; }
|
||||
&.draft { background: rgba(107,114,128,0.85); color: #fff; }
|
||||
&.unpublished { background: rgba(99,102,241,0.9); color: #fff; }
|
||||
&.pending_review { background: rgba(245,158,11,0.92); color: #fff; }
|
||||
&.published { background: rgba(16,185,129,0.92); color: #fff; }
|
||||
&.rejected { background: rgba(239,68,68,0.92); color: #fff; }
|
||||
&.taken_down { background: rgba(107,114,128,0.85); color: #fff; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,239 +0,0 @@
|
||||
<template>
|
||||
<div class="publish-page">
|
||||
<div class="page-header">
|
||||
<a-button type="text" @click="$router.back()">
|
||||
<arrow-left-outlined /> 返回
|
||||
</a-button>
|
||||
<h2>发布作品</h2>
|
||||
</div>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div v-if="work" class="publish-form-wrap">
|
||||
<!-- 作品预览 -->
|
||||
<div class="preview-card">
|
||||
<img v-if="work.coverUrl" :src="work.coverUrl" class="preview-cover" />
|
||||
<div class="preview-info">
|
||||
<h3>{{ form.title }}</h3>
|
||||
<span>{{ work._count?.pages || 0 }} 页绘本</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-form layout="vertical" @finish="handlePublish" class="publish-form">
|
||||
<a-form-item label="作品标题" :rules="[{ required: true, message: '请输入标题' }]">
|
||||
<a-input v-model:value="form.title" placeholder="给作品取个名字" :maxlength="50" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="作品简介">
|
||||
<a-textarea v-model:value="form.description" placeholder="简单介绍一下你的绘本故事" :rows="3" :maxlength="200" show-count />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="选择标签">
|
||||
<div class="tag-selector">
|
||||
<span
|
||||
v-for="tag in availableTags"
|
||||
:key="tag.id"
|
||||
:class="['tag-option', { selected: selectedTagIds.includes(tag.id) }]"
|
||||
@click="toggleTag(tag.id)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="可见性">
|
||||
<a-radio-group v-model:value="form.visibility">
|
||||
<a-radio value="public">公开(所有人可见)</a-radio>
|
||||
<a-radio value="private">仅自己可见</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-button
|
||||
type="primary"
|
||||
html-type="submit"
|
||||
block
|
||||
size="large"
|
||||
:loading="publishing"
|
||||
class="publish-btn"
|
||||
>
|
||||
{{ form.visibility === 'public' ? '提交发布(需审核)' : '保存设置' }}
|
||||
</a-button>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ArrowLeftOutlined } from '@ant-design/icons-vue'
|
||||
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
||||
import publicApi from '@/api/public'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const workId = Number(route.params.id)
|
||||
|
||||
const work = ref<UserWork | null>(null)
|
||||
const loading = ref(true)
|
||||
const publishing = ref(false)
|
||||
const availableTags = ref<Array<{ id: number; name: string; category: string }>>([])
|
||||
const selectedTagIds = ref<number[]>([])
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
visibility: 'public' as string,
|
||||
})
|
||||
|
||||
const toggleTag = (tagId: number) => {
|
||||
const idx = selectedTagIds.value.indexOf(tagId)
|
||||
if (idx >= 0) {
|
||||
selectedTagIds.value.splice(idx, 1)
|
||||
} else {
|
||||
if (selectedTagIds.value.length < 5) {
|
||||
selectedTagIds.value.push(tagId)
|
||||
} else {
|
||||
message.warning('最多选择 5 个标签')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchWork = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
work.value = await publicUserWorksApi.detail(workId)
|
||||
form.title = work.value.title
|
||||
form.description = work.value.description || ''
|
||||
form.visibility = work.value.visibility || 'public'
|
||||
selectedTagIds.value = work.value.tags?.map((t) => t.tag.id) || []
|
||||
} catch {
|
||||
message.error('获取作品详情失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const res = await publicApi.get('/public/tags')
|
||||
availableTags.value = res as any
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
publishing.value = true
|
||||
try {
|
||||
// 先更新作品信息
|
||||
await publicUserWorksApi.update(workId, {
|
||||
title: form.title,
|
||||
description: form.description,
|
||||
visibility: form.visibility,
|
||||
tagIds: selectedTagIds.value,
|
||||
})
|
||||
|
||||
// 如果公开可见,提交发布(进入审核队列)
|
||||
if (form.visibility === 'public') {
|
||||
await publicUserWorksApi.publish(workId)
|
||||
message.success('已提交发布,等待审核')
|
||||
} else {
|
||||
message.success('设置已保存')
|
||||
}
|
||||
|
||||
router.push(`/p/works/${workId}`)
|
||||
} catch (err: any) {
|
||||
message.error(err?.response?.data?.message || '操作失败')
|
||||
} finally {
|
||||
publishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWork()
|
||||
fetchTags()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
$primary: #6366f1;
|
||||
|
||||
.publish-page {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
h2 { font-size: 18px; font-weight: 700; color: #1e1b4b; margin: 0; }
|
||||
}
|
||||
|
||||
.preview-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
|
||||
.preview-cover {
|
||||
width: 64px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.preview-info {
|
||||
h3 { font-size: 15px; font-weight: 600; color: #1e1b4b; margin: 0 0 4px; }
|
||||
span { font-size: 12px; color: #9ca3af; }
|
||||
}
|
||||
}
|
||||
|
||||
.publish-form-wrap {
|
||||
.publish-form {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid rgba($primary, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.tag-selector {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.tag-option {
|
||||
padding: 4px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.selected {
|
||||
background: $primary;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&:hover:not(.selected) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.publish-btn {
|
||||
height: 48px !important;
|
||||
border-radius: 14px !important;
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/views/public/works/_dev-mock.ts
Normal file
211
frontend/src/views/public/works/_dev-mock.ts
Normal file
@ -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(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400" viewBox="0 0 300 400">` +
|
||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0" stop-color="hsl(${hue},75%,72%)"/>` +
|
||||
`<stop offset="1" stop-color="hsl(${(hue + 35) % 360},80%,58%)"/>` +
|
||||
`</linearGradient></defs>` +
|
||||
`<rect width="300" height="400" fill="url(#g)"/>` +
|
||||
`</svg>`
|
||||
)
|
||||
}
|
||||
|
||||
/** 16:9 SVG 渐变占位插图(详情页内页用) */
|
||||
function mockPage(hue: number): string {
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450" viewBox="0 0 800 450">` +
|
||||
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||
`<stop offset="0" stop-color="hsl(${hue},70%,75%)"/>` +
|
||||
`<stop offset="1" stop-color="hsl(${(hue + 45) % 360},75%,55%)"/>` +
|
||||
`</linearGradient></defs>` +
|
||||
`<rect width="800" height="450" fill="url(#g)"/>` +
|
||||
`</svg>`
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user