merge: 合并 origin/feat/zhz —— AI创作UI重做 + originalImageUrl兜底
合并内容: - 对方:AI创作全流程 UI 紫粉主题重做(Ant Design 图标、步骤优化、触屏交互) - 对方:stores/aicreate 新增 fillMockData/fillMockWorkDetail 开发辅助 - 对方:api/public 新增 WorkStatus 类型定义 - 对方:LeaiSyncService originalImageUrl 兜底逻辑 - 对方:Flyway V14-V16 迁移(从对方 V13 重编号避免冲突) 冲突解决: - BookReaderView/PreviewView:保留 res(拦截器已解包),移除 appSecret 判断 - WelcomeView:保留浮动 CTA 设计,移除 goToEnterprise/isTokenMode(依赖已删除字段) - WelcomeView:移除未使用的 KeyOutlined import - Flyway V13:对方文件重编号为 V14-V16 验证:前端 vite build 通过,无冲突标记无残留 res.data Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
15428116b5
@ -135,6 +135,13 @@ public class LeaiSyncService implements ILeaiSyncService {
|
|||||||
if (coverUrl == null) coverUrl = remoteData.get("cover_url");
|
if (coverUrl == null) coverUrl = remoteData.get("cover_url");
|
||||||
if (coverUrl != null) work.setCoverUrl(coverUrl.toString());
|
if (coverUrl != null) work.setCoverUrl(coverUrl.toString());
|
||||||
|
|
||||||
|
// 兜底:如果 originalImageUrl 没传但 coverUrl 传了,把 coverUrl 也写入 originalImageUrl
|
||||||
|
// 当前 leai webhook 大多只传 coverUrl(实际就是用户上传的原图),不传独立的 originalImageUrl 字段
|
||||||
|
// 这里兜底让前端 PIP / 详情页「画作原图」卡片能正确展示
|
||||||
|
if (originalImageUrl == null && coverUrl != null) {
|
||||||
|
work.setOriginalImageUrl(coverUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
// 通过手机号查找用户ID(多租户场景)
|
// 通过手机号查找用户ID(多租户场景)
|
||||||
if (phone != null && work.getUserId() == null) {
|
if (phone != null && work.getUserId() == null) {
|
||||||
Long userId = findUserIdByPhone(phone);
|
Long userId = findUserIdByPhone(phone);
|
||||||
@ -195,6 +202,10 @@ public class LeaiSyncService implements ILeaiSyncService {
|
|||||||
if (coverUrl != null) {
|
if (coverUrl != null) {
|
||||||
wrapper.set(UgcWork::getCoverUrl, coverUrl.toString());
|
wrapper.set(UgcWork::getCoverUrl, coverUrl.toString());
|
||||||
}
|
}
|
||||||
|
// 兜底:originalImageUrl 没传但 coverUrl 传了,把 coverUrl 也写入 originalImageUrl
|
||||||
|
if (originalImageUrl == null && coverUrl != null) {
|
||||||
|
wrapper.set(UgcWork::getOriginalImageUrl, coverUrl.toString());
|
||||||
|
}
|
||||||
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
|
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
|
||||||
|
|
||||||
ugcWorkMapper.update(null, wrapper);
|
ugcWorkMapper.update(null, wrapper);
|
||||||
@ -243,6 +254,10 @@ public class LeaiSyncService implements ILeaiSyncService {
|
|||||||
if (coverUrl != null) {
|
if (coverUrl != null) {
|
||||||
wrapper.set(UgcWork::getCoverUrl, coverUrl.toString());
|
wrapper.set(UgcWork::getCoverUrl, coverUrl.toString());
|
||||||
}
|
}
|
||||||
|
// 兜底:originalImageUrl 没传但 coverUrl 传了,把 coverUrl 也写入 originalImageUrl
|
||||||
|
if (originalImageUrl == null && coverUrl != null) {
|
||||||
|
wrapper.set(UgcWork::getOriginalImageUrl, coverUrl.toString());
|
||||||
|
}
|
||||||
|
|
||||||
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
|
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,29 @@
|
|||||||
|
-- ========================================================
|
||||||
|
-- V13: 回填 t_ugc_work.original_image_url 字段
|
||||||
|
-- ========================================================
|
||||||
|
-- 背景:
|
||||||
|
-- leai webhook 同步作品时,大多数情况下只传了 cover_url,
|
||||||
|
-- 没有单独传 originalImageUrl 字段,导致 LeaiSyncService
|
||||||
|
-- 把 cover_url 写入了 cover 字段,但 original_image_url 字段为 null。
|
||||||
|
--
|
||||||
|
-- 实际情况:
|
||||||
|
-- 当前所有作品的 cover_url 实际上就是用户上传的原图,
|
||||||
|
-- AI 生成的独立绘本封面字段尚未在 leai webhook 中拆分提供。
|
||||||
|
--
|
||||||
|
-- 影响:
|
||||||
|
-- 前端作品库 / 发现页 PIP 画中画功能、详情页「画作原图」卡片
|
||||||
|
-- 需要 original_image_url 字段才能展示,当前为 null 时这些 UI
|
||||||
|
-- 全部不渲染,用户看不到原图。
|
||||||
|
--
|
||||||
|
-- 修复:
|
||||||
|
-- 把 original_image_url 为 null 但 cover_url 有值的数据,
|
||||||
|
-- 统一回填为 cover_url。
|
||||||
|
-- 同时 LeaiSyncService 已加 fallback,新数据自动写入。
|
||||||
|
--
|
||||||
|
-- 详见 docs/design/public/ugc-work-status-redesign.md
|
||||||
|
-- ========================================================
|
||||||
|
|
||||||
|
UPDATE t_ugc_work
|
||||||
|
SET original_image_url = cover_url
|
||||||
|
WHERE original_image_url IS NULL
|
||||||
|
AND cover_url IS NOT NULL;
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
-- ========================================================
|
||||||
|
-- V14: 强制回填 t_ugc_work.original_image_url
|
||||||
|
-- ========================================================
|
||||||
|
-- 背景:
|
||||||
|
-- V13 因 Flyway schema_history 表残留同版本号脏数据被 repair 跳过,
|
||||||
|
-- 实际 SQL 没真正执行。新建 V14 强制重跑同样的 UPDATE 逻辑。
|
||||||
|
--
|
||||||
|
-- 详见 docs/design/public/ugc-work-status-redesign.md
|
||||||
|
-- 详见 V13__backfill_original_image_url.sql
|
||||||
|
-- ========================================================
|
||||||
|
|
||||||
|
UPDATE t_ugc_work
|
||||||
|
SET original_image_url = cover_url
|
||||||
|
WHERE original_image_url IS NULL
|
||||||
|
AND cover_url IS NOT NULL;
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
-- ========================================================
|
||||||
|
-- V15: 回填 t_ugc_work.original_image_url 空字符串数据
|
||||||
|
-- ========================================================
|
||||||
|
-- 背景:
|
||||||
|
-- V14 用 `WHERE original_image_url IS NULL` 回填了 NULL 的数据,
|
||||||
|
-- 但部分作品的 original_image_url 字段是空字符串 '' 而不是 NULL
|
||||||
|
-- (JS 前端经常把空字段当 '' 传给后端),V14 的 IS NULL 条件没匹配到。
|
||||||
|
--
|
||||||
|
-- 前端 v-if="work.originalImageUrl" 在空字符串时是 falsy,
|
||||||
|
-- 导致这些作品的「画作原图」卡片和 PIP 不显示。
|
||||||
|
--
|
||||||
|
-- 修复:
|
||||||
|
-- 把 original_image_url 为 NULL 或空字符串的数据,统一回填为 cover_url。
|
||||||
|
--
|
||||||
|
-- 详见 V13、V14 历史
|
||||||
|
-- ========================================================
|
||||||
|
|
||||||
|
UPDATE t_ugc_work
|
||||||
|
SET original_image_url = cover_url
|
||||||
|
WHERE (original_image_url IS NULL OR original_image_url = '')
|
||||||
|
AND cover_url IS NOT NULL
|
||||||
|
AND cover_url != '';
|
||||||
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 三态命名风格一致
|
||||||
|
- 本地化为"未发布"也是中文用户最直观的理解
|
||||||
@ -388,6 +388,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 {
|
export interface UserWork {
|
||||||
id: number
|
id: number
|
||||||
userId: number
|
userId: number
|
||||||
@ -395,7 +419,7 @@ export interface UserWork {
|
|||||||
coverUrl: string | null
|
coverUrl: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
visibility: string
|
visibility: string
|
||||||
status: string
|
status: WorkStatus
|
||||||
reviewNote: string | null
|
reviewNote: string | null
|
||||||
originalImageUrl: string | null
|
originalImageUrl: string | null
|
||||||
voiceInputUrl: string | null
|
voiceInputUrl: string | null
|
||||||
|
|||||||
@ -1,24 +1,24 @@
|
|||||||
// 乐读派 C端 — AI 创作专用样式(隔离在 .ai-create-shell 容器内)
|
// AI 创作专用样式(隔离在 .ai-create-shell 容器内)
|
||||||
// 暖橙 + 奶油白 儿童绘本风格
|
// 紫粉风格,与 PublicLayout 设计语言保持一致
|
||||||
// 所有 CSS 变量使用 --ai- 前缀,避免与主前端冲突
|
// 所有 CSS 变量使用 --ai- 前缀,避免与主前端冲突
|
||||||
|
|
||||||
.ai-create-shell {
|
.ai-create-shell {
|
||||||
--ai-primary: #FF6B35;
|
--ai-primary: #6366f1;
|
||||||
--ai-primary-light: #FFF0E8;
|
--ai-primary-light: #eef0ff;
|
||||||
--ai-secondary: #6C63FF;
|
--ai-secondary: #ec4899;
|
||||||
--ai-accent: #FFD166;
|
--ai-accent: #a78bfa;
|
||||||
--ai-success: #2EC4B6;
|
--ai-success: #10b981;
|
||||||
--ai-bg: #FFFDF7;
|
--ai-bg: #f8f7fc;
|
||||||
--ai-card: #FFFFFF;
|
--ai-card: #ffffff;
|
||||||
--ai-text: #2D2D3F;
|
--ai-text: #1e1b4b;
|
||||||
--ai-text-sub: #8E8EA0;
|
--ai-text-sub: #6b7280;
|
||||||
--ai-border: #F0EDE8;
|
--ai-border: #e5e7eb;
|
||||||
--ai-radius: 20px;
|
--ai-radius: 20px;
|
||||||
--ai-radius-sm: 14px;
|
--ai-radius-sm: 14px;
|
||||||
--ai-shadow: 0 8px 32px rgba(255, 107, 53, 0.12);
|
--ai-shadow: 0 8px 28px rgba(99, 102, 241, 0.22);
|
||||||
--ai-shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.06);
|
--ai-shadow-soft: 0 4px 20px rgba(99, 102, 241, 0.06);
|
||||||
--ai-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FFB088 100%);
|
--ai-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||||
--ai-gradient-purple: linear-gradient(135deg, #6C63FF 0%, #9B93FF 100%);
|
--ai-gradient-purple: linear-gradient(135deg, #a78bfa 0%, #c084fc 100%);
|
||||||
--ai-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
--ai-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', -apple-system, sans-serif;
|
||||||
|
|
||||||
font-family: var(--ai-font);
|
font-family: var(--ai-font);
|
||||||
|
|||||||
@ -28,12 +28,12 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/p",
|
path: "/p",
|
||||||
name: "PublicMain",
|
|
||||||
component: () => import("@/layouts/PublicLayout.vue"),
|
component: () => import("@/layouts/PublicLayout.vue"),
|
||||||
meta: { requiresAuth: false },
|
meta: { requiresAuth: false },
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "",
|
||||||
|
name: "PublicMain",
|
||||||
redirect: "/p/gallery",
|
redirect: "/p/gallery",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -100,16 +100,16 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
name: "PublicCreateCharacters",
|
name: "PublicCreateCharacters",
|
||||||
component: () => import("@/views/public/create/views/CharactersView.vue"),
|
component: () => import("@/views/public/create/views/CharactersView.vue"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "style",
|
|
||||||
name: "PublicCreateStyle",
|
|
||||||
component: () => import("@/views/public/create/views/StyleSelectView.vue"),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "story",
|
path: "story",
|
||||||
name: "PublicCreateStory",
|
name: "PublicCreateStory",
|
||||||
component: () => import("@/views/public/create/views/StoryInputView.vue"),
|
component: () => import("@/views/public/create/views/StoryInputView.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "style",
|
||||||
|
name: "PublicCreateStyle",
|
||||||
|
component: () => import("@/views/public/create/views/StyleSelectView.vue"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "creating",
|
path: "creating",
|
||||||
name: "PublicCreateCreating",
|
name: "PublicCreateCreating",
|
||||||
@ -155,12 +155,6 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/public/works/Detail.vue"),
|
component: () => import("@/views/public/works/Detail.vue"),
|
||||||
meta: { title: "作品详情" },
|
meta: { title: "作品详情" },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "works/:id/publish",
|
|
||||||
name: "PublicWorkPublish",
|
|
||||||
component: () => import("@/views/public/works/Publish.vue"),
|
|
||||||
meta: { title: "发布作品" },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// ========== 管理端路由 ==========
|
// ========== 管理端路由 ==========
|
||||||
|
|||||||
@ -76,6 +76,89 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
|||||||
sessionStorage.setItem('le_recovery', JSON.stringify(recovery))
|
sessionStorage.setItem('le_recovery', JSON.stringify(recovery))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发模式:填充一份 mock 数据,用于跳过真实后端调用走通 UI 流程
|
||||||
|
* 仅供开发期 UI 调试使用,不要在生产逻辑中调用
|
||||||
|
* @param count 要 mock 的角色数量(1-3),默认 3
|
||||||
|
*/
|
||||||
|
function fillMockData(count: number = 3) {
|
||||||
|
// 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象)
|
||||||
|
const mockSvg = (hue: number) =>
|
||||||
|
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
|
||||||
|
`<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="240" height="240" fill="url(#g)"/>` +
|
||||||
|
`</svg>`
|
||||||
|
)
|
||||||
|
|
||||||
|
imageUrl.value = mockSvg(250)
|
||||||
|
extractId.value = 'mock-extract-' + Date.now()
|
||||||
|
selectedCharacter.value = null
|
||||||
|
|
||||||
|
// 注意:真实 AI 接口不返回 name 字段,mock 数据也不写 name,由用户在 StoryInputView 自己起名
|
||||||
|
const allChars = [
|
||||||
|
{ charId: 'mock-c1', type: 'HERO', originalCropUrl: mockSvg(280) },
|
||||||
|
{ charId: 'mock-c2', type: 'SIDEKICK', originalCropUrl: mockSvg(30) },
|
||||||
|
{ charId: 'mock-c3', type: 'SIDEKICK', originalCropUrl: mockSvg(100) },
|
||||||
|
]
|
||||||
|
const n = Math.max(1, Math.min(count, allChars.length))
|
||||||
|
characters.value = allChars.slice(0, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开发模式:填充一份完整的 mock 作品数据,用于跳过真实 AI 生成走通预览/编辑/发布等下游 UI
|
||||||
|
* 仅供开发期 UI 调试使用
|
||||||
|
*/
|
||||||
|
function fillMockWorkDetail() {
|
||||||
|
// 16:9 渐变占位图(800x450),模拟真实绘本插画
|
||||||
|
const mockPage = (hue: number) =>
|
||||||
|
'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>`
|
||||||
|
)
|
||||||
|
|
||||||
|
// 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图
|
||||||
|
const pageTexts = [
|
||||||
|
'', // 封面
|
||||||
|
'一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。',
|
||||||
|
'它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。',
|
||||||
|
'小主角轻轻抱起小鸟,决定送它回家。',
|
||||||
|
'路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。',
|
||||||
|
'小狐狸说它认识森林里所有的小路,愿意做大家的向导。',
|
||||||
|
'三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。',
|
||||||
|
'小鱼们告诉他们,那棵会发光的大树就在前方不远处。',
|
||||||
|
'森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。',
|
||||||
|
'原来这就是小鸟的家,妈妈正在树枝上焦急地张望。',
|
||||||
|
'小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。',
|
||||||
|
'夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。',
|
||||||
|
'小主角带着这份美好回到家,心里也开出了一朵花。',
|
||||||
|
]
|
||||||
|
|
||||||
|
const wid = 'mock-work-' + Date.now()
|
||||||
|
workId.value = wid
|
||||||
|
workDetail.value = {
|
||||||
|
workId: wid,
|
||||||
|
status: 3, // COMPLETED
|
||||||
|
title: storyData.value?.title || '森林大冒险',
|
||||||
|
subtitle: '',
|
||||||
|
author: '',
|
||||||
|
coverUrl: mockPage(280),
|
||||||
|
pageList: pageTexts.map((text, i) => ({
|
||||||
|
pageNum: i,
|
||||||
|
text,
|
||||||
|
imageUrl: mockPage((280 + i * 27) % 360),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function restoreRecoveryState() {
|
function restoreRecoveryState() {
|
||||||
const raw = sessionStorage.getItem('le_recovery')
|
const raw = sessionStorage.getItem('le_recovery')
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
@ -105,6 +188,9 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
|||||||
imageUrl, extractId, characters, selectedCharacter,
|
imageUrl, extractId, characters, selectedCharacter,
|
||||||
selectedStyle, storyData, workId, workDetail,
|
selectedStyle, storyData, workId, workDetail,
|
||||||
reset, saveRecoveryState, restoreRecoveryState,
|
reset, saveRecoveryState, restoreRecoveryState,
|
||||||
|
// 开发模式
|
||||||
|
fillMockData,
|
||||||
|
fillMockWorkDetail,
|
||||||
// Tab 切换状态
|
// Tab 切换状态
|
||||||
lastCreateRoute, setLastCreateRoute, clearLastCreateRoute,
|
lastCreateRoute, setLastCreateRoute, clearLastCreateRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -683,7 +683,7 @@ $primary: #6366f1;
|
|||||||
|
|
||||||
.results-icon {
|
.results-icon {
|
||||||
font-size: 40px;
|
font-size: 40px;
|
||||||
color: #f59e0b;
|
color: #6366f1;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
<!-- 推荐作品 -->
|
<!-- 推荐作品 -->
|
||||||
<div class="recommend-section" v-if="recommendedWorks.length > 0">
|
<div class="recommend-section" v-if="recommendedWorks.length > 0">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-title"><fire-outlined /> 编辑推荐</span>
|
<span class="section-title"><crown-filled /> 编辑推荐</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="recommend-scroll">
|
<div class="recommend-scroll">
|
||||||
<div
|
<div
|
||||||
@ -77,10 +77,19 @@
|
|||||||
@click="$router.push(`/p/works/${work.id}`)"
|
@click="$router.push(`/p/works/${work.id}`)"
|
||||||
>
|
>
|
||||||
<div class="card-cover">
|
<div class="card-cover">
|
||||||
|
<!-- 大图:AI 生成的绘本封面 -->
|
||||||
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title" />
|
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title" />
|
||||||
<div v-else class="cover-placeholder">
|
<div v-else class="cover-placeholder">
|
||||||
<picture-outlined />
|
<picture-outlined />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 右下角 PIP:用户上传的原图 -->
|
||||||
|
<div
|
||||||
|
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
|
||||||
|
class="cover-pip"
|
||||||
|
title="原图"
|
||||||
|
>
|
||||||
|
<img :src="work.originalImageUrl" alt="原图" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3>{{ work.title }}</h3>
|
<h3>{{ work.title }}</h3>
|
||||||
@ -116,7 +125,7 @@
|
|||||||
import { ref, reactive, computed, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined, FireOutlined } from '@ant-design/icons-vue'
|
import { SearchOutlined, PictureOutlined, HeartOutlined, HeartFilled, EyeOutlined, CrownFilled } from '@ant-design/icons-vue'
|
||||||
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
|
import { publicGalleryApi, publicTagsApi, publicInteractionApi, type UserWork, type WorkTag } from '@/api/public'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -239,7 +248,7 @@ $primary: #6366f1;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
||||||
:deep(.anticon) { color: #f59e0b; }
|
:deep(.anticon) { color: $primary; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -351,10 +360,36 @@ $primary: #6366f1;
|
|||||||
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
||||||
|
|
||||||
.card-cover {
|
.card-cover {
|
||||||
|
position: relative;
|
||||||
aspect-ratio: 3/4;
|
aspect-ratio: 3/4;
|
||||||
background: #f5f3ff;
|
background: #f5f3ff;
|
||||||
img { width: 100%; height: 100%; object-fit: cover; }
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
|
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
|
||||||
|
|
||||||
|
/* 右下角 PIP:用户原图 */
|
||||||
|
.cover-pip {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 34%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 3px 10px rgba(15, 12, 41, 0.25);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .cover-pip {
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
|
|||||||
@ -25,8 +25,18 @@
|
|||||||
:class="['selector-card', { selected: selectedWork?.id === work.id }]"
|
:class="['selector-card', { selected: selectedWork?.id === work.id }]"
|
||||||
@click="selectedWork = work"
|
@click="selectedWork = work"
|
||||||
>
|
>
|
||||||
<img v-if="work.coverUrl" :src="work.coverUrl" class="selector-cover" />
|
<div class="cover-wrap">
|
||||||
<div v-else class="selector-cover-empty"><picture-outlined /></div>
|
<img v-if="work.coverUrl" :src="work.coverUrl" class="selector-cover" />
|
||||||
|
<div v-else class="selector-cover-empty"><picture-outlined /></div>
|
||||||
|
<!-- 右下角 PIP:用户上传的原图 -->
|
||||||
|
<div
|
||||||
|
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
|
||||||
|
class="cover-pip"
|
||||||
|
title="原图"
|
||||||
|
>
|
||||||
|
<img :src="work.originalImageUrl" alt="原图" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="selector-info">
|
<div class="selector-info">
|
||||||
<h4>{{ work.title }}</h4>
|
<h4>{{ work.title }}</h4>
|
||||||
<span>{{ work._count?.pages || 0 }}页</span>
|
<span>{{ work._count?.pages || 0 }}页</span>
|
||||||
@ -107,15 +117,22 @@ $primary: #6366f1;
|
|||||||
&:hover { border-color: rgba($primary, 0.3); }
|
&:hover { border-color: rgba($primary, 0.3); }
|
||||||
&.selected { border-color: $primary; box-shadow: 0 0 0 2px rgba($primary, 0.2); }
|
&.selected { border-color: $primary; box-shadow: 0 0 0 2px rgba($primary, 0.2); }
|
||||||
|
|
||||||
|
.cover-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 3 / 4;
|
||||||
|
}
|
||||||
|
|
||||||
.selector-cover {
|
.selector-cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3/4;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selector-cover-empty {
|
.selector-cover-empty {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 3/4;
|
height: 100%;
|
||||||
background: #f5f3ff;
|
background: #f5f3ff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -124,6 +141,26 @@ $primary: #6366f1;
|
|||||||
color: #d1d5db;
|
color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 右下角 PIP:用户原图(更小尺寸) */
|
||||||
|
.cover-pip {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
bottom: 5px;
|
||||||
|
width: 32%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1.5px solid #fff;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(15, 12, 41, 0.22);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.selector-info {
|
.selector-info {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
h4 { font-size: 12px; font-weight: 600; color: #1e1b4b; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
h4 { font-size: 12px; font-weight: 600; color: #1e1b4b; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|||||||
@ -73,16 +73,13 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
// 壳组件样式不使用 scoped,因为 aicreate.scss 已通过 .ai-create-shell 隔离
|
// 壳组件样式不使用 scoped,因为 aicreate.scss 已通过 .ai-create-shell 隔离
|
||||||
// 覆盖 PublicLayout 的 public-main 样式,让创作页面全屏展示
|
// 覆盖 PublicLayout 的 public-main 样式:限制为 H5 单列宽度,子页面自己控制内边距
|
||||||
// 只清除左右上的内边距,保留底部为 tabbar 占位
|
|
||||||
.public-main:has(> .ai-create-shell) {
|
.public-main:has(> .ai-create-shell) {
|
||||||
padding-left: 0 !important;
|
padding-left: 0 !important;
|
||||||
padding-right: 0 !important;
|
padding-right: 0 !important;
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
// 保留 padding-bottom 为底部 tabbar 留空间(桌面 40px / 移动 80px)
|
// 保留 padding-bottom 为底部 tabbar 留空间(桌面 40px / 移动 80px)
|
||||||
max-width: 430px;
|
max-width: 430px;
|
||||||
overflow: hidden;
|
|
||||||
background: var(--ai-bg, #FFFDF7);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ai-create-shell {
|
.ai-create-shell {
|
||||||
@ -98,8 +95,8 @@ onMounted(() => {
|
|||||||
.spinner {
|
.spinner {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border: 4px solid rgba(255, 107, 53, 0.15);
|
border: 4px solid rgba(99, 102, 241, 0.15);
|
||||||
border-top-color: #FF6B35;
|
border-top-color: #6366f1;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="reader-page" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
<div class="reader-page page-fullscreen" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||||
<!-- 顶栏 -->
|
<!-- 顶栏 -->
|
||||||
<div class="reader-top">
|
<div class="reader-top">
|
||||||
<div v-if="fromWorks" class="back-btn" @click="handleBack">
|
<div v-if="fromWorks" class="back-btn" @click="handleBack">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><path d="M15 18l-6-6 6-6"/></svg>
|
<left-outlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="top-title">{{ title }}</div>
|
<div class="top-title">{{ title }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 书本区域 -->
|
<!-- 书本区域 -->
|
||||||
<div class="book-area">
|
<div class="book-area page-content">
|
||||||
<div
|
<div
|
||||||
class="book"
|
class="book"
|
||||||
:class="{ 'flip-left': flipDir === -1, 'flip-right': flipDir === 1 }"
|
:class="{ 'flip-left': flipDir === -1, 'flip-right': flipDir === 1 }"
|
||||||
@ -19,22 +19,24 @@
|
|||||||
|
|
||||||
<!-- 封面 -->
|
<!-- 封面 -->
|
||||||
<div v-if="isCover" class="page-cover">
|
<div v-if="isCover" class="page-cover">
|
||||||
<div class="cover-deco star">⭐</div>
|
<div class="cover-image">
|
||||||
<div class="cover-image" v-if="coverImageUrl">
|
<img v-if="coverImageUrl" :src="coverImageUrl" class="cover-real-img" />
|
||||||
<img :src="coverImageUrl" class="cover-real-img" />
|
<picture-outlined v-else class="cover-placeholder" />
|
||||||
</div>
|
</div>
|
||||||
<div class="cover-image" v-else>📖</div>
|
|
||||||
<div class="cover-title">{{ currentPage.text }}</div>
|
<div class="cover-title">{{ currentPage.text }}</div>
|
||||||
<div class="cover-divider" />
|
<div class="cover-divider" />
|
||||||
<div class="cover-brand">{{ brandName }} AI 绘本</div>
|
<div class="cover-brand">{{ brandName }} · AI 绘本创作</div>
|
||||||
<div v-if="authorDisplay" class="cover-author">✍️ {{ authorDisplay }}</div>
|
<div v-if="authorDisplay" class="cover-author">
|
||||||
|
<user-outlined />
|
||||||
|
<span>{{ authorDisplay }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 正文页 -->
|
<!-- 正文页 -->
|
||||||
<div v-else-if="isContent" class="page-content">
|
<div v-else-if="isContent" class="book-content-page">
|
||||||
<div class="content-image">
|
<div class="content-image">
|
||||||
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="content-real-img" />
|
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="content-real-img" />
|
||||||
<span v-else class="content-emoji">{{ pageEmoji }}</span>
|
<picture-outlined v-else class="content-placeholder" />
|
||||||
<div class="page-num">P{{ idx }}</div>
|
<div class="page-num">P{{ idx }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content-text">{{ currentPage.text }}</div>
|
<div class="content-text">{{ currentPage.text }}</div>
|
||||||
@ -42,29 +44,31 @@
|
|||||||
|
|
||||||
<!-- 封底 -->
|
<!-- 封底 -->
|
||||||
<div v-else-if="isBack" class="page-back">
|
<div v-else-if="isBack" class="page-back">
|
||||||
<div class="back-emoji">🎉</div>
|
<heart-filled class="back-icon" />
|
||||||
<div class="back-title">故事讲完啦!</div>
|
<div class="back-title">故事讲完了</div>
|
||||||
<div class="back-divider" />
|
<div class="back-divider" />
|
||||||
<div class="back-desc">每一个孩子的画<br/>都是一个精彩的故事</div>
|
<div class="back-desc">你的画作<br />变成了一个精彩的故事</div>
|
||||||
<div v-if="workTags.length" class="book-tags">
|
<div v-if="workTags.length" class="book-tags">
|
||||||
<span v-for="tag in workTags" :key="tag" class="book-tag">{{ tag }}</span>
|
<span v-for="tag in workTags" :key="tag" class="book-tag">{{ tag }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="back-replay" @click="jumpTo(0)">
|
<div class="back-replay" @click="jumpTo(0)">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M1 4v6h6"/><path d="M3.51 15a9 9 0 105.64-8.36L1 10"/></svg>
|
<reload-outlined />
|
||||||
重新阅读
|
<span>重新阅读</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="back-brand">{{ brandName }} AI 绘本 · {{ brandSlogan }}</div>
|
<div class="back-brand">{{ brandName }} · AI 绘本创作</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 翻页导航 -->
|
<!-- 翻页导航 -->
|
||||||
<div class="nav-row">
|
<div class="nav-row">
|
||||||
<div class="nav-btn prev" :class="{ disabled: idx <= 0 }" @click="go(-1)">
|
<div class="nav-btn prev" :class="{ disabled: idx <= 0 }" @click="go(-1)">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M15 18l-6-6 6-6"/></svg>
|
<left-outlined />
|
||||||
|
</div>
|
||||||
|
<div class="nav-label">
|
||||||
|
{{ isCover ? '封面' : isBack ? '— 完 —' : `${idx} / ${totalContent}` }}
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-label">{{ isCover ? '封面' : isBack ? '— 完 —' : `${idx} / ${totalContent}` }}</div>
|
|
||||||
<div class="nav-btn next" :class="{ disabled: idx >= pages.length - 1 }" @click="go(1)">
|
<div class="nav-btn next" :class="{ disabled: idx >= pages.length - 1 }" @click="go(1)">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 18l6-6-6-6"/></svg>
|
<right-outlined />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -74,10 +78,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部:再次创作(仅创作流程入口显示,作品列表入口不显示) -->
|
<!-- 底部:再次创作(仅创作流程入口显示,作品库入口不显示) -->
|
||||||
<div v-if="!fromWorks" class="reader-bottom safe-bottom">
|
<div v-if="!fromWorks" class="reader-bottom page-bottom">
|
||||||
<button class="btn-primary" @click="goHome">再次创作 →</button>
|
<button class="btn-primary again-btn" @click="goHome">
|
||||||
<div class="bottom-hint">本作品可在作品板块中查看</div>
|
<plus-outlined />
|
||||||
|
<span>再创作一本</span>
|
||||||
|
</button>
|
||||||
|
<div class="bottom-hint">本作品可在「作品库」继续查看</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -85,16 +92,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
HeartFilled,
|
||||||
|
ReloadOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { getWorkDetail } from '@/api/aicreate'
|
import { getWorkDetail } from '@/api/aicreate'
|
||||||
import config from '@/utils/aicreate/config'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|
const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works'
|
||||||
|| sessionStorage.getItem('le_from') === 'works'
|
|| sessionStorage.getItem('le_from') === 'works'
|
||||||
|
|
||||||
|
const brandName = '乐绘世界'
|
||||||
|
|
||||||
function handleBack() {
|
function handleBack() {
|
||||||
if (fromWorks) {
|
if (fromWorks) {
|
||||||
router.push('/p/works')
|
router.push('/p/works')
|
||||||
@ -103,17 +122,14 @@ function handleBack() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const brandName = config.brand.title || '乐读派'
|
|
||||||
const brandSlogan = config.brand.slogan || '让想象力飞翔'
|
|
||||||
|
|
||||||
const idx = ref(0)
|
const idx = ref(0)
|
||||||
const flipDir = ref(0)
|
const flipDir = ref(0)
|
||||||
|
|
||||||
const title = ref('我的绘本')
|
const title = ref('我的绘本')
|
||||||
const coverImageUrl = ref('')
|
const coverImageUrl = ref('')
|
||||||
const authorDisplay = ref('')
|
const authorDisplay = ref('')
|
||||||
const workTags = ref([])
|
const workTags = ref<string[]>([])
|
||||||
const pages = ref([
|
const pages = ref<any[]>([
|
||||||
{ pageNum: 0, text: '我的绘本', type: 'cover' },
|
{ pageNum: 0, text: '我的绘本', type: 'cover' },
|
||||||
{ pageNum: 99, text: '', type: 'backcover' },
|
{ pageNum: 99, text: '', type: 'backcover' },
|
||||||
])
|
])
|
||||||
@ -125,35 +141,29 @@ const isContent = computed(() => !isCover.value && !isBack.value)
|
|||||||
const totalContent = computed(() => pages.value.length - 2)
|
const totalContent = computed(() => pages.value.length - 2)
|
||||||
const progressPct = computed(() => ((idx.value) / (pages.value.length - 1)) * 100)
|
const progressPct = computed(() => ((idx.value) / (pages.value.length - 1)) * 100)
|
||||||
|
|
||||||
const bgColors = ['#FFF5EB', '#E8F4FD', '#F0F9EC', '#FFF8E1', '#F3E8FF', '#E0F7F4', '#FFF9E6', '#FCE4EC']
|
|
||||||
const emojis = ['📖', '🌅', '🐦', '🗣️', '🍄', '🏠', '❤️', '🌟']
|
|
||||||
const pageEmoji = computed(() => emojis[idx.value % emojis.length])
|
|
||||||
|
|
||||||
const pageBg = computed(() => {
|
const pageBg = computed(() => {
|
||||||
if (isCover.value) return 'linear-gradient(135deg, #FF8F65 0%, #FF6B35 40%, #E85D26 100%)'
|
if (isCover.value) return 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%)'
|
||||||
if (isBack.value) return 'linear-gradient(135deg, #FFD4A8 0%, #FFB874 50%, #FF9F43 100%)'
|
if (isBack.value) return 'linear-gradient(135deg, #8b5cf6 0%, #a78bfa 50%, #f472b6 100%)'
|
||||||
return `linear-gradient(180deg, ${bgColors[idx.value % bgColors.length]} 0%, #FFFFFF 100%)`
|
return '#ffffff'
|
||||||
})
|
})
|
||||||
|
|
||||||
const go = (dir) => {
|
const go = (dir: number) => {
|
||||||
const next = idx.value + dir
|
const next = idx.value + dir
|
||||||
if (next < 0 || next >= pages.value.length) return
|
if (next < 0 || next >= pages.value.length) return
|
||||||
flipDir.value = dir
|
flipDir.value = dir
|
||||||
setTimeout(() => { idx.value = next; flipDir.value = 0 }, 250)
|
setTimeout(() => { idx.value = next; flipDir.value = 0 }, 250)
|
||||||
}
|
}
|
||||||
|
|
||||||
const jumpTo = (i) => { idx.value = i; flipDir.value = 0 }
|
const jumpTo = (i: number) => { idx.value = i; flipDir.value = 0 }
|
||||||
|
|
||||||
// 触摸滑动翻页
|
// 触摸滑动翻页
|
||||||
let touchStartX = 0
|
let touchStartX = 0
|
||||||
let touchStartY = 0
|
let touchStartY = 0
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
const onTouchStart = (e) => {
|
|
||||||
touchStartX = e.touches[0].clientX
|
touchStartX = e.touches[0].clientX
|
||||||
touchStartY = e.touches[0].clientY
|
touchStartY = e.touches[0].clientY
|
||||||
}
|
}
|
||||||
|
const onTouchEnd = (e: TouchEvent) => {
|
||||||
const onTouchEnd = (e) => {
|
|
||||||
const dx = e.changedTouches[0].clientX - touchStartX
|
const dx = e.changedTouches[0].clientX - touchStartX
|
||||||
const dy = e.changedTouches[0].clientY - touchStartY
|
const dy = e.changedTouches[0].clientY - touchStartY
|
||||||
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||||
@ -162,12 +172,33 @@ const onTouchEnd = (e) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
store.reset() // 清空所有创作缓存,确保新创作从零开始
|
store.reset()
|
||||||
router.push('/p/create')
|
router.push('/p/create')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyWork(work: any) {
|
||||||
|
title.value = work.title || '我的绘本'
|
||||||
|
const list: any[] = [{ pageNum: 0, text: work.title || '我的绘本', type: 'cover' }]
|
||||||
|
;(work.pageList || []).forEach((p: any) => {
|
||||||
|
if (p.pageNum > 0) list.push({ pageNum: p.pageNum, text: p.text, imageUrl: p.imageUrl })
|
||||||
|
})
|
||||||
|
if (work.pageList?.[0]?.imageUrl) coverImageUrl.value = work.pageList[0].imageUrl
|
||||||
|
if (work.author) authorDisplay.value = work.author
|
||||||
|
if (Array.isArray(work.tags) && work.tags.length > 0) workTags.value = work.tags
|
||||||
|
list.push({ pageNum: 99, text: '', type: 'backcover' })
|
||||||
|
pages.value = list
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const workId = route.params.workId
|
const workId = route.params.workId
|
||||||
|
|
||||||
|
// dev 兜底:mock workId 直接用 store.workDetail
|
||||||
|
if (isDev && String(workId || '').startsWith('mock-')) {
|
||||||
|
if (!store.workDetail) store.fillMockWorkDetail()
|
||||||
|
if (store.workDetail) applyWork(store.workDetail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!workId) return
|
if (!workId) return
|
||||||
try {
|
try {
|
||||||
let work
|
let work
|
||||||
@ -177,7 +208,6 @@ onMounted(async () => {
|
|||||||
const res = await getWorkDetail(workId)
|
const res = await getWorkDetail(workId)
|
||||||
work = res
|
work = res
|
||||||
} else if (shareToken) {
|
} else if (shareToken) {
|
||||||
// 分享链接: 无认证,用 shareToken
|
|
||||||
const leaiBase = import.meta.env.VITE_LEAI_API_URL || ''
|
const leaiBase = import.meta.env.VITE_LEAI_API_URL || ''
|
||||||
const resp = await fetch(`${leaiBase}/api/v1/query/work/${workId}?shareToken=${encodeURIComponent(shareToken)}`)
|
const resp = await fetch(`${leaiBase}/api/v1/query/work/${workId}?shareToken=${encodeURIComponent(shareToken)}`)
|
||||||
const json = await resp.json()
|
const json = await resp.json()
|
||||||
@ -185,62 +215,73 @@ onMounted(async () => {
|
|||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (work) {
|
if (work) applyWork(work)
|
||||||
title.value = work.title || '我的绘本'
|
|
||||||
const list = [{ pageNum: 0, text: work.title || '我的绘本', type: 'cover' }]
|
|
||||||
;(work.pageList || []).forEach(p => {
|
|
||||||
if (p.pageNum > 0) list.push({ pageNum: p.pageNum, text: p.text, imageUrl: p.imageUrl })
|
|
||||||
})
|
|
||||||
if (work.pageList?.[0]?.imageUrl) coverImageUrl.value = work.pageList[0].imageUrl
|
|
||||||
if (work.author) authorDisplay.value = work.author
|
|
||||||
if (Array.isArray(work.tags) && work.tags.length > 0) workTags.value = work.tags
|
|
||||||
list.push({ pageNum: 99, text: '', type: 'backcover' })
|
|
||||||
pages.value = list
|
|
||||||
}
|
|
||||||
} catch { /* use default */ }
|
} catch { /* use default */ }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.reader-page {
|
.reader-page {
|
||||||
min-height: 100vh;
|
background: var(--ai-bg);
|
||||||
background: #F5F0E8;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 顶栏
|
/* ---------- 顶栏 ---------- */
|
||||||
.reader-top {
|
.reader-top {
|
||||||
padding: 14px 20px;
|
flex-shrink: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
background: rgba(255, 255, 255, 0.92);
|
||||||
background: rgba(255,255,255,0.85);
|
backdrop-filter: blur(12px);
|
||||||
backdrop-filter: blur(10px);
|
border-bottom: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.back-btn { padding: 4px; cursor: pointer; background: rgba(0,0,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
.back-btn {
|
||||||
.top-title { font-size: 15px; font-weight: 700; color: var(--ai-text); flex: 1; text-align: center; }
|
position: absolute;
|
||||||
|
left: 12px;
|
||||||
// 书本区域
|
width: 32px;
|
||||||
.book-area {
|
height: 32px;
|
||||||
flex: 1;
|
cursor: pointer;
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 16px; }
|
||||||
|
}
|
||||||
|
.top-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ai-text);
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 书本区域 ---------- */
|
||||||
|
.book-area {
|
||||||
|
display: flex !important;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 12px 16px;
|
padding: 14px 16px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.book {
|
.book {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 320px;
|
||||||
aspect-ratio: 3/4;
|
aspect-ratio: 3 / 4;
|
||||||
border-radius: 4px 16px 16px 4px;
|
border-radius: 4px 16px 16px 4px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: inset -4px 0 8px rgba(0,0,0,0.04), 4px 0 12px rgba(0,0,0,0.08), 0 8px 32px rgba(0,0,0,0.12);
|
box-shadow:
|
||||||
|
inset -4px 0 8px rgba(0, 0, 0, 0.04),
|
||||||
|
4px 0 12px rgba(99, 102, 241, 0.14),
|
||||||
|
0 14px 36px rgba(99, 102, 241, 0.2);
|
||||||
transition: transform 0.25s ease;
|
transition: transform 0.25s ease;
|
||||||
|
|
||||||
&.flip-left { transform: perspective(800px) rotateY(8deg); }
|
&.flip-left { transform: perspective(800px) rotateY(8deg); }
|
||||||
@ -248,146 +289,313 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.book-spine {
|
.book-spine {
|
||||||
position: absolute; left: 0; top: 0; bottom: 0; width: 6px;
|
position: absolute;
|
||||||
background: linear-gradient(180deg, rgba(0,0,0,0.08), rgba(0,0,0,0.02), rgba(0,0,0,0.08));
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
background: linear-gradient(180deg, rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.12));
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 封面
|
/* ---------- 封面 ---------- */
|
||||||
.page-cover {
|
.page-cover {
|
||||||
width: 100%; height: 100%;
|
width: 100%;
|
||||||
display: flex; flex-direction: column;
|
height: 100%;
|
||||||
align-items: center; justify-content: center;
|
display: flex;
|
||||||
padding: 32px; text-align: center;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 26px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
.cover-deco { position: absolute; opacity: 0.4; &.star { top: 20px; right: 24px; font-size: 24px; } }
|
|
||||||
.cover-image {
|
.cover-image {
|
||||||
width: calc(100% - 32px); aspect-ratio: 4/3; border-radius: 16px;
|
width: 100%;
|
||||||
background: rgba(255,255,255,0.2);
|
aspect-ratio: 16 / 9;
|
||||||
display: flex; align-items: center; justify-content: center;
|
border-radius: 14px;
|
||||||
font-size: 72px; margin-bottom: 20px; overflow: hidden;
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
.cover-real-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.cover-placeholder {
|
||||||
|
font-size: 56px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
.cover-real-img { width: 100%; height: 100%; object-fit: cover; border-radius: 16px; }
|
|
||||||
.cover-title {
|
.cover-title {
|
||||||
font-size: 24px; font-weight: 900; color: #fff;
|
font-size: 22px;
|
||||||
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
font-weight: 900;
|
||||||
line-height: 1.4; letter-spacing: 2px;
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);
|
||||||
|
line-height: 1.3;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
.cover-divider {
|
||||||
|
width: 50px;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 14px 0 12px;
|
||||||
|
}
|
||||||
|
.cover-brand {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
.cover-divider { width: 60px; height: 3px; background: rgba(255,255,255,0.5); border-radius: 2px; margin: 16px 0; }
|
|
||||||
.cover-brand { font-size: 13px; color: rgba(255,255,255,0.8); }
|
|
||||||
.cover-author {
|
.cover-author {
|
||||||
margin-top: 8px; font-size: 12px; color: rgba(255,255,255,0.7);
|
margin-top: 12px;
|
||||||
background: rgba(255,255,255,0.15); border-radius: 12px; padding: 4px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 正文页
|
|
||||||
.page-content { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
|
||||||
.content-image {
|
|
||||||
flex: 1; display: flex; align-items: center; justify-content: center;
|
|
||||||
padding: 16px; position: relative;
|
|
||||||
}
|
|
||||||
.content-emoji { font-size: 64px; }
|
|
||||||
.content-real-img { width: 100%; height: 100%; object-fit: contain; border-radius: 12px; }
|
|
||||||
.page-num {
|
|
||||||
position: absolute; top: 20px; left: 20px;
|
|
||||||
background: rgba(0,0,0,0.4); border-radius: 8px; padding: 2px 10px;
|
|
||||||
font-size: 11px; font-weight: 600; color: #fff;
|
|
||||||
}
|
|
||||||
.content-text {
|
|
||||||
padding: 12px 24px 20px; text-align: center;
|
|
||||||
background: rgba(255,255,255,0.6); border-top: 1px solid rgba(0,0,0,0.04);
|
|
||||||
font-size: 16px; font-weight: 500; line-height: 1.8; letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 封底
|
|
||||||
.page-back {
|
|
||||||
width: 100%; height: 100%;
|
|
||||||
display: flex; flex-direction: column;
|
|
||||||
align-items: center; justify-content: center;
|
|
||||||
padding: 40px; text-align: center;
|
|
||||||
}
|
|
||||||
.back-emoji { font-size: 56px; margin-bottom: 20px; }
|
|
||||||
.back-title { font-size: 22px; font-weight: 900; color: #fff; text-shadow: 0 2px 6px rgba(0,0,0,0.15); }
|
|
||||||
.back-divider { width: 40px; height: 3px; background: rgba(255,255,255,0.5); border-radius: 2px; margin: 14px 0; }
|
|
||||||
.back-desc { font-size: 14px; color: rgba(255,255,255,0.8); line-height: 1.8; }
|
|
||||||
.back-replay {
|
|
||||||
margin-top: 24px;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
background: rgba(255,255,255,0.95);
|
font-size: 11px;
|
||||||
color: var(--ai-primary);
|
color: #fff;
|
||||||
font-size: 15px;
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 11px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 正文页 ---------- */
|
||||||
|
.book-content-page {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.content-image {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px;
|
||||||
|
position: relative;
|
||||||
|
background: #1e1b4b;
|
||||||
|
}
|
||||||
|
.content-real-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.content-placeholder {
|
||||||
|
font-size: 48px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.page-num {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: 14px;
|
||||||
|
background: rgba(15, 12, 41, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 12px 32px;
|
color: #fff;
|
||||||
border-radius: 28px;
|
letter-spacing: 0.5px;
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
&:active { transform: scale(0.95); }
|
|
||||||
}
|
}
|
||||||
.back-brand {
|
.content-text {
|
||||||
margin-top: 20px;
|
padding: 14px 22px 18px;
|
||||||
font-size: 12px; color: rgba(255,255,255,0.7);
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--ai-text);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 封底 ---------- */
|
||||||
|
.page-back {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 28px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.back-icon {
|
||||||
|
font-size: 44px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
.back-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 900;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.back-divider {
|
||||||
|
width: 36px;
|
||||||
|
height: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
.back-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
line-height: 1.7;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.book-tags {
|
.book-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
margin-top: 16px;
|
margin-top: 14px;
|
||||||
padding: 0 20px;
|
padding: 0 16px;
|
||||||
}
|
}
|
||||||
.book-tag {
|
.book-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 4px 14px;
|
padding: 4px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 14px;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
color: #E11D48;
|
color: #fff;
|
||||||
background: linear-gradient(135deg, #FFF1F2, #FFE4E6);
|
background: rgba(255, 255, 255, 0.2);
|
||||||
border: 1px solid #FECDD3;
|
border: 1px solid rgba(255, 255, 255, 0.32);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 翻页导航
|
.back-replay {
|
||||||
|
margin-top: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 11px 26px;
|
||||||
|
border-radius: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 13px; }
|
||||||
|
|
||||||
|
&:active { transform: scale(0.96); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-brand {
|
||||||
|
margin-top: 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 翻页导航 ---------- */
|
||||||
.nav-row {
|
.nav-row {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex;
|
||||||
width: 100%; max-width: 360px; margin-top: 16px; padding: 0 4px;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
width: 44px; height: 44px; border-radius: 50%;
|
width: 40px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
height: 40px;
|
||||||
cursor: pointer; transition: all 0.2s;
|
border-radius: 50%;
|
||||||
&.prev { background: var(--ai-card); box-shadow: var(--ai-shadow-soft); color: var(--ai-text); }
|
display: flex;
|
||||||
&.next { background: var(--ai-gradient); box-shadow: var(--ai-shadow); color: #fff; }
|
align-items: center;
|
||||||
&.disabled { opacity: 0; pointer-events: none; }
|
justify-content: center;
|
||||||
}
|
cursor: pointer;
|
||||||
.nav-label { font-size: 13px; color: var(--ai-text-sub); font-weight: 500; }
|
transition: all 0.2s;
|
||||||
|
|
||||||
// 进度条
|
:deep(.anticon) { font-size: 16px; }
|
||||||
|
|
||||||
|
&.prev {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
|
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.08);
|
||||||
|
|
||||||
|
&:hover { border-color: var(--ai-primary); }
|
||||||
|
}
|
||||||
|
&.next {
|
||||||
|
background: var(--ai-gradient);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.32);
|
||||||
|
|
||||||
|
&:hover { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.nav-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 进度条 ---------- */
|
||||||
.progress-bar-wrap {
|
.progress-bar-wrap {
|
||||||
width: 100%; max-width: 360px; height: 3px;
|
width: 100%;
|
||||||
background: #E2DDD4; border-radius: 2px; margin-top: 12px; overflow: hidden;
|
max-width: 320px;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
border-radius: 2px;
|
||||||
|
margin-top: 12px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%; background: var(--ai-primary); border-radius: 2px;
|
height: 100%;
|
||||||
transition: width 0.3s ease;
|
background: var(--ai-gradient);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 底部
|
/* ---------- 底部 ---------- */
|
||||||
.reader-bottom {
|
.reader-bottom {
|
||||||
padding: 12px 20px 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255,255,255,0.85);
|
background: #fff;
|
||||||
backdrop-filter: blur(10px);
|
border-top: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
button { width: 100%; }
|
}
|
||||||
|
.again-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 14px 0 !important;
|
||||||
|
border-radius: 28px !important;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 16px; }
|
||||||
}
|
}
|
||||||
.bottom-hint {
|
.bottom-hint {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #9E9E9E;
|
color: var(--ai-text-sub);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,36 +1,57 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="char-page page-fullscreen">
|
<div class="char-page page-fullscreen">
|
||||||
<PageHeader title="选择主角" subtitle="AI已识别画中角色,请选择绘本主角" :step="1" />
|
<PageHeader title="画作角色" :subtitle="subtitle" :step="1" />
|
||||||
|
|
||||||
<div class="content page-content">
|
<div class="content page-content">
|
||||||
|
<!-- 开发模式:mock 数据切换 -->
|
||||||
|
<div v-if="isDev" class="dev-bar">
|
||||||
|
<experiment-outlined />
|
||||||
|
<span class="dev-label">Mock 角色数</span>
|
||||||
|
<button class="dev-btn" :class="{ active: characters.length === 1 }" @click="regenMock(1)">1 个</button>
|
||||||
|
<button class="dev-btn" :class="{ active: characters.length === 3 }" @click="regenMock(3)">3 个</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<div class="loading-emojis">
|
<loading-outlined class="loading-spinner" spin />
|
||||||
<span class="loading-emoji e1">🔍</span>
|
<div class="loading-title">AI 正在识别角色…</div>
|
||||||
<span class="loading-emoji e2">🎨</span>
|
|
||||||
<span class="loading-emoji e3">✨</span>
|
|
||||||
</div>
|
|
||||||
<div class="loading-title">AI正在识别角色...</div>
|
|
||||||
<div class="loading-sub">通常需要 10-20 秒</div>
|
<div class="loading-sub">通常需要 10-20 秒</div>
|
||||||
<div class="progress-bar"><div class="progress-fill" /></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误状态 -->
|
<!-- 错误状态 -->
|
||||||
<template v-else-if="error">
|
<template v-else-if="error">
|
||||||
<div class="error-state">
|
<div class="error-state">
|
||||||
<div class="error-emoji">😔</div>
|
<frown-outlined class="error-icon" />
|
||||||
<div class="error-text">{{ error }}</div>
|
<div class="error-text">{{ error }}</div>
|
||||||
<button class="btn-ghost" style="max-width:200px;margin-top:20px" @click="$router.back()">返回重新上传</button>
|
<button class="btn-ghost back-btn" @click="$router.back()">返回重新上传</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 单角色:大图展示 -->
|
||||||
|
<template v-else-if="characters.length === 1">
|
||||||
|
<div class="single-wrap">
|
||||||
|
<div class="single-card">
|
||||||
|
<div class="single-img-wrap" @click="characters[0].originalCropUrl && (previewImg = characters[0].originalCropUrl)">
|
||||||
|
<img v-if="characters[0].originalCropUrl" :src="characters[0].originalCropUrl" class="single-img" />
|
||||||
|
<user-outlined v-else class="single-placeholder" />
|
||||||
|
<div class="zoom-hint"><zoom-in-outlined /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="single-tip">
|
||||||
|
<check-circle-filled />
|
||||||
|
<span>AI 识别到 1 个角色,将作为绘本主角</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 多角色:网格选择 -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="result-tip">
|
<div class="result-tip">
|
||||||
<span class="result-icon">🎉</span>
|
<check-circle-filled class="result-icon" />
|
||||||
<span>发现 <strong>{{ characters.length }}</strong> 个角色!点击选择绘本主角</span>
|
<span>AI 识别到 <strong>{{ characters.length }}</strong> 个角色,选一个作为主角</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="char-list">
|
<div class="char-grid">
|
||||||
<div
|
<div
|
||||||
v-for="c in characters"
|
v-for="c in characters"
|
||||||
:key="c.charId"
|
:key="c.charId"
|
||||||
@ -38,50 +59,58 @@
|
|||||||
:class="{ selected: selected === c.charId }"
|
:class="{ selected: selected === c.charId }"
|
||||||
@click="selected = c.charId"
|
@click="selected = c.charId"
|
||||||
>
|
>
|
||||||
<!-- 选中星星装饰 -->
|
<!-- 推荐角标 -->
|
||||||
<div v-if="selected === c.charId" class="selected-stars">
|
<div v-if="c.type === 'HERO'" class="hero-badge">
|
||||||
<span class="star s1">⭐</span>
|
<crown-filled />
|
||||||
<span class="star s2">✨</span>
|
<span>推荐</span>
|
||||||
<span class="star s3">⭐</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 选中角标 -->
|
||||||
<div class="char-avatar" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
|
<div v-if="selected === c.charId" class="check-badge">
|
||||||
|
<check-outlined />
|
||||||
|
</div>
|
||||||
|
<!-- 头像 -->
|
||||||
|
<div class="char-img-wrap" @click.stop="c.originalCropUrl && (previewImg = c.originalCropUrl)">
|
||||||
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
|
<img v-if="c.originalCropUrl" :src="c.originalCropUrl" class="char-img" />
|
||||||
<div v-else class="char-placeholder">🎭</div>
|
<user-outlined v-else class="char-placeholder" />
|
||||||
</div>
|
<div class="zoom-hint"><zoom-in-outlined /></div>
|
||||||
<div class="char-info">
|
|
||||||
<div class="char-name-row">
|
|
||||||
<span class="char-name">{{ c.name }}</span>
|
|
||||||
<span v-if="c.type === 'HERO'" class="hero-badge">⭐ 推荐主角</span>
|
|
||||||
</div>
|
|
||||||
<div class="char-hint">点击头像可放大查看</div>
|
|
||||||
</div>
|
|
||||||
<div class="check-badge" :class="{ checked: selected === c.charId }">
|
|
||||||
<span v-if="selected === c.charId">✓</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 图片预览 -->
|
|
||||||
<Transition name="fade">
|
|
||||||
<div v-if="previewImg" class="preview-overlay" @click="previewImg = ''">
|
|
||||||
<img :src="previewImg" class="preview-full-img" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- 图片预览 -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="previewImg" class="preview-overlay" @click="previewImg = ''">
|
||||||
|
<img :src="previewImg" class="preview-full-img" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-bottom">
|
<div class="page-bottom">
|
||||||
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
|
<button class="btn-primary next-btn" :disabled="!canNext" @click="goNext">
|
||||||
确定主角,选画风 →
|
<span>{{ nextLabel }}</span>
|
||||||
|
<arrow-right-outlined />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
LoadingOutlined,
|
||||||
|
FrownOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
UserOutlined,
|
||||||
|
CrownFilled,
|
||||||
|
CheckOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
ZoomInOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { extractCharacters } from '@/api/aicreate'
|
import { extractCharacters } from '@/api/aicreate'
|
||||||
@ -94,12 +123,28 @@ const characters = ref<any[]>([])
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const previewImg = ref('')
|
const previewImg = ref('')
|
||||||
|
|
||||||
|
const subtitle = computed(() => {
|
||||||
|
if (loading.value) return 'AI 正在识别画中的角色'
|
||||||
|
if (error.value) return ''
|
||||||
|
if (characters.value.length === 0) return ''
|
||||||
|
if (characters.value.length === 1) return 'AI 识别出的角色形象'
|
||||||
|
return 'AI 识别出多个角色,选一个作为主角'
|
||||||
|
})
|
||||||
|
|
||||||
|
const canNext = computed(() => {
|
||||||
|
if (characters.value.length === 1) return true
|
||||||
|
return !!selected.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const nextLabel = computed(() => {
|
||||||
|
if (characters.value.length === 1) return '使用此角色,编排故事'
|
||||||
|
return '确定主角,编排故事'
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (store.characters && store.characters.length > 0) {
|
if (store.characters && store.characters.length > 0) {
|
||||||
characters.value = store.characters
|
characters.value = store.characters
|
||||||
// 自动选中推荐主角
|
autoSelect()
|
||||||
const hero = characters.value.find(c => c.type === 'HERO')
|
|
||||||
if (hero) selected.value = hero.charId
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -117,12 +162,11 @@ onMounted(async () => {
|
|||||||
type: c.charType || c.type || 'SIDEKICK'
|
type: c.charType || c.type || 'SIDEKICK'
|
||||||
}))
|
}))
|
||||||
if (characters.value.length === 0) {
|
if (characters.value.length === 0) {
|
||||||
error.value = 'AI未识别到角色,请更换图片重试'
|
error.value = 'AI 未识别到角色,请更换图片重试'
|
||||||
}
|
}
|
||||||
store.extractId = data.extractId || ''
|
store.extractId = data.extractId || ''
|
||||||
store.characters = characters.value
|
store.characters = characters.value
|
||||||
const hero = characters.value.find(c => c.type === 'HERO')
|
autoSelect()
|
||||||
if (hero) selected.value = hero.charId
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = '角色识别失败:' + (e.message || '请检查网络')
|
error.value = '角色识别失败:' + (e.message || '请检查网络')
|
||||||
} finally {
|
} finally {
|
||||||
@ -130,233 +174,358 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function autoSelect() {
|
||||||
|
if (characters.value.length === 1) {
|
||||||
|
selected.value = characters.value[0].charId
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hero = characters.value.find(c => c.type === 'HERO')
|
||||||
|
if (hero) selected.value = hero.charId
|
||||||
|
}
|
||||||
|
|
||||||
|
const regenMock = (count: number) => {
|
||||||
|
store.fillMockData(count)
|
||||||
|
characters.value = store.characters
|
||||||
|
selected.value = null
|
||||||
|
error.value = ''
|
||||||
|
loading.value = false
|
||||||
|
autoSelect()
|
||||||
|
}
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
store.selectedCharacter = characters.value.find(c => c.charId === selected.value)
|
// 单角色直接用第一个,多角色用 selected
|
||||||
router.push('/p/create/style')
|
const target = characters.value.length === 1
|
||||||
|
? characters.value[0]
|
||||||
|
: characters.value.find(c => c.charId === selected.value)
|
||||||
|
store.selectedCharacter = target
|
||||||
|
router.push('/p/create/story')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.char-page {
|
.char-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, #F0F4FF 0%, #F5F0FF 40%, #FFF5F8 70%, #FFFDF7 100%);
|
background: var(--ai-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.content { flex: 1; padding: 16px 20px; display: flex; flex-direction: column; }
|
|
||||||
|
|
||||||
// Loading
|
/* ---------- 开发模式切换器 ---------- */
|
||||||
|
.dev-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
border: 1px dashed rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dev-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dev-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
color: var(--ai-primary);
|
||||||
|
}
|
||||||
|
&.active {
|
||||||
|
background: var(--ai-gradient);
|
||||||
|
border-color: transparent;
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 加载状态 ---------- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 60px 0;
|
||||||
}
|
}
|
||||||
.loading-emojis {
|
.loading-spinner {
|
||||||
display: flex;
|
font-size: 44px;
|
||||||
gap: 16px;
|
color: var(--ai-primary);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
.loading-emoji {
|
.loading-title {
|
||||||
font-size: 40px;
|
font-size: 16px;
|
||||||
display: inline-block;
|
font-weight: 700;
|
||||||
animation: emojiPop 1.8s ease-in-out infinite;
|
color: var(--ai-text);
|
||||||
&.e1 { animation-delay: 0s; }
|
|
||||||
&.e2 { animation-delay: 0.4s; }
|
|
||||||
&.e3 { animation-delay: 0.8s; }
|
|
||||||
}
|
}
|
||||||
@keyframes emojiPop {
|
.loading-sub {
|
||||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
|
font-size: 13px;
|
||||||
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
|
color: var(--ai-text-sub);
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.loading-title { font-size: 18px; font-weight: 700; margin-top: 4px; color: var(--ai-text); }
|
|
||||||
.loading-sub { font-size: 14px; color: var(--ai-text-sub); margin-top: 8px; }
|
|
||||||
.progress-bar { width: 220px; height: 6px; background: rgba(108,99,255,0.15); border-radius: 3px; margin-top: 20px; overflow: hidden; }
|
|
||||||
.progress-fill { width: 100%; height: 100%; background: linear-gradient(90deg, #6C63FF, #9B93FF); border-radius: 3px; animation: loading 2s ease-in-out infinite; }
|
|
||||||
@keyframes loading { 0%{transform:translateX(-100%)} 100%{transform:translateX(200%)} }
|
|
||||||
|
|
||||||
// Error
|
/* ---------- 错误状态 ---------- */
|
||||||
.error-state {
|
.error-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 0;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ai-text);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.back-btn {
|
||||||
|
max-width: 200px;
|
||||||
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
.error-emoji { font-size: 56px; }
|
|
||||||
.error-text { font-size: 16px; font-weight: 600; margin-top: 16px; color: var(--ai-text); text-align: center; }
|
|
||||||
|
|
||||||
// Result tip
|
/* ---------- 单角色大图 ---------- */
|
||||||
|
.single-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 12px 0 24px;
|
||||||
|
}
|
||||||
|
.single-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid var(--ai-primary);
|
||||||
|
border-radius: 26px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: 0 14px 36px rgba(99, 102, 241, 0.22);
|
||||||
|
}
|
||||||
|
.single-img-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(236, 72, 153, 0.08));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: zoom-in;
|
||||||
|
|
||||||
|
&:hover .zoom-hint { opacity: 1; }
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
}
|
||||||
|
.single-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.single-placeholder {
|
||||||
|
font-size: 72px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
}
|
||||||
|
.single-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ai-text);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
color: var(--ai-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 多角色结果提示 ---------- */
|
||||||
.result-tip {
|
.result-tip {
|
||||||
background: linear-gradient(135deg, #E8F5E9, #F1F8E9);
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.05));
|
||||||
border: 1.5px solid #C8E6C9;
|
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||||
border-radius: 18px;
|
border-radius: var(--ai-radius-sm);
|
||||||
padding: 14px 18px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
color: #2E7D32;
|
color: var(--ai-text);
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
}
|
|
||||||
.result-icon { font-size: 22px; }
|
|
||||||
|
|
||||||
// Character list
|
strong {
|
||||||
.char-list {
|
color: var(--ai-primary);
|
||||||
display: flex;
|
font-weight: 800;
|
||||||
flex-direction: column;
|
margin: 0 2px;
|
||||||
gap: 14px;
|
}
|
||||||
|
}
|
||||||
|
.result-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 多角色网格 ---------- */
|
||||||
|
.char-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-card {
|
.char-card {
|
||||||
background: rgba(255,255,255,0.95);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 16px 18px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 16px;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: visible;
|
background: #fff;
|
||||||
|
border-radius: var(--ai-radius);
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||||
|
|
||||||
&:active { transform: scale(0.98); }
|
&:hover {
|
||||||
|
border-color: rgba(99, 102, 241, 0.18);
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
border-color: var(--ai-primary);
|
border-color: var(--ai-primary);
|
||||||
background: linear-gradient(135deg, #FFF5F0 0%, #FFFAF7 50%, #FFF0F5 100%);
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.04), rgba(236, 72, 153, 0.03));
|
||||||
box-shadow: 0 6px 24px rgba(255, 107, 53, 0.2);
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.18);
|
||||||
transform: scale(1.02);
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.99);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选中星星装饰
|
.char-img-wrap {
|
||||||
.selected-stars {
|
position: relative;
|
||||||
position: absolute;
|
width: 100%;
|
||||||
top: -8px;
|
aspect-ratio: 1;
|
||||||
right: -4px;
|
border-radius: 14px;
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.star {
|
|
||||||
font-size: 14px;
|
|
||||||
animation: starPop 1.5s ease-in-out infinite;
|
|
||||||
&.s1 { animation-delay: 0s; }
|
|
||||||
&.s2 { animation-delay: 0.3s; font-size: 12px; }
|
|
||||||
&.s3 { animation-delay: 0.6s; }
|
|
||||||
}
|
|
||||||
@keyframes starPop {
|
|
||||||
0%, 100% { transform: scale(1); opacity: 0.6; }
|
|
||||||
50% { transform: scale(1.3); opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-avatar {
|
|
||||||
width: 88px;
|
|
||||||
height: 88px;
|
|
||||||
border-radius: 20px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: linear-gradient(135deg, #FFF5F0, #FFE8D6);
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.06));
|
||||||
flex-shrink: 0;
|
display: flex;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
cursor: zoom-in;
|
cursor: zoom-in;
|
||||||
transition: transform 0.2s;
|
|
||||||
|
|
||||||
&:active { transform: scale(0.95); }
|
&:hover .zoom-hint { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-img {
|
.char-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: contain;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.char-placeholder {
|
.char-placeholder {
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
}
|
|
||||||
|
|
||||||
.char-info {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-name-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-name {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--ai-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.char-hint {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check badge (right side)
|
.hero-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
z-index: 2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #ec4899);
|
||||||
|
color: #fff;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4);
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 9px; }
|
||||||
|
}
|
||||||
|
|
||||||
.check-badge {
|
.check-badge {
|
||||||
width: 32px;
|
position: absolute;
|
||||||
height: 32px;
|
top: 6px;
|
||||||
|
right: 6px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2.5px solid #E2E8F0;
|
background: var(--ai-gradient);
|
||||||
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
color: #fff;
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
flex-shrink: 0;
|
}
|
||||||
transition: all 0.3s;
|
|
||||||
|
|
||||||
&.checked {
|
.zoom-hint {
|
||||||
background: linear-gradient(135deg, #FF8C42, #FF6B35);
|
position: absolute;
|
||||||
border-color: var(--ai-primary);
|
right: 6px;
|
||||||
box-shadow: 0 3px 10px rgba(255, 107, 53, 0.4);
|
bottom: 6px;
|
||||||
transform: scale(1.1);
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(15, 12, 41, 0.55);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 底部按钮 ---------- */
|
||||||
|
.next-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 14px 0 !important;
|
||||||
|
border-radius: 28px !important;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-badge {
|
/* ---------- 图片预览 ---------- */
|
||||||
font-size: 11px;
|
|
||||||
background: linear-gradient(135deg, #FFD166, #FFBE4A);
|
|
||||||
color: #fff;
|
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 2px 6px rgba(255,209,102,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-area { margin-top: auto; padding-top: 20px; }
|
|
||||||
|
|
||||||
.next-btn {
|
|
||||||
font-size: 17px !important;
|
|
||||||
padding: 16px 0 !important;
|
|
||||||
border-radius: 28px !important;
|
|
||||||
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
|
|
||||||
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 图片预览
|
|
||||||
.preview-overlay {
|
.preview-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 999;
|
z-index: 999;
|
||||||
background: rgba(0,0,0,0.85);
|
background: rgba(15, 12, 41, 0.88);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -367,8 +536,14 @@ const goNext = () => {
|
|||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 8px 40px rgba(0,0,0,0.3);
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active {
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.2s; }
|
|
||||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,31 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="creating-page">
|
<div class="creating-page">
|
||||||
<!-- 飘浮装饰元素 -->
|
<!-- 开发模式:状态切换 -->
|
||||||
<div class="floating-deco d1">☁️</div>
|
<div v-if="isDev" class="dev-bar">
|
||||||
<div class="floating-deco d2">🌟</div>
|
<experiment-outlined />
|
||||||
<div class="floating-deco d3">🎨</div>
|
<span class="dev-label">Mock</span>
|
||||||
<div class="floating-deco d4">📖</div>
|
<button class="dev-btn" @click="enterMockProgress">进度</button>
|
||||||
<div class="floating-deco d5">✨</div>
|
<button class="dev-btn" @click="enterMockError">错误</button>
|
||||||
|
<button class="dev-btn" @click="goMockPreview">跳到预览</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 进度环 -->
|
<!-- 进度环 -->
|
||||||
<div class="ring-wrap">
|
<div class="ring-wrap">
|
||||||
<svg width="180" height="180" class="ring-svg">
|
<svg width="180" height="180" class="ring-svg">
|
||||||
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(255,255,255,0.5)" stroke-width="8" />
|
<defs>
|
||||||
<circle cx="90" cy="90" r="80" fill="none" stroke="var(--ai-primary)" stroke-width="8"
|
<linearGradient id="ringGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
:stroke-dasharray="502" :stroke-dashoffset="502 - (502 * progress / 100)"
|
<stop offset="0%" stop-color="#6366f1" />
|
||||||
stroke-linecap="round" class="ring-fill" />
|
<stop offset="50%" stop-color="#8b5cf6" />
|
||||||
|
<stop offset="100%" stop-color="#ec4899" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="90" cy="90" r="80" fill="none" stroke="rgba(99, 102, 241, 0.12)" stroke-width="8" />
|
||||||
|
<circle
|
||||||
|
cx="90"
|
||||||
|
cy="90"
|
||||||
|
r="80"
|
||||||
|
fill="none"
|
||||||
|
stroke="url(#ringGrad)"
|
||||||
|
stroke-width="8"
|
||||||
|
:stroke-dasharray="502"
|
||||||
|
:stroke-dashoffset="502 - (502 * progress / 100)"
|
||||||
|
stroke-linecap="round"
|
||||||
|
class="ring-fill"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="ring-center">
|
<div class="ring-center">
|
||||||
<div class="ring-pct">{{ progress }}%</div>
|
<div class="ring-pct">{{ progress }}%</div>
|
||||||
<div class="ring-label">创作进度</div>
|
<div class="ring-label">创作进度</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- 星星点缀 -->
|
|
||||||
<div class="ring-stars">
|
|
||||||
<span class="ring-star s1">✨</span>
|
|
||||||
<span class="ring-star s2">⭐</span>
|
|
||||||
<span class="ring-star s3">✨</span>
|
|
||||||
<span class="ring-star s4">⭐</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态文字 -->
|
<!-- 状态文字 -->
|
||||||
@ -38,17 +49,43 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 网络波动提示(非致命,轮询仍在继续) -->
|
<!-- 网络波动提示 -->
|
||||||
<div v-if="networkWarn && !error" class="network-warn">
|
<div v-if="networkWarn && !error" class="network-warn">
|
||||||
网络不太稳定,正在尝试重新连接{{ dots }}
|
<wifi-outlined />
|
||||||
|
<span>网络不太稳定,正在尝试重新连接{{ dots }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 错误重试 -->
|
<!-- 错误重试 -->
|
||||||
<div v-if="error" class="error-box">
|
<div v-if="error" class="error-box">
|
||||||
<div class="error-emoji">😔</div>
|
<frown-outlined class="error-icon" />
|
||||||
<div class="error-text">{{ error }}</div>
|
<div class="error-text">{{ error }}</div>
|
||||||
<button v-if="store.workId" class="btn-primary error-retry-btn" @click="resumePolling">恢复查询进度</button>
|
<div class="error-actions">
|
||||||
<button class="btn-primary error-retry-btn" :class="{ 'btn-outline': store.workId }" @click="retry">重新创作</button>
|
<button v-if="store.workId" class="btn-primary error-btn" @click="resumePolling">
|
||||||
|
恢复查询进度
|
||||||
|
</button>
|
||||||
|
<button class="btn-primary error-btn" :class="{ 'btn-outline': !!store.workId }" @click="retry">
|
||||||
|
重新创作
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 任务式说明 + 离开入口 -->
|
||||||
|
<div v-if="!error" class="task-hint">
|
||||||
|
<div class="task-hint-row">
|
||||||
|
<cloud-server-outlined class="task-icon" />
|
||||||
|
<span>AI 正在后台为你创作绘本,可以随时离开</span>
|
||||||
|
</div>
|
||||||
|
<button class="leave-btn" @click="leaveToWorks">
|
||||||
|
<inbox-outlined />
|
||||||
|
<span>去逛逛,看看其他作品</span>
|
||||||
|
</button>
|
||||||
|
<div class="task-hint-sub">完成后会自动出现在「作品库 · 草稿」</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="task-hint">
|
||||||
|
<button class="leave-btn" @click="leaveToWorks">
|
||||||
|
<inbox-outlined />
|
||||||
|
<span>去逛逛,看看其他作品</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -57,6 +94,13 @@
|
|||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Client } from '@stomp/stompjs'
|
import { Client } from '@stomp/stompjs'
|
||||||
|
import {
|
||||||
|
ExperimentOutlined,
|
||||||
|
FrownOutlined,
|
||||||
|
WifiOutlined,
|
||||||
|
CloudServerOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { createStory, getWorkDetail } from '@/api/aicreate'
|
import { createStory, getWorkDetail } from '@/api/aicreate'
|
||||||
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
||||||
@ -65,19 +109,21 @@ import config from '@/utils/aicreate/config'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
const progress = ref(0)
|
const progress = ref(0)
|
||||||
const stage = ref('准备中...')
|
const stage = ref('准备中…')
|
||||||
const dots = ref('')
|
const dots = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const networkWarn = ref(false)
|
const networkWarn = ref(false)
|
||||||
const currentTipIdx = ref(0)
|
const currentTipIdx = ref(0)
|
||||||
const creatingTips = [
|
const creatingTips = [
|
||||||
'AI 画师正在构思精彩故事...',
|
'AI 正在为你构思故事',
|
||||||
'魔法画笔正在绘制插画...',
|
'画笔正在绘制插画',
|
||||||
'故事世界正在成形...',
|
'故事世界正在成形',
|
||||||
'角色们正在准备登场...',
|
'角色们正在准备登场',
|
||||||
'色彩魔法正在施展中...',
|
'色彩正在调和',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let dotTimer: ReturnType<typeof setInterval> | null = null
|
let dotTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let tipTimer: ReturnType<typeof setInterval> | null = null
|
let tipTimer: ReturnType<typeof setInterval> | null = null
|
||||||
@ -93,35 +139,33 @@ function sanitizeError(msg: string | undefined): string {
|
|||||||
if (!msg) return '创作遇到问题,请重新尝试'
|
if (!msg) return '创作遇到问题,请重新尝试'
|
||||||
if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作'
|
if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作'
|
||||||
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试'
|
if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试'
|
||||||
if (msg.includes('额度')) return msg // 额度提示保留原文
|
if (msg.includes('额度')) return msg
|
||||||
if (msg.includes('重复') || msg.includes('DUPLICATE')) return '您有正在创作的作品,请等待完成'
|
if (msg.includes('重复') || msg.includes('DUPLICATE')) return '你有正在创作的作品,请等待完成'
|
||||||
if (msg.includes('timeout') || msg.includes('超时')) return '创作超时,请重新尝试'
|
if (msg.includes('timeout') || msg.includes('超时')) return '创作超时,请重新尝试'
|
||||||
if (msg.includes('OSS') || msg.includes('MiniMax')) return '服务暂时不可用,请稍后重试'
|
if (msg.includes('OSS') || msg.includes('MiniMax')) return '服务暂时不可用,请稍后重试'
|
||||||
if (msg.length > 50) return '创作遇到问题,请重新尝试'
|
if (msg.length > 50) return '创作遇到问题,请重新尝试'
|
||||||
return msg
|
return msg
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运维级消息转为用户友好消息(隐藏分组/模型/耗时等内部细节)
|
// 后端进度消息 → 用户友好阶段文案(不带任何 emoji 前缀)
|
||||||
function friendlyStage(pct: number, msg: string): string {
|
function friendlyStage(pct: number, msg: string): string {
|
||||||
if (!msg) return '创作中...'
|
if (!msg) return '创作中…'
|
||||||
// 按关键词匹配,优先级从高到低
|
if (msg.includes('创作完成')) return '绘本创作完成'
|
||||||
if (msg.includes('创作完成')) return '🎉 绘本创作完成!'
|
if (msg.includes('绘图完成') || msg.includes('绘制完成')) return '插画绘制完成'
|
||||||
if (msg.includes('绘图完成') || msg.includes('绘制完成')) return '🎨 插画绘制完成'
|
if (msg.includes('第') && msg.includes('组')) return '正在绘制插画…'
|
||||||
if (msg.includes('第') && msg.includes('组')) return '🎨 正在绘制插画...'
|
if (msg.includes('绘图') || msg.includes('绘制') || msg.includes('插画')) return '正在绘制插画…'
|
||||||
if (msg.includes('绘图') || msg.includes('绘制') || msg.includes('插画')) return '🎨 正在绘制插画...'
|
if (msg.includes('补生成')) return '正在绘制插画…'
|
||||||
if (msg.includes('补生成')) return '🎨 正在绘制插画...'
|
if (msg.includes('语音合成') || msg.includes('配音')) return '正在合成语音…'
|
||||||
if (msg.includes('语音合成') || msg.includes('配音')) return '🔊 正在合成语音...'
|
if (msg.includes('故事') && msg.includes('完成')) return '故事编写完成,开始绘图…'
|
||||||
if (msg.includes('故事') && msg.includes('完成')) return '📝 故事编写完成,开始绘图...'
|
if (msg.includes('故事') || msg.includes('创作故事')) return '正在编写故事…'
|
||||||
if (msg.includes('故事') || msg.includes('创作故事')) return '📝 正在编写故事...'
|
if (msg.includes('适配') || msg.includes('角色')) return '正在准备绘图…'
|
||||||
if (msg.includes('适配') || msg.includes('角色')) return '🎨 正在准备绘图...'
|
if (msg.includes('重试')) return '遇到小问题,正在重新创作…'
|
||||||
if (msg.includes('重试')) return '✨ 遇到小问题,正在重新创作...'
|
if (msg.includes('失败')) return '处理中,请稍候…'
|
||||||
if (msg.includes('失败')) return '⏳ 处理中,请稍候...'
|
if (pct < 20) return '正在提交创作…'
|
||||||
// 兜底:根据进度百分比返回友好提示,不展示原始技术消息
|
if (pct < 50) return '正在编写故事…'
|
||||||
if (pct < 20) return '✨ 正在提交创作...'
|
if (pct < 80) return '正在绘制插画…'
|
||||||
if (pct < 50) return '📝 正在编写故事...'
|
if (pct < 100) return '即将完成…'
|
||||||
if (pct < 80) return '🎨 正在绘制插画...'
|
return '绘本创作完成'
|
||||||
if (pct < 100) return '🔊 即将完成...'
|
|
||||||
return '🎉 绘本创作完成!'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 持久化 workId 到 localStorage,页面刷新后可恢复轮询
|
// 持久化 workId 到 localStorage,页面刷新后可恢复轮询
|
||||||
@ -150,7 +194,7 @@ const startWebSocket = (workId: string) => {
|
|||||||
|
|
||||||
stompClient = new Client({
|
stompClient = new Client({
|
||||||
brokerURL: wsUrl,
|
brokerURL: wsUrl,
|
||||||
reconnectDelay: 0, // 不自动重连,失败直接降级轮询
|
reconnectDelay: 0,
|
||||||
onConnect: () => {
|
onConnect: () => {
|
||||||
stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => {
|
stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => {
|
||||||
try {
|
try {
|
||||||
@ -160,7 +204,7 @@ const startWebSocket = (workId: string) => {
|
|||||||
|
|
||||||
if (data.progress >= 100) {
|
if (data.progress >= 100) {
|
||||||
progress.value = 100
|
progress.value = 100
|
||||||
stage.value = '🎉 绘本创作完成!'
|
stage.value = '绘本创作完成'
|
||||||
closeWebSocket()
|
closeWebSocket()
|
||||||
saveWorkId('')
|
saveWorkId('')
|
||||||
const route = getRouteByStatus(STATUS.COMPLETED, workId)
|
const route = getRouteByStatus(STATUS.COMPLETED, workId)
|
||||||
@ -204,7 +248,7 @@ const closeWebSocket = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── B2 轮询 (重进 / WebSocket 降级使用) ───
|
// ─── B2 轮询 ───
|
||||||
const startPolling = (workId: string) => {
|
const startPolling = (workId: string) => {
|
||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
consecutiveErrors = 0
|
consecutiveErrors = 0
|
||||||
@ -216,7 +260,6 @@ const startPolling = (workId: string) => {
|
|||||||
const work = detail.data
|
const work = detail.data
|
||||||
if (!work) return
|
if (!work) return
|
||||||
|
|
||||||
// 轮询成功,清除网络异常状态
|
|
||||||
if (consecutiveErrors > 0 || networkWarn.value) {
|
if (consecutiveErrors > 0 || networkWarn.value) {
|
||||||
consecutiveErrors = 0
|
consecutiveErrors = 0
|
||||||
networkWarn.value = false
|
networkWarn.value = false
|
||||||
@ -225,10 +268,9 @@ const startPolling = (workId: string) => {
|
|||||||
if (work.progress != null && work.progress > progress.value) progress.value = work.progress
|
if (work.progress != null && work.progress > progress.value) progress.value = work.progress
|
||||||
if (work.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage)
|
if (work.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage)
|
||||||
|
|
||||||
// 状态 >= COMPLETED(3) 表示创作已结束,根据具体状态导航
|
|
||||||
if (work.status >= STATUS.COMPLETED) {
|
if (work.status >= STATUS.COMPLETED) {
|
||||||
progress.value = 100
|
progress.value = 100
|
||||||
stage.value = '🎉 绘本创作完成!'
|
stage.value = '绘本创作完成'
|
||||||
clearInterval(pollTimer!)
|
clearInterval(pollTimer!)
|
||||||
pollTimer = null
|
pollTimer = null
|
||||||
saveWorkId('')
|
saveWorkId('')
|
||||||
@ -243,27 +285,24 @@ const startPolling = (workId: string) => {
|
|||||||
} catch {
|
} catch {
|
||||||
consecutiveErrors++
|
consecutiveErrors++
|
||||||
if (consecutiveErrors > MAX_POLL_ERRORS) {
|
if (consecutiveErrors > MAX_POLL_ERRORS) {
|
||||||
// 连续失败太多次,暂停轮询,让用户手动恢复
|
|
||||||
clearInterval(pollTimer!)
|
clearInterval(pollTimer!)
|
||||||
pollTimer = null
|
pollTimer = null
|
||||||
networkWarn.value = false
|
networkWarn.value = false
|
||||||
error.value = '网络连接异常,创作仍在后台进行中'
|
error.value = '网络连接异常,创作仍在后台进行中'
|
||||||
} else if (consecutiveErrors > MAX_SILENT_ERRORS) {
|
} else if (consecutiveErrors > MAX_SILENT_ERRORS) {
|
||||||
// 连续失败超过阈值,提示网络波动但继续轮询
|
|
||||||
networkWarn.value = true
|
networkWarn.value = true
|
||||||
}
|
}
|
||||||
// 前几次静默忽略,避免偶尔的网络抖动触发提示
|
|
||||||
}
|
}
|
||||||
}, 8000)
|
}, 8000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const startCreation = async () => {
|
const startCreation = async () => {
|
||||||
if (submitted) return // 防重复
|
if (submitted) return
|
||||||
submitted = true
|
submitted = true
|
||||||
|
|
||||||
error.value = ''
|
error.value = ''
|
||||||
progress.value = 5
|
progress.value = 5
|
||||||
stage.value = '📝 正在提交创作请求...'
|
stage.value = '正在提交创作请求…'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await createStory({
|
const res = await createStory({
|
||||||
@ -285,15 +324,13 @@ const startCreation = async () => {
|
|||||||
|
|
||||||
saveWorkId(workId)
|
saveWorkId(workId)
|
||||||
progress.value = 10
|
progress.value = 10
|
||||||
stage.value = '📝 故事构思中...'
|
stage.value = '故事构思中…'
|
||||||
// 首次提交:优先 WebSocket 实时推送
|
|
||||||
startWebSocket(workId)
|
startWebSocket(workId)
|
||||||
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 创作提交可能已入库(超时但服务端已接收)
|
|
||||||
if (store.workId) {
|
if (store.workId) {
|
||||||
progress.value = 10
|
progress.value = 10
|
||||||
stage.value = '📝 创作已提交到后台...'
|
stage.value = '创作已提交到后台…'
|
||||||
startPolling(store.workId)
|
startPolling(store.workId)
|
||||||
} else {
|
} else {
|
||||||
error.value = sanitizeError(e.message)
|
error.value = sanitizeError(e.message)
|
||||||
@ -306,16 +343,52 @@ const resumePolling = () => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
networkWarn.value = false
|
networkWarn.value = false
|
||||||
progress.value = 10
|
progress.value = 10
|
||||||
stage.value = '📝 正在查询创作进度...'
|
stage.value = '正在查询创作进度…'
|
||||||
startPolling(store.workId)
|
startPolling(store.workId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const retry = () => {
|
const retry = () => {
|
||||||
|
if (isDev && !store.imageUrl) {
|
||||||
|
enterMockProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
saveWorkId('')
|
saveWorkId('')
|
||||||
submitted = false
|
submitted = false
|
||||||
startCreation()
|
startCreation()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const leaveToWorks = () => {
|
||||||
|
// 关闭前端监听,但后端任务继续;store.workId 仍在 localStorage,下次进入 CreatingView 会恢复
|
||||||
|
closeWebSocket()
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
router.push('/p/works?tab=draft')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 开发模式:模拟状态 ───
|
||||||
|
const enterMockProgress = () => {
|
||||||
|
closeWebSocket()
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
submitted = true
|
||||||
|
error.value = ''
|
||||||
|
networkWarn.value = false
|
||||||
|
progress.value = 35
|
||||||
|
stage.value = '正在编写故事…'
|
||||||
|
}
|
||||||
|
|
||||||
|
const enterMockError = () => {
|
||||||
|
closeWebSocket()
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
submitted = false
|
||||||
|
error.value = '创作请求异常,请返回重新操作'
|
||||||
|
}
|
||||||
|
|
||||||
|
const goMockPreview = () => {
|
||||||
|
closeWebSocket()
|
||||||
|
if (pollTimer) { clearInterval(pollTimer); pollTimer = null }
|
||||||
|
store.fillMockWorkDetail()
|
||||||
|
router.push(`/p/create/preview/${store.workId}`)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
dotTimer = setInterval(() => {
|
dotTimer = setInterval(() => {
|
||||||
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
|
dots.value = dots.value.length >= 3 ? '' : dots.value + '.'
|
||||||
@ -325,7 +398,7 @@ onMounted(() => {
|
|||||||
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
|
currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length
|
||||||
}, 3500)
|
}, 3500)
|
||||||
|
|
||||||
// 恢复 workId:优先从URL参数(作品列表跳入),其次从localStorage(页面刷新)
|
// 恢复 workId
|
||||||
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
|
||||||
if (urlWorkId) {
|
if (urlWorkId) {
|
||||||
saveWorkId(urlWorkId)
|
saveWorkId(urlWorkId)
|
||||||
@ -333,11 +406,16 @@ onMounted(() => {
|
|||||||
restoreWorkId()
|
restoreWorkId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已有进行中的任务,恢复轮询而非重新提交
|
// 开发模式兜底:缺关键数据时直接进入模拟态,避免真实接口失败
|
||||||
|
if (isDev && !store.workId && (!store.imageUrl || !store.storyData)) {
|
||||||
|
enterMockProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (store.workId) {
|
if (store.workId) {
|
||||||
submitted = true
|
submitted = true
|
||||||
progress.value = 10
|
progress.value = 10
|
||||||
stage.value = '📝 正在查询创作进度...'
|
stage.value = '正在查询创作进度…'
|
||||||
startPolling(store.workId)
|
startPolling(store.workId)
|
||||||
} else {
|
} else {
|
||||||
startCreation()
|
startCreation()
|
||||||
@ -355,41 +433,61 @@ onUnmounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.creating-page {
|
.creating-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(160deg, #FFF8E1 0%, #FFF0F0 40%, #F0F8FF 100%);
|
background: var(--ai-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 24px;
|
padding: 24px 20px 32px;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 飘浮装饰元素 */
|
/* ---------- 开发模式切换器 ---------- */
|
||||||
.floating-deco {
|
.dev-bar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
font-size: 28px;
|
top: 16px;
|
||||||
opacity: 0.35;
|
left: 50%;
|
||||||
pointer-events: none;
|
transform: translateX(-50%);
|
||||||
animation: floatDeco 6s ease-in-out infinite;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
border: 1px dashed rgba(99, 102, 241, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
z-index: 5;
|
||||||
|
|
||||||
|
:deep(.anticon) {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.d1 { top: 8%; left: 10%; animation-delay: 0s; font-size: 36px; }
|
.dev-label { font-weight: 600; }
|
||||||
.d2 { top: 15%; right: 12%; animation-delay: 1.2s; font-size: 24px; }
|
.dev-btn {
|
||||||
.d3 { bottom: 20%; left: 8%; animation-delay: 2.4s; }
|
padding: 4px 10px;
|
||||||
.d4 { bottom: 12%; right: 15%; animation-delay: 0.8s; font-size: 32px; }
|
border-radius: 10px;
|
||||||
.d5 { top: 40%; right: 6%; animation-delay: 3.6s; font-size: 20px; }
|
background: #fff;
|
||||||
@keyframes floatDeco {
|
color: var(--ai-text-sub);
|
||||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
font-size: 11px;
|
||||||
25% { transform: translateY(-12px) rotate(5deg); }
|
font-weight: 600;
|
||||||
50% { transform: translateY(-6px) rotate(-3deg); }
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
75% { transform: translateY(-16px) rotate(3deg); }
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
color: var(--ai-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 进度环 ---------- */
|
||||||
.ring-wrap {
|
.ring-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 180px;
|
width: 180px;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
margin-bottom: 32px;
|
margin-bottom: 28px;
|
||||||
}
|
}
|
||||||
.ring-svg { transform: rotate(-90deg); }
|
.ring-svg { transform: rotate(-90deg); }
|
||||||
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
|
.ring-fill { transition: stroke-dashoffset 0.8s ease; }
|
||||||
@ -401,42 +499,41 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.ring-pct { font-size: 36px; font-weight: 900; color: var(--ai-primary); }
|
.ring-pct {
|
||||||
.ring-label { font-size: 12px; color: var(--ai-text-sub); }
|
font-size: 38px;
|
||||||
|
font-weight: 900;
|
||||||
/* 星星点缀在进度环外圈 */
|
background: var(--ai-gradient);
|
||||||
.ring-stars {
|
-webkit-background-clip: text;
|
||||||
position: absolute;
|
-webkit-text-fill-color: transparent;
|
||||||
inset: -12px;
|
background-clip: text;
|
||||||
pointer-events: none;
|
letter-spacing: -1px;
|
||||||
}
|
}
|
||||||
.ring-star {
|
.ring-label {
|
||||||
position: absolute;
|
font-size: 12px;
|
||||||
font-size: 14px;
|
color: var(--ai-text-sub);
|
||||||
animation: starTwinkle 2s ease-in-out infinite;
|
margin-top: 2px;
|
||||||
}
|
letter-spacing: 1px;
|
||||||
.ring-star.s1 { top: -4px; left: 50%; transform: translateX(-50%); animation-delay: 0s; }
|
|
||||||
.ring-star.s2 { bottom: -4px; left: 50%; transform: translateX(-50%); animation-delay: 0.5s; }
|
|
||||||
.ring-star.s3 { left: -4px; top: 50%; transform: translateY(-50%); animation-delay: 1s; }
|
|
||||||
.ring-star.s4 { right: -4px; top: 50%; transform: translateY(-50%); animation-delay: 1.5s; }
|
|
||||||
@keyframes starTwinkle {
|
|
||||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
||||||
50% { opacity: 1; transform: scale(1.2); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-text { font-size: 18px; font-weight: 700; text-align: center; }
|
/* ---------- 阶段文字 ---------- */
|
||||||
|
.stage-text {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ai-text);
|
||||||
|
}
|
||||||
|
|
||||||
/* 轮转 tips */
|
/* ---------- 轮转 tips ---------- */
|
||||||
.rotating-tips {
|
.rotating-tips {
|
||||||
margin-top: 14px;
|
margin-top: 12px;
|
||||||
height: 28px;
|
height: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.rotating-tip {
|
.rotating-tip {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: #B0876E;
|
color: var(--ai-text-sub);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
@ -445,22 +542,26 @@ onUnmounted(() => {
|
|||||||
.tip-fade-leave-active {
|
.tip-fade-leave-active {
|
||||||
transition: opacity 0.5s ease, transform 0.5s ease;
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
}
|
}
|
||||||
.tip-fade-enter-from {
|
.tip-fade-enter-from { opacity: 0; transform: translateY(8px); }
|
||||||
opacity: 0;
|
.tip-fade-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||||
transform: translateY(8px);
|
|
||||||
}
|
|
||||||
.tip-fade-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* ---------- 网络警告 ---------- */
|
||||||
.network-warn {
|
.network-warn {
|
||||||
margin-top: 12px;
|
margin-top: 14px;
|
||||||
font-size: 13px;
|
padding: 6px 14px;
|
||||||
color: #F59E0B;
|
border-radius: 12px;
|
||||||
text-align: center;
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
color: #d97706;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 13px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 错误状态 ---------- */
|
||||||
.error-box {
|
.error-box {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -468,13 +569,90 @@ onUnmounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.error-emoji { font-size: 40px; margin-bottom: 12px; }
|
.error-icon {
|
||||||
.error-text { color: #EF4444; font-size: 14px; font-weight: 600; line-height: 1.6; max-width: 260px; }
|
font-size: 44px;
|
||||||
.error-retry-btn { max-width: 200px; margin-top: 16px; }
|
|
||||||
.error-retry-btn.btn-outline {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
border: 1px solid var(--ai-border);
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.error-text {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.error-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
.error-btn {
|
||||||
|
font-size: 14px !important;
|
||||||
|
padding: 12px 0 !important;
|
||||||
|
border-radius: 24px !important;
|
||||||
|
}
|
||||||
|
.error-btn.btn-outline {
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--ai-primary) !important;
|
||||||
|
border: 1.5px solid rgba(99, 102, 241, 0.3) !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 任务式说明 + 离开按钮 ---------- */
|
||||||
|
.task-hint {
|
||||||
|
margin-top: 36px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.task-hint-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
color: var(--ai-text);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.task-icon {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-hint-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.leave-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 11px 22px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1.5px solid rgba(99, 102, 241, 0.3);
|
||||||
|
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 15px; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,15 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="edit-page page-fullscreen">
|
<div class="edit-page page-fullscreen">
|
||||||
<PageHeader title="编辑绘本信息" subtitle="补充信息让绘本更完整" :showBack="true" />
|
<PageHeader title="编辑绘本信息" subtitle="完善信息,作品将进入「未发布」可随时发布" :showBack="true" />
|
||||||
|
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<div style="font-size:36px">📖</div>
|
<loading-outlined class="loading-icon" spin />
|
||||||
<div style="color:var(--ai-text-sub);margin-top:8px">加载中...</div>
|
<div class="loading-text">加载中…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="content page-content">
|
<div v-else class="content page-content">
|
||||||
<!-- 封面预览 -->
|
<!-- 封面预览 -->
|
||||||
<div class="cover-preview card" v-if="coverUrl">
|
<div class="cover-preview" v-if="coverUrl">
|
||||||
<img :src="coverUrl" class="cover-img" />
|
<img :src="coverUrl" class="cover-img" />
|
||||||
<div class="cover-title-overlay">{{ store.workDetail?.title || '未命名' }}</div>
|
<div class="cover-title-overlay">{{ store.workDetail?.title || '未命名' }}</div>
|
||||||
</div>
|
</div>
|
||||||
@ -17,39 +17,77 @@
|
|||||||
<!-- 基本信息 -->
|
<!-- 基本信息 -->
|
||||||
<div class="card form-card">
|
<div class="card form-card">
|
||||||
<div class="field-item">
|
<div class="field-item">
|
||||||
<div class="field-label"><span>✍️</span> 作者署名 <span class="required-mark">必填</span></div>
|
<div class="field-label">
|
||||||
<input v-model="form.author" class="text-input" :class="{ 'input-error': authorError }" placeholder="如:宝宝的名字" maxlength="16" @input="authorError = ''" />
|
<edit-outlined />
|
||||||
<span class="char-count-inline">{{ form.author.length }}/16</span>
|
<span>作者署名</span>
|
||||||
<div v-if="authorError" class="field-error">{{ authorError }}</div>
|
<span class="required-mark">必填</span>
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
<div class="field-item">
|
v-model="form.author"
|
||||||
<div class="field-label"><span>📝</span> 副标题 <span class="optional-mark">选填</span></div>
|
class="text-input"
|
||||||
<input v-model="form.subtitle" class="text-input" placeholder="如:一个关于勇气的故事" maxlength="20" />
|
:class="{ 'input-error': authorError }"
|
||||||
<span class="char-count-inline">{{ form.subtitle.length }}/20</span>
|
placeholder="如:你的名字"
|
||||||
|
maxlength="16"
|
||||||
|
@input="authorError = ''"
|
||||||
|
/>
|
||||||
|
<div class="field-row-meta">
|
||||||
|
<span v-if="authorError" class="field-error">{{ authorError }}</span>
|
||||||
|
<span v-else class="field-error placeholder-error"> </span>
|
||||||
|
<span class="char-count">{{ form.author.length }}/16</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field-item">
|
<div class="field-item">
|
||||||
<div class="field-label">
|
<div class="field-label">
|
||||||
<span>📖</span> 绘本简介 <span class="optional-mark">选填</span>
|
<file-text-outlined />
|
||||||
<span class="char-count">{{ form.intro.length }}/250</span>
|
<span>副标题</span>
|
||||||
|
<span class="optional-mark">选填</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea v-model="form.intro" class="textarea-input" placeholder="简单介绍一下这个绘本的故事" maxlength="250" rows="3" />
|
<input
|
||||||
|
v-model="form.subtitle"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="如:一个关于勇气的故事"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
<div class="field-row-meta">
|
||||||
|
<span class="char-count">{{ form.subtitle.length }}/20</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-item">
|
||||||
|
<div class="field-label">
|
||||||
|
<book-outlined />
|
||||||
|
<span>绘本简介</span>
|
||||||
|
<span class="optional-mark">选填</span>
|
||||||
|
<span class="char-count char-count-right">{{ form.intro.length }}/250</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="form.intro"
|
||||||
|
class="textarea-input"
|
||||||
|
placeholder="简单介绍一下这个绘本的故事"
|
||||||
|
maxlength="250"
|
||||||
|
rows="3"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签 -->
|
<!-- 标签 -->
|
||||||
<div class="card form-card">
|
<div class="card form-card">
|
||||||
<div class="field-label" style="margin-bottom:12px"><span>🏷️</span> 绘本标签</div>
|
<div class="field-label" style="margin-bottom: 12px">
|
||||||
|
<tags-outlined />
|
||||||
|
<span>绘本标签</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tags-wrap">
|
<div class="tags-wrap">
|
||||||
<span v-for="(tag, i) in selectedTags" :key="'s'+i" class="tag selected-tag">
|
<span v-for="(tag, i) in selectedTags" :key="'s' + i" class="tag selected-tag">
|
||||||
{{ tag }}
|
<span>{{ tag }}</span>
|
||||||
<span v-if="selectedTags.length > 1" class="tag-remove" @click="removeTag(i)">×</span>
|
<close-outlined v-if="selectedTags.length > 1" class="tag-remove" @click="removeTag(i)" />
|
||||||
</span>
|
</span>
|
||||||
<!-- 添加标签(达到5个上限时隐藏) -->
|
<!-- 添加标签(达到5个上限时隐藏) -->
|
||||||
<template v-if="selectedTags.length < 5">
|
<template v-if="selectedTags.length < 5">
|
||||||
<span v-if="!addingTag" class="tag add-tag" @click="addingTag = true">+</span>
|
<span v-if="!addingTag" class="tag add-tag" @click="addingTag = true">
|
||||||
|
<plus-outlined />
|
||||||
|
</span>
|
||||||
<span v-else class="tag adding-tag">
|
<span v-else class="tag adding-tag">
|
||||||
<input
|
<input
|
||||||
ref="tagInput"
|
ref="tagInput"
|
||||||
@ -64,29 +102,59 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 推荐标签(达到5个上限时隐藏,只显示未选中的) -->
|
<!-- 推荐标签 -->
|
||||||
<div v-if="selectedTags.length < 5 && limitedPresets.length > 0" class="preset-tags">
|
<div v-if="selectedTags.length < 5 && limitedPresets.length > 0" class="preset-tags">
|
||||||
<span
|
<span
|
||||||
v-for="p in limitedPresets" :key="p"
|
v-for="p in limitedPresets"
|
||||||
|
:key="p"
|
||||||
class="tag preset-tag"
|
class="tag preset-tag"
|
||||||
@click="addPresetTag(p)"
|
@click="addPresetTag(p)"
|
||||||
>+ {{ p }}</span>
|
>
|
||||||
|
<plus-outlined />
|
||||||
|
<span>{{ p }}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部 -->
|
<!-- 底部三按钮 -->
|
||||||
<div v-if="!loading" class="page-bottom">
|
<div v-if="!loading" class="page-bottom">
|
||||||
<button class="btn-primary" :disabled="saving" @click="handleSave">
|
<div class="action-row">
|
||||||
{{ saving ? '保存中...' : '保存绘本 →' }}
|
<button class="action-btn draft-btn" :disabled="processing" @click="handleSave">
|
||||||
</button>
|
<inbox-outlined />
|
||||||
|
<span>保存</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn dubbing-btn" :disabled="processing" @click="handleGoDubbing">
|
||||||
|
<audio-outlined />
|
||||||
|
<span>去配音</span>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn publish-btn" :disabled="processing" @click="handlePublish">
|
||||||
|
<send-outlined />
|
||||||
|
<span>直接发布</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="action-hint">
|
||||||
|
保存后进入「未发布」可随时发布 · 配音是可选步骤 · 发布后进入审核
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
LoadingOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
AudioOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { getWorkDetail, updateWork } from '@/api/aicreate'
|
import { getWorkDetail, updateWork } from '@/api/aicreate'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
@ -97,8 +165,10 @@ const route = useRoute()
|
|||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
const workId = computed(() => route.params.workId || store.workId)
|
const workId = computed(() => route.params.workId || store.workId)
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const saving = ref(false)
|
const processing = ref(false)
|
||||||
const coverUrl = ref('')
|
const coverUrl = ref('')
|
||||||
|
|
||||||
const form = ref({ author: '', subtitle: '', intro: '' })
|
const form = ref({ author: '', subtitle: '', intro: '' })
|
||||||
@ -111,7 +181,6 @@ const PRESET_TAGS = ['冒险', '成长', '友谊', '魔法', '勇敢', '快乐',
|
|||||||
const availablePresets = computed(() =>
|
const availablePresets = computed(() =>
|
||||||
PRESET_TAGS.filter(t => !selectedTags.value.includes(t))
|
PRESET_TAGS.filter(t => !selectedTags.value.includes(t))
|
||||||
)
|
)
|
||||||
// 推荐标签最多显示到总标签数 5 个的剩余空位
|
|
||||||
const limitedPresets = computed(() => {
|
const limitedPresets = computed(() => {
|
||||||
const remaining = 5 - selectedTags.value.length
|
const remaining = 5 - selectedTags.value.length
|
||||||
if (remaining <= 0) return []
|
if (remaining <= 0) return []
|
||||||
@ -140,7 +209,22 @@ function confirmAddTag() {
|
|||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// 缓存不匹配当前 workId 时重新请求(防止上一个作品数据残留)
|
const wid = String(workId.value || '')
|
||||||
|
|
||||||
|
// dev 兜底:mock workId 直接用 store.workDetail
|
||||||
|
if (isDev && wid.startsWith('mock-')) {
|
||||||
|
if (!store.workDetail) store.fillMockWorkDetail()
|
||||||
|
const w = store.workDetail
|
||||||
|
form.value.author = w.author || ''
|
||||||
|
form.value.subtitle = w.subtitle || ''
|
||||||
|
form.value.intro = w.intro || ''
|
||||||
|
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
|
||||||
|
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 缓存不匹配当前 workId 时重新请求
|
||||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||||
store.workDetail = null
|
store.workDetail = null
|
||||||
const res = await getWorkDetail(workId.value)
|
const res = await getWorkDetail(workId.value)
|
||||||
@ -148,7 +232,6 @@ async function loadWork() {
|
|||||||
}
|
}
|
||||||
const w = store.workDetail
|
const w = store.workDetail
|
||||||
|
|
||||||
// 如果作品状态已超过 CATALOGED,重定向到对应页面
|
|
||||||
if (w.status > STATUS.CATALOGED) {
|
if (w.status > STATUS.CATALOGED) {
|
||||||
const nextRoute = getRouteByStatus(w.status, w.workId)
|
const nextRoute = getRouteByStatus(w.status, w.workId)
|
||||||
if (nextRoute) { router.replace(nextRoute); return }
|
if (nextRoute) { router.replace(nextRoute); return }
|
||||||
@ -168,14 +251,34 @@ async function loadWork() {
|
|||||||
|
|
||||||
const authorError = ref('')
|
const authorError = ref('')
|
||||||
|
|
||||||
async function handleSave() {
|
function validate() {
|
||||||
// 作者署名必填校验
|
|
||||||
if (!form.value.author.trim()) {
|
if (!form.value.author.trim()) {
|
||||||
authorError.value = '请填写作者署名'
|
authorError.value = '请填写作者署名'
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
authorError.value = ''
|
authorError.value = ''
|
||||||
saving.value = true
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存表单数据到后端,返回是否成功
|
||||||
|
* 不做跳转,由各 handler 决定下一步去哪
|
||||||
|
*/
|
||||||
|
async function saveFormToServer() {
|
||||||
|
const wid = String(workId.value || '')
|
||||||
|
|
||||||
|
// dev 兜底:mock workId 直接写回 store,跳过真实接口
|
||||||
|
if (isDev && wid.startsWith('mock-')) {
|
||||||
|
if (store.workDetail) {
|
||||||
|
store.workDetail.author = form.value.author.trim()
|
||||||
|
store.workDetail.subtitle = form.value.subtitle.trim()
|
||||||
|
store.workDetail.intro = form.value.intro.trim()
|
||||||
|
store.workDetail.tags = [...selectedTags.value]
|
||||||
|
}
|
||||||
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = { tags: selectedTags.value }
|
const data = { tags: selectedTags.value }
|
||||||
data.author = form.value.author.trim()
|
data.author = form.value.author.trim()
|
||||||
@ -184,30 +287,75 @@ async function handleSave() {
|
|||||||
|
|
||||||
await updateWork(workId.value, data)
|
await updateWork(workId.value, data)
|
||||||
|
|
||||||
// 更新缓存
|
|
||||||
if (store.workDetail) {
|
if (store.workDetail) {
|
||||||
if (data.author) store.workDetail.author = data.author
|
store.workDetail.author = data.author
|
||||||
if (data.subtitle) store.workDetail.subtitle = data.subtitle
|
if (data.subtitle) store.workDetail.subtitle = data.subtitle
|
||||||
if (data.intro) store.workDetail.intro = data.intro
|
if (data.intro) store.workDetail.intro = data.intro
|
||||||
store.workDetail.tags = [...selectedTags.value]
|
store.workDetail.tags = [...selectedTags.value]
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
// C1 保存后进入配音
|
|
||||||
store.workDetail = null // 清除缓存
|
|
||||||
router.push(`/p/create/dubbing/${workId.value}`)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 容错:保存报错时检查实际状态,可能已经成功但重试导致CAS失败
|
// 容错:保存报错时检查实际状态,可能已经成功但重试导致 CAS 失败
|
||||||
try {
|
try {
|
||||||
const check = await getWorkDetail(workId.value)
|
const check = await getWorkDetail(workId.value)
|
||||||
if (check?.data?.status >= 4) {
|
if (check?.data?.status >= 4) return true
|
||||||
store.workDetail = null
|
|
||||||
router.push(`/p/create/dubbing/${workId.value}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
alert(e.message || '保存失败,请重试')
|
alert(e.message || '保存失败,请重试')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存(编目完成 → unpublished)→ 跳作品库未发布 tab */
|
||||||
|
async function handleSave() {
|
||||||
|
if (!validate()) return
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
if (await saveFormToServer()) {
|
||||||
|
store.workDetail = null
|
||||||
|
router.push('/p/works?tab=unpublished')
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 去配音 → 跳 DubbingView */
|
||||||
|
async function handleGoDubbing() {
|
||||||
|
if (!validate()) return
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
if (await saveFormToServer()) {
|
||||||
|
store.workDetail = null
|
||||||
|
router.push(`/p/create/dubbing/${workId.value}`)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发布作品 → 进入超管端待审核,跳作品库审核中 tab */
|
||||||
|
async function handlePublish() {
|
||||||
|
if (!validate()) return
|
||||||
|
processing.value = true
|
||||||
|
try {
|
||||||
|
if (!(await saveFormToServer())) return
|
||||||
|
|
||||||
|
const wid = String(workId.value || '')
|
||||||
|
|
||||||
|
// dev 兜底:mock workId 直接跳作品库
|
||||||
|
if (isDev && wid.startsWith('mock-')) {
|
||||||
|
await new Promise(r => setTimeout(r, 300))
|
||||||
|
store.workDetail = null
|
||||||
|
router.push('/p/works?tab=pending_review')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: 真实发布接口需要本地 DB 作品 id(leai workId 到本地 id 的映射),
|
||||||
|
// 等后端联调 publicUserWorksApi.publish 完成后接入
|
||||||
|
store.workDetail = null
|
||||||
|
router.push('/p/works?tab=pending_review')
|
||||||
|
} finally {
|
||||||
|
processing.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,94 +368,175 @@ onMounted(() => {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.edit-page {
|
.edit-page {
|
||||||
background: var(--ai-bg);
|
background: var(--ai-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 加载状态 ---------- */
|
||||||
.loading-state {
|
.loading-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 80px 0;
|
||||||
}
|
}
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 44px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.loading-text {
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 内容区 ---------- */
|
||||||
.content {
|
.content {
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 封面预览 ---------- */
|
||||||
.cover-preview {
|
.cover-preview {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 16px;
|
border-radius: var(--ai-radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 120px;
|
height: 130px;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.12);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
}
|
}
|
||||||
.cover-img {
|
.cover-img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.cover-title-overlay {
|
.cover-title-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
padding: 10px 16px;
|
padding: 16px 18px 12px;
|
||||||
background: linear-gradient(transparent, rgba(0,0,0,0.6));
|
background: linear-gradient(transparent, rgba(15, 12, 41, 0.75));
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 17px;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-card { padding: 20px; }
|
/* ---------- 表单卡片 ---------- */
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
|
border-radius: var(--ai-radius);
|
||||||
|
padding: 18px;
|
||||||
|
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
&:last-child { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
.field-item { margin-bottom: 16px; &:last-child { margin-bottom: 0; } }
|
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text);
|
||||||
margin-bottom: 6px;
|
margin-bottom: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
font-weight: 600;
|
||||||
.required-mark { color: var(--ai-primary); font-size: 11px; }
|
|
||||||
.optional-mark { color: #94A3B8; font-size: 11px; }
|
|
||||||
.required-mark { color: #EF4444; font-size: 11px; font-weight: 600; }
|
|
||||||
.input-error { border-color: #EF4444 !important; }
|
|
||||||
.field-error { color: #EF4444; font-size: 12px; margin-top: 4px; }
|
|
||||||
.char-count { margin-left: auto; font-size: 11px; color: #94A3B8; }
|
|
||||||
.char-count-inline { display: block; text-align: right; font-size: 11px; color: #94A3B8; margin-top: 2px; }
|
|
||||||
|
|
||||||
.text-input {
|
:deep(.anticon) {
|
||||||
width: 100%;
|
font-size: 14px;
|
||||||
border: none;
|
color: var(--ai-primary);
|
||||||
background: #F8F7F4;
|
}
|
||||||
border-radius: var(--ai-radius-sm);
|
|
||||||
padding: 14px 16px;
|
|
||||||
font-size: 16px;
|
|
||||||
outline: none;
|
|
||||||
color: var(--ai-text);
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: box-shadow 0.2s;
|
|
||||||
|
|
||||||
&:focus { box-shadow: 0 0 0 2px var(--ai-primary); }
|
|
||||||
&.input-error { box-shadow: 0 0 0 2px #EF4444; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 必填 / 选填标签 ---------- */
|
||||||
|
.required-mark {
|
||||||
|
color: var(--ai-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.optional-mark {
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(107, 114, 128, 0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 输入框 ---------- */
|
||||||
|
.text-input,
|
||||||
.textarea-input {
|
.textarea-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: 1.5px solid var(--ai-border);
|
border: 1.5px solid rgba(99, 102, 241, 0.12);
|
||||||
background: #FAFAF8;
|
background: rgba(99, 102, 241, 0.04);
|
||||||
border-radius: var(--ai-radius-sm);
|
border-radius: 12px;
|
||||||
padding: 12px 14px;
|
padding: 13px 14px;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
resize: none;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: border 0.3s;
|
font-weight: 500;
|
||||||
&:focus { border-color: var(--ai-primary); }
|
transition: all 0.2s;
|
||||||
}
|
|
||||||
.error-text { color: #EF4444; font-size: 12px; margin-top: 4px; }
|
|
||||||
|
|
||||||
|
&::placeholder { color: #b8b6c4; }
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.textarea-input {
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-error {
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
&:focus {
|
||||||
|
border-color: #ef4444 !important;
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.1) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 字段下方元信息行 ---------- */
|
||||||
|
.field-row-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 6px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.placeholder-error { visibility: hidden; }
|
||||||
|
.char-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.char-count-right {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 标签区 ---------- */
|
||||||
.tags-wrap {
|
.tags-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@ -318,42 +547,52 @@ onMounted(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 6px 14px;
|
padding: 6px 12px;
|
||||||
border-radius: 20px;
|
border-radius: 18px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
}
|
|
||||||
.selected-tag {
|
|
||||||
background: var(--ai-primary-light);
|
|
||||||
color: var(--ai-primary);
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.selected-tag {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--ai-primary);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.2);
|
||||||
|
}
|
||||||
.tag-remove {
|
.tag-remove {
|
||||||
font-size: 15px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
|
margin-left: 2px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
&:hover { opacity: 1; }
|
&:hover { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-tag {
|
.add-tag {
|
||||||
background: var(--ai-border);
|
background: rgba(99, 102, 241, 0.06);
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
font-size: 16px;
|
border: 1px dashed rgba(99, 102, 241, 0.3);
|
||||||
font-weight: 700;
|
padding: 6px 14px;
|
||||||
padding: 6px 16px;
|
:deep(.anticon) { font-size: 12px; }
|
||||||
|
&:hover {
|
||||||
|
color: var(--ai-primary);
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.adding-tag {
|
.adding-tag {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1.5px solid var(--ai-primary);
|
border: 1.5px solid var(--ai-primary);
|
||||||
padding: 4px 8px;
|
padding: 4px 10px;
|
||||||
}
|
}
|
||||||
.tag-input {
|
.tag-input {
|
||||||
border: none;
|
border: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
width: 60px;
|
width: 70px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.preset-tags {
|
.preset-tags {
|
||||||
@ -362,10 +601,94 @@ onMounted(() => {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.preset-tag {
|
.preset-tag {
|
||||||
background: #F0EDE8;
|
background: rgba(99, 102, 241, 0.04);
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.1);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 4px 12px;
|
padding: 4px 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 10px; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--ai-primary);
|
||||||
|
border-color: rgba(99, 102, 241, 0.25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 底部三按钮 ---------- */
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 13px 8px;
|
||||||
|
border-radius: 22px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 14px; }
|
||||||
|
|
||||||
|
&:active { transform: scale(0.97); }
|
||||||
|
&:disabled { opacity: 0.4; pointer-events: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保存草稿:最弱,透明 + 紫色淡边 */
|
||||||
|
.draft-btn {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
border: 1.5px solid rgba(99, 102, 241, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ai-primary);
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 去配音:中等,白底紫边 */
|
||||||
|
.dubbing-btn {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
border: 1.5px solid rgba(99, 102, 241, 0.4);
|
||||||
|
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 发布作品:主操作,紫粉渐变 */
|
||||||
|
.publish-btn {
|
||||||
|
background: var(--ai-gradient);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.32);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-hint {
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -3,64 +3,75 @@
|
|||||||
<!-- 顶部 -->
|
<!-- 顶部 -->
|
||||||
<div class="top-bar">
|
<div class="top-bar">
|
||||||
<div class="top-title">绘本预览</div>
|
<div class="top-title">绘本预览</div>
|
||||||
<div class="top-sub">你的绘本已生成!</div>
|
<div class="top-sub">你的绘本已生成,翻一翻看看吧</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div v-if="loading" class="loading-state">
|
<div v-if="loading" class="loading-state">
|
||||||
<div class="loading-icon">📖</div>
|
<loading-outlined class="loading-icon" spin />
|
||||||
<div class="loading-text">加载中...</div>
|
<div class="loading-text">加载中…</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 加载失败 -->
|
<!-- 加载失败 -->
|
||||||
<div v-else-if="error" class="error-state card">
|
<div v-else-if="error" class="error-state">
|
||||||
<div style="font-size:36px;margin-bottom:12px">😥</div>
|
<frown-outlined class="error-icon" />
|
||||||
<div style="font-weight:600;margin-bottom:8px">加载失败</div>
|
<div class="error-title">加载失败</div>
|
||||||
<div style="color:var(--ai-text-sub);font-size:13px;margin-bottom:16px">{{ error }}</div>
|
<div class="error-msg">{{ error }}</div>
|
||||||
<button class="btn-primary" style="width:auto;padding:10px 32px" @click="loadWork">重试</button>
|
<button class="btn-primary error-btn" @click="loadWork">重试</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主内容 -->
|
<!-- 主内容 -->
|
||||||
<template v-else-if="pages.length">
|
<template v-else-if="pages.length">
|
||||||
<div class="content page-content" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
<div class="content page-content" @touchstart="onTouchStart" @touchend="onTouchEnd">
|
||||||
<!-- 1. 图片区:16:9 完整展示,不裁切 -->
|
<!-- 1. 图片区:16:9 完整展示 -->
|
||||||
<div class="image-section">
|
<div class="image-section">
|
||||||
<div class="page-badge">{{ pageBadge }}</div>
|
<div class="page-badge">{{ pageBadge }}</div>
|
||||||
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" />
|
<img v-if="currentPage.imageUrl" :src="currentPage.imageUrl" class="page-image" :alt="pageBadge" />
|
||||||
<div v-else class="page-image placeholder-img">📖</div>
|
<div v-else class="page-image placeholder-img">
|
||||||
|
<picture-outlined />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 2. 故事文字区 -->
|
<!-- 2. 故事文字区 -->
|
||||||
<div class="text-section">
|
<div class="text-section">
|
||||||
<div class="text-deco">"</div>
|
<div class="story-text">{{ currentPage.text || '(封面)' }}</div>
|
||||||
<div class="story-text">{{ currentPage.text || '' }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 3. 翻页 -->
|
<!-- 3. 翻页 -->
|
||||||
<div class="nav-row">
|
<div class="nav-row">
|
||||||
<button class="nav-btn" :class="{ invisible: idx <= 0 }" @click="prev">‹</button>
|
<button class="nav-btn" :class="{ invisible: idx <= 0 }" @click="prev" aria-label="上一页">
|
||||||
|
<left-outlined />
|
||||||
|
</button>
|
||||||
<span class="page-counter">{{ idx + 1 }} / {{ pages.length }}</span>
|
<span class="page-counter">{{ idx + 1 }} / {{ pages.length }}</span>
|
||||||
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next">›</button>
|
<button class="nav-btn" :class="{ invisible: idx >= pages.length - 1 }" @click="next" aria-label="下一页">
|
||||||
|
<right-outlined />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 4. 横版卡片网格(2列) -->
|
<!-- 4. 缩略图横向胶卷 -->
|
||||||
<div class="thumb-grid">
|
<div class="thumb-strip" ref="thumbStrip">
|
||||||
<div v-for="(p, i) in pages" :key="i"
|
<div
|
||||||
class="thumb-card" :class="{ active: i === idx }"
|
v-for="(p, i) in pages"
|
||||||
@click="idx = i">
|
:key="i"
|
||||||
<div class="thumb-card-img-wrap">
|
class="thumb-item"
|
||||||
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-card-img" />
|
:class="{ active: i === idx }"
|
||||||
<div v-else class="thumb-card-placeholder">📖</div>
|
@click="idx = i"
|
||||||
<div class="thumb-card-badge">{{ i === 0 ? '封面' : 'P' + i }}</div>
|
>
|
||||||
|
<img v-if="p.imageUrl" :src="p.imageUrl" class="thumb-img" />
|
||||||
|
<div v-else class="thumb-placeholder">
|
||||||
|
<picture-outlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="thumb-card-text">{{ p.text ? (p.text.length > 16 ? p.text.slice(0,16) + '...' : p.text) : '' }}</div>
|
<div class="thumb-num">{{ i === 0 ? '封面' : i }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 底部按钮 -->
|
<!-- 底部按钮 -->
|
||||||
<div class="page-bottom">
|
<div class="page-bottom">
|
||||||
<button class="btn-primary" @click="goEditInfo">下一步: 编辑绘本信息 →</button>
|
<button class="btn-primary next-btn" @click="goEditInfo">
|
||||||
|
<span>下一步:编辑绘本信息</span>
|
||||||
|
<arrow-right-outlined />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -69,6 +80,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import {
|
||||||
|
LoadingOutlined,
|
||||||
|
FrownOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import { getWorkDetail } from '@/api/aicreate'
|
import { getWorkDetail } from '@/api/aicreate'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
||||||
@ -77,32 +96,31 @@ const router = useRouter()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const pages = ref([])
|
const pages = ref<any[]>([])
|
||||||
const idx = ref(0)
|
const idx = ref(0)
|
||||||
const thumbStrip = ref(null)
|
const thumbStrip = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
// Touch swipe
|
// 触屏滑动翻页
|
||||||
let touchX = 0
|
let touchX = 0
|
||||||
const onTouchStart = (e) => { touchX = e.touches[0].clientX }
|
const onTouchStart = (e: TouchEvent) => { touchX = e.touches[0].clientX }
|
||||||
const onTouchEnd = (e) => {
|
const onTouchEnd = (e: TouchEvent) => {
|
||||||
const dx = e.changedTouches[0].clientX - touchX
|
const dx = e.changedTouches[0].clientX - touchX
|
||||||
if (Math.abs(dx) > 50) dx > 0 ? prev() : next()
|
if (Math.abs(dx) > 50) dx > 0 ? prev() : next()
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentPage = computed(() => pages.value[idx.value] || {})
|
const currentPage = computed(() => pages.value[idx.value] || {})
|
||||||
const pageBadge = computed(() => {
|
const pageBadge = computed(() => idx.value === 0 ? '封面' : `P${idx.value}`)
|
||||||
if (idx.value === 0) return '封面'
|
|
||||||
return `P${idx.value}`
|
|
||||||
})
|
|
||||||
|
|
||||||
function prev() { if (idx.value > 0) { idx.value--; scrollThumbIntoView(idx.value) } }
|
function prev() { if (idx.value > 0) { idx.value--; scrollThumbIntoView(idx.value) } }
|
||||||
function next() { if (idx.value < pages.value.length - 1) { idx.value++; scrollThumbIntoView(idx.value) } }
|
function next() { if (idx.value < pages.value.length - 1) { idx.value++; scrollThumbIntoView(idx.value) } }
|
||||||
|
|
||||||
function scrollThumbIntoView(i) {
|
function scrollThumbIntoView(i: number) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const el = thumbStrip.value?.children[i]
|
const el = thumbStrip.value?.children[i] as HTMLElement | undefined
|
||||||
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
if (el) el.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -112,6 +130,22 @@ const workId = computed(() => route.params.workId || store.workId)
|
|||||||
async function loadWork() {
|
async function loadWork() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
|
// dev 兜底:mock workId 或 dev 模式无 workId 时使用 store.workDetail
|
||||||
|
const wid = String(workId.value || '')
|
||||||
|
if (isDev && (wid.startsWith('mock-') || !wid)) {
|
||||||
|
if (!store.workDetail) store.fillMockWorkDetail()
|
||||||
|
const work = store.workDetail
|
||||||
|
pages.value = (work.pageList || []).map((p: any) => ({
|
||||||
|
pageNum: p.pageNum,
|
||||||
|
text: p.text,
|
||||||
|
imageUrl: p.imageUrl,
|
||||||
|
audioUrl: p.audioUrl,
|
||||||
|
}))
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await getWorkDetail(workId.value)
|
const res = await getWorkDetail(workId.value)
|
||||||
const work = res
|
const work = res
|
||||||
@ -124,13 +158,13 @@ async function loadWork() {
|
|||||||
if (nextRoute) { router.replace(nextRoute); return }
|
if (nextRoute) { router.replace(nextRoute); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
pages.value = (work.pageList || []).map(p => ({
|
pages.value = (work.pageList || []).map((p: any) => ({
|
||||||
pageNum: p.pageNum,
|
pageNum: p.pageNum,
|
||||||
text: p.text,
|
text: p.text,
|
||||||
imageUrl: p.imageUrl,
|
imageUrl: p.imageUrl,
|
||||||
audioUrl: p.audioUrl
|
audioUrl: p.audioUrl,
|
||||||
}))
|
}))
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
error.value = e.message || '加载失败'
|
error.value = e.message || '加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@ -147,186 +181,260 @@ onMounted(loadWork)
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.preview-page {
|
.preview-page {
|
||||||
background: var(--ai-bg);
|
background: var(--ai-bg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶部 ---------- */
|
||||||
.top-bar {
|
.top-bar {
|
||||||
background: var(--ai-gradient);
|
background: var(--ai-gradient);
|
||||||
padding: 20px 20px 16px;
|
padding: 22px 22px 18px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.18);
|
||||||
|
}
|
||||||
|
.top-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.top-sub {
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.92;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.top-title { font-size: 20px; font-weight: 800; }
|
|
||||||
.top-sub { font-size: 13px; opacity: 0.85; margin-top: 4px; }
|
|
||||||
|
|
||||||
.loading-state, .error-state {
|
/* ---------- 加载/错误状态 ---------- */
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 40px;
|
padding: 60px 24px;
|
||||||
|
}
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 44px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.loading-text {
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.error-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.error-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ai-text);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.error-msg {
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
.error-btn {
|
||||||
|
width: auto !important;
|
||||||
|
padding: 10px 32px !important;
|
||||||
|
font-size: 14px !important;
|
||||||
}
|
}
|
||||||
.loading-icon { font-size: 48px; animation: pulse 1.5s ease infinite; }
|
|
||||||
.loading-text { margin-top: 12px; color: var(--ai-text-sub); }
|
|
||||||
|
|
||||||
|
/* ---------- 主内容 ---------- */
|
||||||
.content {
|
.content {
|
||||||
padding: 10px 14px 14px;
|
padding: 16px 16px 14px;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 1. 图片区:16:9 完整展示 */
|
/* 图片区 */
|
||||||
.image-section {
|
.image-section {
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #1A1A1A;
|
background: #1e1b4b; /* 深紫黑底,电影画幅感 */
|
||||||
box-shadow: 0 6px 24px rgba(0,0,0,0.12);
|
box-shadow: 0 8px 28px rgba(99, 102, 241, 0.2);
|
||||||
}
|
}
|
||||||
.page-image {
|
.page-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: contain; /* 16:9 横图完整显示,不裁切 */
|
object-fit: contain;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: #1A1A1A; /* 上下留黑,像电影画幅 */
|
background: #1e1b4b;
|
||||||
}
|
}
|
||||||
.placeholder-img {
|
.placeholder-img {
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 48px;
|
font-size: 56px;
|
||||||
background: #F5F3EE;
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: #1e1b4b;
|
||||||
}
|
}
|
||||||
.page-badge {
|
.page-badge {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 12px;
|
||||||
left: 10px;
|
left: 12px;
|
||||||
background: linear-gradient(135deg, rgba(255,107,53,0.9), rgba(255,140,66,0.9));
|
background: var(--ai-gradient);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 4px 14px;
|
padding: 4px 14px;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
box-shadow: 0 2px 8px rgba(255,107,53,0.3);
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2. 故事文字区 */
|
/* 故事文字区 */
|
||||||
.text-section {
|
.text-section {
|
||||||
background: rgba(255,255,255,0.92);
|
background: #fff;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
|
border-top: none;
|
||||||
border-radius: 0 0 18px 18px;
|
border-radius: 0 0 18px 18px;
|
||||||
margin-top: -8px; /* 与图片无缝衔接 */
|
margin-top: -8px;
|
||||||
padding: 18px 22px 16px;
|
padding: 18px 22px 16px;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.06);
|
||||||
min-height: 60px;
|
min-height: 64px;
|
||||||
}
|
|
||||||
.text-deco {
|
|
||||||
position: absolute;
|
|
||||||
top: 4px; left: 14px;
|
|
||||||
font-size: 36px; color: #FFD166; opacity: 0.25;
|
|
||||||
font-family: Georgia, serif; font-weight: 900; line-height: 1;
|
|
||||||
}
|
}
|
||||||
.story-text {
|
.story-text {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: #3D2E1E;
|
color: var(--ai-text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
padding-left: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3. 翻页行 */
|
/* 翻页 */
|
||||||
|
|
||||||
.nav-row {
|
.nav-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 20px;
|
gap: 22px;
|
||||||
|
margin-top: 18px;
|
||||||
}
|
}
|
||||||
.nav-btn {
|
.nav-btn {
|
||||||
width: 36px;
|
width: 38px;
|
||||||
height: 36px;
|
height: 38px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: 1px solid rgba(99, 102, 241, 0.18);
|
||||||
background: var(--ai-card);
|
background: #fff;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.1);
|
||||||
font-size: 18px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ai-primary);
|
color: var(--ai-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:active { transform: scale(0.9); }
|
&:hover {
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
background: rgba(99, 102, 241, 0.04);
|
||||||
|
}
|
||||||
|
&:active { transform: scale(0.92); }
|
||||||
&.invisible { opacity: 0; pointer-events: none; }
|
&.invisible { opacity: 0; pointer-events: none; }
|
||||||
}
|
}
|
||||||
.page-counter {
|
.page-counter {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4. 横版卡片网格 */
|
/* ---------- 横向胶卷缩略图 ---------- */
|
||||||
.thumb-grid {
|
.thumb-strip {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 4px 0;
|
padding: 18px 4px 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
scroll-snap-type: x proximity;
|
||||||
|
scroll-padding: 0 50%;
|
||||||
|
|
||||||
|
/* 隐藏滚动条 */
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
&::-webkit-scrollbar { display: none; }
|
||||||
}
|
}
|
||||||
.thumb-card {
|
|
||||||
border-radius: 14px;
|
.thumb-item {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 88px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: rgba(255,255,255,0.9);
|
|
||||||
box-shadow: 0 2px 10px rgba(0,0,0,0.06);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
border: 2px solid transparent;
|
||||||
border: 2.5px solid transparent;
|
background: #1e1b4b;
|
||||||
|
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.08);
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
scroll-snap-align: center;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
border-color: var(--ai-primary);
|
border-color: var(--ai-primary);
|
||||||
box-shadow: 0 3px 14px rgba(255,107,53,0.25);
|
transform: translateY(-3px) scale(1.06);
|
||||||
transform: scale(1.02);
|
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.36);
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.thumb-card-img-wrap {
|
|
||||||
position: relative;
|
.thumb-img {
|
||||||
aspect-ratio: 16 / 9;
|
|
||||||
overflow: hidden;
|
|
||||||
background: #F0EDE8;
|
|
||||||
}
|
|
||||||
.thumb-card-img {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
.thumb-card-placeholder {
|
|
||||||
|
.thumb-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 24px;
|
font-size: 22px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
.thumb-card-badge {
|
|
||||||
|
.thumb-num {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 4px; left: 4px;
|
bottom: 0;
|
||||||
background: rgba(0,0,0,0.5);
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 8px 4px 3px;
|
||||||
|
background: linear-gradient(to top, rgba(15, 12, 41, 0.85), transparent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
padding: 2px 8px;
|
text-align: center;
|
||||||
border-radius: 10px;
|
letter-spacing: 0.5px;
|
||||||
}
|
line-height: 1;
|
||||||
.thumb-card-text {
|
|
||||||
padding: 6px 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
color: #64748B;
|
|
||||||
line-height: 1.4;
|
|
||||||
min-height: 20px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 底部按钮 ---------- */
|
||||||
|
.next-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 14px 0 !important;
|
||||||
|
border-radius: 28px !important;
|
||||||
|
|
||||||
@keyframes pulse {
|
:deep(.anticon) { font-size: 16px; }
|
||||||
0%, 100% { transform: scale(1); }
|
|
||||||
50% { transform: scale(1.08); }
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,54 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="story-page page-fullscreen">
|
<div class="story-page page-fullscreen">
|
||||||
<PageHeader title="编写故事" subtitle="告诉AI你想要什么样的故事" :step="3" />
|
<PageHeader title="编写故事" subtitle="告诉 AI 你想要什么样的故事" :step="2" />
|
||||||
|
|
||||||
<div class="content page-content">
|
<div class="content page-content">
|
||||||
<!-- 绘本信息 -->
|
<!-- 绘本标题 -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-icon">📚</span>
|
<book-outlined class="section-icon" />
|
||||||
<span class="section-label">绘本信息</span>
|
<span class="section-label">绘本标题</span>
|
||||||
|
<span class="required-mark">必填</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-item">
|
<div class="input-wrap" :class="{ focus: bookTitleFocus }">
|
||||||
<div class="field-label">
|
<input
|
||||||
<span>✏️</span> 绘本标题
|
v-model="bookTitle"
|
||||||
<span class="required-mark">必填</span>
|
class="text-input"
|
||||||
</div>
|
placeholder="如:森林大冒险"
|
||||||
<div class="input-wrap" :class="{ focus: bookTitleFocus }">
|
maxlength="12"
|
||||||
<input v-model="bookTitle" class="text-input" placeholder="如:小璃的冒险"
|
@focus="bookTitleFocus = true"
|
||||||
maxlength="12" @focus="bookTitleFocus = true" @blur="bookTitleFocus = false" />
|
@blur="bookTitleFocus = false"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 主角名字 -->
|
<!-- 主角名字 -->
|
||||||
<div class="section-card">
|
<div class="section-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-icon">🐰</span>
|
<user-outlined class="section-icon" />
|
||||||
<span class="section-label">主角名字</span>
|
<span class="section-label">主角名字</span>
|
||||||
<span class="required-mark">必填</span>
|
<span class="required-mark">必填</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-wrap" :class="{ focus: heroNameFocus }">
|
<div class="hero-row">
|
||||||
<input v-model="heroName" class="text-input" placeholder="给主角起个名字吧~"
|
<div v-if="heroAvatar" class="hero-mini">
|
||||||
maxlength="10" @focus="heroNameFocus = true" @blur="heroNameFocus = false" />
|
<img :src="heroAvatar" alt="主角形象" />
|
||||||
|
</div>
|
||||||
|
<div class="input-wrap" :class="{ focus: heroNameFocus }">
|
||||||
|
<input
|
||||||
|
v-model="heroName"
|
||||||
|
class="text-input"
|
||||||
|
placeholder="为主角起个名字"
|
||||||
|
maxlength="10"
|
||||||
|
@focus="heroNameFocus = true"
|
||||||
|
@blur="heroNameFocus = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="heroAvatar" class="hero-hint">这是你画作中的角色形象</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 故事要素 -->
|
<!-- 故事要素 -->
|
||||||
<div class="section-card story-elements">
|
<div class="section-card">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<span class="section-icon">📖</span>
|
<edit-outlined class="section-icon" />
|
||||||
<span class="section-label">故事要素</span>
|
<span class="section-label">故事要素</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-for="(f, i) in fields" :key="i" class="field-item">
|
<div v-for="(f, i) in fields" :key="i" class="field-item">
|
||||||
<div class="field-label">
|
<div class="field-label">
|
||||||
<span class="field-emoji">{{ f.emoji }}</span>
|
|
||||||
<span>{{ f.label }}</span>
|
<span>{{ f.label }}</span>
|
||||||
<span v-if="f.required" class="required-mark">必填</span>
|
<span v-if="f.required" class="required-mark">必填</span>
|
||||||
<span v-else class="optional-mark">选填</span>
|
<span v-else class="optional-mark">选填</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="textarea-wrap" :class="{ focus: f.focused?.value }">
|
<div class="textarea-wrap" :class="{ focus: f.focused.value }">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="f.value.value"
|
v-model="f.value.value"
|
||||||
:placeholder="f.placeholder"
|
:placeholder="f.placeholder"
|
||||||
@ -61,20 +73,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-bottom">
|
<div class="page-bottom">
|
||||||
<button class="btn-primary create-btn" :disabled="!canSubmit" @click="goNext">
|
<button class="btn-primary create-btn" :disabled="!canSubmit" @click="goNext">
|
||||||
<span class="btn-rocket">🚀</span> 开始创作绘本
|
<rocket-outlined />
|
||||||
|
<span>开始创作绘本</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="time-hint" style="text-align:center;margin-top:6px;font-size:12px;color:var(--ai-text-sub)">✨ 创作预计需要 1-3 分钟</div>
|
<div class="time-hint">
|
||||||
|
<clock-circle-outlined />
|
||||||
|
<span>创作预计需要 1-3 分钟</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onActivated } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
BookOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
|
|
||||||
@ -89,17 +112,23 @@ const whatHappens = ref('')
|
|||||||
const bookTitleFocus = ref(false)
|
const bookTitleFocus = ref(false)
|
||||||
const heroNameFocus = ref(false)
|
const heroNameFocus = ref(false)
|
||||||
|
|
||||||
|
const heroAvatar = computed(() => store.selectedCharacter?.originalCropUrl || '')
|
||||||
|
|
||||||
const fields = [
|
const fields = [
|
||||||
{ emoji: '🌅', label: '故事开始', placeholder: '如:一个阳光明媚的早晨...', value: storyStart, required: false, focused: ref(false) },
|
{ label: '故事开始', placeholder: '如:一个阳光明媚的早晨…', value: storyStart, required: false, focused: ref(false) },
|
||||||
{ emoji: '👋', label: '遇见谁', placeholder: '如:遇到了一只迷路的小鸟', value: meetWho, required: false, focused: ref(false) },
|
{ label: '遇见谁', placeholder: '如:遇到了一只迷路的小鸟', value: meetWho, required: false, focused: ref(false) },
|
||||||
{ emoji: '⚡', label: '发生什么', placeholder: '如:一起去森林探险寻找宝藏', value: whatHappens, required: true, focused: ref(false) },
|
{ label: '发生什么', placeholder: '如:一起去森林探险寻找宝藏', value: whatHappens, required: true, focused: ref(false) },
|
||||||
]
|
]
|
||||||
|
|
||||||
const canSubmit = computed(() => bookTitle.value.trim() && heroName.value.trim() && whatHappens.value.trim())
|
const canSubmit = computed(() => bookTitle.value.trim() && heroName.value.trim() && whatHappens.value.trim())
|
||||||
|
|
||||||
|
// 防重复点击:组件被 keep-alive 缓存,从下一页退回时通过 onActivated 重置
|
||||||
let submitted = false
|
let submitted = false
|
||||||
|
onActivated(() => {
|
||||||
|
submitted = false
|
||||||
|
})
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
if (submitted) return // 防重复点击
|
if (submitted) return
|
||||||
submitted = true
|
submitted = true
|
||||||
|
|
||||||
const parts = []
|
const parts = []
|
||||||
@ -113,51 +142,33 @@ const goNext = () => {
|
|||||||
storyHint: parts.join(';'),
|
storyHint: parts.join(';'),
|
||||||
title: bookTitle.value.trim()
|
title: bookTitle.value.trim()
|
||||||
}
|
}
|
||||||
router.push('/p/create/creating')
|
router.push('/p/create/style')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.story-page {
|
.story-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, #FFF8E7 0%, #FFFAF0 30%, #FFF5E6 60%, #FFFDF7 100%);
|
background: var(--ai-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 20px;
|
padding: 16px 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 卡片 ---------- */
|
||||||
.section-card {
|
.section-card {
|
||||||
background: rgba(255,255,255,0.92);
|
background: #fff;
|
||||||
border-radius: 22px;
|
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
padding: 18px 18px;
|
border-radius: var(--ai-radius);
|
||||||
box-shadow: 0 4px 20px rgba(0,0,0,0.05);
|
padding: 18px;
|
||||||
// 书页纹理效果
|
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||||
border-left: 4px solid #FFE4C8;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 12px;
|
|
||||||
left: -4px;
|
|
||||||
bottom: 12px;
|
|
||||||
width: 4px;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
180deg,
|
|
||||||
transparent 0px,
|
|
||||||
transparent 4px,
|
|
||||||
#FFD4A8 4px,
|
|
||||||
#FFD4A8 8px
|
|
||||||
);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
@ -166,91 +177,135 @@ const goNext = () => {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.section-icon { font-size: 20px; }
|
.section-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
}
|
||||||
.section-label {
|
.section-label {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 必填 / 选填标签 ---------- */
|
||||||
|
.required-mark {
|
||||||
|
color: var(--ai-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.optional-mark {
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
font-size: 11px;
|
||||||
|
background: rgba(107, 114, 128, 0.08);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 输入框 ---------- */
|
||||||
.input-wrap {
|
.input-wrap {
|
||||||
background: #FFF8F0;
|
background: rgba(99, 102, 241, 0.04);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
border: 2px solid #FFE8D4;
|
border: 1.5px solid rgba(99, 102, 241, 0.12);
|
||||||
transition: all 0.3s;
|
transition: all 0.2s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
&.focus {
|
&.focus {
|
||||||
border-color: var(--ai-primary);
|
border-color: var(--ai-primary);
|
||||||
box-shadow: 0 0 0 4px rgba(255,107,53,0.1);
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
|
||||||
background: #FFF;
|
background: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-input {
|
.text-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 14px 16px;
|
padding: 13px 14px;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&::placeholder { color: #C8B8A8; }
|
&::placeholder {
|
||||||
|
color: #b8b6c4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 主角名字(含形象回显) ---------- */
|
||||||
|
.hero-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.hero-mini {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid var(--ai-primary);
|
||||||
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.22);
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.06));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hero-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 故事要素字段 ---------- */
|
||||||
.field-item {
|
.field-item {
|
||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
&:last-child { margin-bottom: 0; }
|
&:last-child { margin-bottom: 0; }
|
||||||
}
|
}
|
||||||
.field-label {
|
.field-label {
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.field-emoji { font-size: 16px; }
|
|
||||||
.required-mark {
|
|
||||||
color: var(--ai-primary);
|
|
||||||
font-size: 11px;
|
|
||||||
background: rgba(255,107,53,0.1);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.optional-mark {
|
|
||||||
color: #94A3B8;
|
|
||||||
font-size: 11px;
|
|
||||||
background: rgba(148,163,184,0.1);
|
|
||||||
padding: 1px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea-wrap {
|
.textarea-wrap {
|
||||||
background: #FFF8F0;
|
background: rgba(99, 102, 241, 0.04);
|
||||||
border-radius: 14px;
|
border-radius: 12px;
|
||||||
border: 2px solid #FFE8D4;
|
border: 1.5px solid rgba(99, 102, 241, 0.12);
|
||||||
transition: all 0.3s;
|
transition: all 0.2s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
&.focus {
|
&.focus {
|
||||||
border-color: var(--ai-primary);
|
border-color: var(--ai-primary);
|
||||||
box-shadow: 0 0 0 4px rgba(255,107,53,0.1);
|
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.1);
|
||||||
background: #FFF;
|
background: #fff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.textarea-input {
|
.textarea-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
padding: 12px 16px;
|
padding: 12px 14px;
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
resize: none;
|
resize: none;
|
||||||
@ -258,30 +313,36 @@ const goNext = () => {
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&::placeholder { color: #C8B8A8; }
|
&::placeholder {
|
||||||
|
color: #b8b6c4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-area { margin-top: auto; padding-top: 8px; padding-bottom: 20px; }
|
/* ---------- 底部按钮 ---------- */
|
||||||
|
|
||||||
.create-btn {
|
.create-btn {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
font-size: 18px !important;
|
font-size: 17px !important;
|
||||||
padding: 18px 0 !important;
|
padding: 16px 0 !important;
|
||||||
border-radius: 28px !important;
|
border-radius: 28px !important;
|
||||||
background: linear-gradient(135deg, #FF8C42, #FF6B35, #FF5722) !important;
|
|
||||||
box-shadow: 0 6px 24px rgba(255,107,53,0.35) !important;
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 20px; }
|
||||||
}
|
}
|
||||||
.btn-rocket { font-size: 20px; }
|
|
||||||
|
|
||||||
.time-hint {
|
.time-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
color: var(--ai-primary);
|
color: var(--ai-primary);
|
||||||
margin-top: 10px;
|
margin-top: 8px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 13px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="style-page page-fullscreen">
|
<div class="style-page page-fullscreen">
|
||||||
<PageHeader title="选择画风" subtitle="为绘本挑选一种你喜欢的画风" :step="2" />
|
<PageHeader title="选择画风" subtitle="为绘本挑选一种你喜欢的画风" :step="3" />
|
||||||
|
|
||||||
<div class="content page-content">
|
<div class="content page-content">
|
||||||
<!-- 提示文字 -->
|
<!-- 提示 -->
|
||||||
<div class="tip-banner">
|
<div class="tip-banner">
|
||||||
<span class="tip-icon">🎨</span>
|
<bg-colors-outlined class="tip-icon" />
|
||||||
<span>每种画风都有独特魅力,选一个最喜欢的吧!</span>
|
<span>每种画风都有独特魅力,选一个你最喜欢的</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="style-grid">
|
<div class="style-grid">
|
||||||
@ -18,27 +18,30 @@
|
|||||||
@click="selected = s.styleId"
|
@click="selected = s.styleId"
|
||||||
>
|
>
|
||||||
<!-- 选中角标 -->
|
<!-- 选中角标 -->
|
||||||
<div v-if="selected === s.styleId" class="check-corner" :style="{ background: s.color }">
|
<div v-if="selected === s.styleId" class="check-corner">
|
||||||
<span>✓</span>
|
<check-outlined />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="style-preview" :style="{ background: `linear-gradient(135deg, ${s.color}18, ${s.color}35)` }">
|
<div class="style-preview">
|
||||||
<span class="style-emoji">{{ s.emoji }}</span>
|
<img
|
||||||
|
:src="previewUrl(s.styleId)"
|
||||||
|
:alt="s.styleName"
|
||||||
|
class="style-preview-img"
|
||||||
|
@error="(e) => onPreviewError(e, s.hue)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="style-info">
|
<div class="style-info">
|
||||||
<div class="style-name">{{ s.styleName }}</div>
|
<div class="style-name">{{ s.styleName }}</div>
|
||||||
<div class="style-desc">{{ s.desc }}</div>
|
<div class="style-desc">{{ s.desc }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selected === s.styleId" class="style-selected-tag" :style="{ background: s.color }">
|
|
||||||
已选择
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="page-bottom">
|
<div class="page-bottom">
|
||||||
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
|
<button class="btn-primary next-btn" :disabled="!selected" @click="goNext">
|
||||||
下一步,编故事 →
|
<span>确定画风,开始创作</span>
|
||||||
|
<arrow-right-outlined />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -47,6 +50,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
BgColorsOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
|
|
||||||
@ -54,157 +62,182 @@ const router = useRouter()
|
|||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
const selected = ref('')
|
const selected = ref('')
|
||||||
|
|
||||||
const styles = [
|
interface StyleItem {
|
||||||
{ styleId: 'style_cartoon', styleName: '卡通风格', emoji: '🎨', color: '#FF6B35', desc: '色彩鲜明,充满童趣' },
|
styleId: string
|
||||||
{ styleId: 'style_watercolor', styleName: '水彩风格', emoji: '🖌️', color: '#2EC4B6', desc: '柔和透明,梦幻浪漫' },
|
styleName: string
|
||||||
{ styleId: 'style_ink', styleName: '水墨国风', emoji: '🏮', color: '#6C63FF', desc: '古韵悠长,意境深远' },
|
desc: string
|
||||||
{ styleId: 'style_pencil', styleName: '彩铅风格', emoji: '✏️', color: '#FFD166', desc: '细腻温暖,自然亲切' },
|
/** 用于预览图加载失败时的 fallback 渐变色调(HSL 色相) */
|
||||||
{ styleId: 'style_oilpaint', styleName: '油画风格', emoji: '🖼️', color: '#8B5E3C', desc: '色彩浓郁,质感丰富' },
|
hue: number
|
||||||
{ styleId: 'style_collage', styleName: '剪贴画', emoji: '✂️', color: '#E91E63', desc: '趣味拼贴,创意满满' },
|
}
|
||||||
|
|
||||||
|
const styles: StyleItem[] = [
|
||||||
|
{ styleId: 'style_cartoon', styleName: '卡通风格', desc: '色彩鲜明,充满童趣', hue: 30 },
|
||||||
|
{ styleId: 'style_watercolor', styleName: '水彩风格', desc: '柔和透明,梦幻浪漫', hue: 200 },
|
||||||
|
{ styleId: 'style_ink', styleName: '水墨国风', desc: '古韵悠长,意境深远', hue: 270 },
|
||||||
|
{ styleId: 'style_pencil', styleName: '彩铅风格', desc: '细腻温暖,自然亲切', hue: 50 },
|
||||||
|
{ styleId: 'style_oilpaint', styleName: '油画风格', desc: '色彩浓郁,质感丰富', hue: 25 },
|
||||||
|
{ styleId: 'style_collage', styleName: '剪贴画', desc: '趣味拼贴,创意满满', hue: 320 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** 预览图路径约定:/public/aicreate/styles/{styleId}.jpg */
|
||||||
|
const previewUrl = (id: string) => `/aicreate/styles/${id}.jpg`
|
||||||
|
|
||||||
|
/** 加载失败时切换为对应色调的 SVG 渐变占位 */
|
||||||
|
const fallbackSvg = (hue: number) =>
|
||||||
|
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="240" viewBox="0 0 240 240">` +
|
||||||
|
`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">` +
|
||||||
|
`<stop offset="0" stop-color="hsl(${hue},65%,84%)"/>` +
|
||||||
|
`<stop offset="1" stop-color="hsl(${(hue + 30) % 360},70%,66%)"/>` +
|
||||||
|
`</linearGradient></defs>` +
|
||||||
|
`<rect width="240" height="240" fill="url(#g)"/>` +
|
||||||
|
`</svg>`
|
||||||
|
)
|
||||||
|
|
||||||
|
const onPreviewError = (e: Event, hue: number) => {
|
||||||
|
const img = e.target as HTMLImageElement
|
||||||
|
if (img.dataset.fallback === '1') return // 防止 fallback 也失败导致死循环
|
||||||
|
img.dataset.fallback = '1'
|
||||||
|
img.src = fallbackSvg(hue)
|
||||||
|
}
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
store.selectedStyle = selected.value
|
store.selectedStyle = selected.value
|
||||||
router.push('/p/create/story')
|
router.push('/p/create/creating')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.style-page {
|
.style-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(180deg, #F5F0FF 0%, #FFF0F5 40%, #FFF5F0 70%, #FFFDF7 100%);
|
background: var(--ai-bg);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px 16px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 提示条 ---------- */
|
||||||
.tip-banner {
|
.tip-banner {
|
||||||
background: rgba(255,255,255,0.85);
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.08), rgba(236, 72, 153, 0.05));
|
||||||
border: 1.5px solid #E8D5F5;
|
border: 1px solid rgba(99, 102, 241, 0.15);
|
||||||
border-radius: 16px;
|
border-radius: var(--ai-radius-sm);
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
margin-bottom: 14px;
|
margin-bottom: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
color: #7B61B8;
|
color: var(--ai-text);
|
||||||
|
}
|
||||||
|
.tip-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.tip-icon { font-size: 18px; }
|
|
||||||
|
|
||||||
|
/* ---------- 风格网格 ---------- */
|
||||||
.style-grid {
|
.style-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 14px;
|
gap: 12px;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.style-card {
|
.style-card {
|
||||||
background: rgba(255,255,255,0.92);
|
|
||||||
border-radius: 22px;
|
|
||||||
padding: 14px;
|
|
||||||
text-align: center;
|
|
||||||
border: 3px solid transparent;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
background: #fff;
|
||||||
|
border-radius: var(--ai-radius);
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&:active { transform: scale(0.97); }
|
&:hover {
|
||||||
|
border-color: rgba(99, 102, 241, 0.18);
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
&.selected {
|
&.selected {
|
||||||
transform: scale(1.04);
|
border-color: var(--ai-primary);
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,0.12);
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.04), rgba(236, 72, 153, 0.03));
|
||||||
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
.style-preview {
|
&:active {
|
||||||
transform: scale(1.05);
|
transform: scale(0.99);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 动态设置选中边框颜色
|
/* ---------- 选中角标 ---------- */
|
||||||
.style-card.selected {
|
|
||||||
border-color: var(--ai-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.check-corner {
|
.check-corner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 8px;
|
||||||
right: 0;
|
right: 8px;
|
||||||
width: 36px;
|
width: 26px;
|
||||||
height: 36px;
|
height: 26px;
|
||||||
border-radius: 0 19px 0 18px;
|
border-radius: 50%;
|
||||||
|
background: var(--ai-gradient);
|
||||||
|
color: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
font-size: 13px;
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 700;
|
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 预览图 ---------- */
|
||||||
.style-preview {
|
.style-preview {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 18px;
|
border-radius: 14px;
|
||||||
margin-bottom: 12px;
|
overflow: hidden;
|
||||||
display: flex;
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.06), rgba(236, 72, 153, 0.04));
|
||||||
align-items: center;
|
}
|
||||||
justify-content: center;
|
.style-preview-img {
|
||||||
transition: transform 0.3s;
|
width: 100%;
|
||||||
}
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
.style-emoji {
|
display: block;
|
||||||
font-size: 52px;
|
|
||||||
filter: drop-shadow(0 2px 6px rgba(0,0,0,0.1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 风格名称 ---------- */
|
||||||
.style-info {
|
.style-info {
|
||||||
flex: 1;
|
padding: 10px 4px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.style-name {
|
.style-name {
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
color: var(--ai-text);
|
color: var(--ai-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.style-desc {
|
.style-desc {
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
color: var(--ai-text-sub);
|
color: var(--ai-text-sub);
|
||||||
margin-top: 4px;
|
margin-top: 3px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.style-selected-tag {
|
/* ---------- 底部按钮 ---------- */
|
||||||
margin-top: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #fff;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 4px 14px;
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 700;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bottom-area { margin-top: auto; padding-top: 16px; }
|
|
||||||
|
|
||||||
.next-btn {
|
.next-btn {
|
||||||
font-size: 17px !important;
|
display: flex !important;
|
||||||
padding: 16px 0 !important;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 14px 0 !important;
|
||||||
border-radius: 28px !important;
|
border-radius: 28px !important;
|
||||||
background: linear-gradient(135deg, #FF8C42, #FF6B35) !important;
|
|
||||||
box-shadow: 0 6px 24px rgba(255,107,53,0.3) !important;
|
:deep(.anticon) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,32 +1,44 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="upload-page page-fullscreen">
|
<div class="upload-page page-fullscreen">
|
||||||
<PageHeader title="上传作品" subtitle="拍下孩子的画,让AI识别角色" :step="0" />
|
<PageHeader title="上传作品" subtitle="上传你的画作,AI 自动识别角色" :step="0" />
|
||||||
|
|
||||||
<div class="content page-content">
|
<div class="content page-content">
|
||||||
|
<!-- 开发模式:跳过真实后端调用 -->
|
||||||
|
<div v-if="isDev" class="dev-skip">
|
||||||
|
<span class="dev-skip-label">
|
||||||
|
<experiment-outlined />
|
||||||
|
开发模式
|
||||||
|
</span>
|
||||||
|
<button class="dev-skip-btn" @click="handleSkipUpload(3)">跳过 · 3 个角色</button>
|
||||||
|
<button class="dev-skip-btn" @click="handleSkipUpload(1)">跳过 · 1 个角色</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="!preview">
|
<template v-if="!preview">
|
||||||
<!-- 上传区域 -->
|
<!-- 上传区域 -->
|
||||||
<div class="upload-area card">
|
<div class="upload-area card">
|
||||||
<template v-if="uploading">
|
<template v-if="uploading">
|
||||||
<div class="uploading-icon">📤</div>
|
<cloud-upload-outlined class="uploading-icon" />
|
||||||
<div class="uploading-text">正在上传...</div>
|
<div class="uploading-text">正在上传...</div>
|
||||||
<div class="progress-bar"><div class="progress-fill" /></div>
|
<div class="progress-bar"><div class="progress-fill" /></div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="upload-icon">🖼️</div>
|
<div class="upload-icon-wrap">
|
||||||
<div class="upload-title">上传孩子的画作</div>
|
<picture-outlined class="upload-icon" />
|
||||||
<div class="upload-desc">支持拍照或从相册选择<br/>AI会自动识别画中的角色</div>
|
</div>
|
||||||
|
<div class="upload-title">上传你的画作</div>
|
||||||
|
<div class="upload-desc">支持拍照或从相册选择<br/>AI 会自动识别画中的角色</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 拍照/相册按钮 -->
|
<!-- 拍照/相册按钮 -->
|
||||||
<div class="action-btns">
|
<div class="action-btns">
|
||||||
<div class="action-btn camera" @click="pickImage('camera')">
|
<div class="action-btn camera" @click="pickImage('camera')">
|
||||||
<div class="action-emoji">📷</div>
|
<camera-outlined class="action-icon" />
|
||||||
<div class="action-label">拍照</div>
|
<div class="action-label">拍照</div>
|
||||||
<input ref="cameraInput" type="file" accept="image/*" capture="environment" @change="onFileChange" style="display:none" />
|
<input ref="cameraInput" type="file" accept="image/*" capture="environment" @change="onFileChange" style="display:none" />
|
||||||
</div>
|
</div>
|
||||||
<div class="action-btn album" @click="pickImage('album')">
|
<div class="action-btn album" @click="pickImage('album')">
|
||||||
<div class="action-emoji">🖼️</div>
|
<folder-open-outlined class="action-icon" />
|
||||||
<div class="action-label">相册</div>
|
<div class="action-label">相册</div>
|
||||||
<input ref="albumInput" type="file" accept="image/*" @change="onFileChange" style="display:none" />
|
<input ref="albumInput" type="file" accept="image/*" @change="onFileChange" style="display:none" />
|
||||||
</div>
|
</div>
|
||||||
@ -39,44 +51,41 @@
|
|||||||
<div class="preview-image">
|
<div class="preview-image">
|
||||||
<img :src="preview" alt="预览" />
|
<img :src="preview" alt="预览" />
|
||||||
</div>
|
</div>
|
||||||
<!-- 识别中:填满空间 -->
|
<!-- 识别中 -->
|
||||||
<div v-if="uploading" class="recognizing-box">
|
<div v-if="uploading" class="recognizing-box">
|
||||||
<div class="recognizing-emojis">
|
<loading-outlined class="recognizing-spinner" spin />
|
||||||
<span class="recognizing-emoji e1">🎨</span>
|
<div class="recognizing-text">{{ uploadProgress || 'AI 正在识别你的角色...' }}</div>
|
||||||
<span class="recognizing-emoji e2">✏️</span>
|
|
||||||
<span class="recognizing-emoji e3">🖌️</span>
|
|
||||||
</div>
|
|
||||||
<div class="recognizing-text">{{ uploadProgress || 'AI 小画家正在认识你的角色...' }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="preview-info">
|
<div v-else class="preview-info">
|
||||||
<div class="preview-ok">✅ 已选择图片</div>
|
<check-circle-filled class="preview-ok-icon" />
|
||||||
|
<span class="preview-ok-text">已选择图片</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 识别中的趣味等待内容(填满空白) -->
|
<!-- 等待内容 -->
|
||||||
<div v-if="uploading" class="waiting-content">
|
<div v-if="uploading" class="waiting-content">
|
||||||
<div class="waiting-card">
|
<div class="waiting-card">
|
||||||
<div class="waiting-title">✨ AI 正在为你做这些事</div>
|
<div class="waiting-title">AI 正在为你做这些事</div>
|
||||||
<div class="waiting-steps">
|
<div class="waiting-steps">
|
||||||
<div class="w-step" :class="{ active: uploadStage >= 1 }">
|
<div class="w-step" :class="{ active: uploadStage >= 1 }">
|
||||||
<span class="w-icon">📤</span>
|
<cloud-upload-outlined class="w-icon" />
|
||||||
<span>上传画作到云端</span>
|
<span>上传画作到云端</span>
|
||||||
<span v-if="uploadStage >= 1" class="w-done">✓</span>
|
<check-outlined v-if="uploadStage >= 1" class="w-done" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-step" :class="{ active: uploadStage >= 2 }">
|
<div class="w-step" :class="{ active: uploadStage >= 2 }">
|
||||||
<span class="w-icon">👀</span>
|
<eye-outlined class="w-icon" />
|
||||||
<span>AI 识别画中角色</span>
|
<span>AI 识别画中角色</span>
|
||||||
<span v-if="uploadStage >= 2" class="w-done">✓</span>
|
<check-outlined v-if="uploadStage >= 2" class="w-done" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-step" :class="{ active: uploadStage >= 3 }">
|
<div class="w-step" :class="{ active: uploadStage >= 3 }">
|
||||||
<span class="w-icon">🎭</span>
|
<team-outlined class="w-icon" />
|
||||||
<span>提取角色特征</span>
|
<span>提取角色特征</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="waiting-funfact">
|
<div class="waiting-funfact">
|
||||||
<span class="ff-icon">💡</span>
|
<bulb-outlined class="ff-icon" />
|
||||||
<span class="ff-text">你知道吗?AI 画师可以识别超过 100 种不同的卡通角色哦!</span>
|
<span class="ff-text">小知识:AI 画师可以识别超过 100 种不同的卡通角色</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -84,8 +93,15 @@
|
|||||||
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
|
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
|
||||||
<div class="preview-actions">
|
<div class="preview-actions">
|
||||||
<button class="btn-ghost" @click="reset">重新上传</button>
|
<button class="btn-ghost" @click="reset">重新上传</button>
|
||||||
<button class="btn-primary" :disabled="uploading" @click="goNext">
|
<button class="btn-primary preview-next-btn" :disabled="uploading" @click="goNext">
|
||||||
{{ uploading ? 'AI 识别中...' : '识别角色 →' }}
|
<template v-if="uploading">
|
||||||
|
<loading-outlined spin />
|
||||||
|
<span>AI 识别中...</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>识别角色</span>
|
||||||
|
<arrow-right-outlined />
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -99,6 +115,27 @@ import { useRouter } from 'vue-router'
|
|||||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
|
import { extractCharacters, ossUpload, checkQuota } from '@/api/aicreate'
|
||||||
|
import {
|
||||||
|
PictureOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
FolderOpenOutlined,
|
||||||
|
CloudUploadOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
EyeOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
BulbOutlined,
|
||||||
|
ArrowRightOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const handleSkipUpload = (count: number) => {
|
||||||
|
store.fillMockData(count)
|
||||||
|
router.push('/p/create/characters')
|
||||||
|
}
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
@ -314,17 +351,44 @@ const goNext = async () => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 3px dashed var(--ai-border);
|
border: 2px dashed rgba(99, 102, 241, 0.28);
|
||||||
|
border-radius: var(--ai-radius);
|
||||||
|
background: rgba(99, 102, 241, 0.03);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32px;
|
padding: 32px;
|
||||||
|
transition: all 0.2s;
|
||||||
}
|
}
|
||||||
.upload-icon, .uploading-icon {
|
.upload-icon-wrap {
|
||||||
font-size: 64px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(236, 72, 153, 0.1));
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
.uploading-icon { animation: pulse 1.5s infinite; }
|
.upload-icon {
|
||||||
.upload-title { font-size: 18px; font-weight: 700; margin-top: 16px; }
|
font-size: 36px;
|
||||||
.upload-desc { font-size: 14px; color: var(--ai-text-sub); margin-top: 8px; line-height: 1.6; }
|
color: var(--ai-primary);
|
||||||
.uploading-text { font-size: 16px; font-weight: 600; margin-top: 16px; }
|
}
|
||||||
|
.uploading-icon {
|
||||||
|
font-size: 56px;
|
||||||
|
color: var(--ai-primary);
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
.upload-title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ai-text);
|
||||||
|
}
|
||||||
|
.upload-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
margin-top: 8px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.uploading-text { font-size: 16px; font-weight: 600; margin-top: 16px; color: var(--ai-text); }
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
width: 200px; height: 6px; background: var(--ai-border); border-radius: 3px; margin-top: 16px; overflow: hidden;
|
width: 200px; height: 6px; background: var(--ai-border); border-radius: 3px; margin-top: 16px; overflow: hidden;
|
||||||
}
|
}
|
||||||
@ -341,93 +405,174 @@ const goNext = async () => {
|
|||||||
}
|
}
|
||||||
.action-btn {
|
.action-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
border-radius: var(--ai-radius);
|
border-radius: var(--ai-radius);
|
||||||
padding: 20px 0;
|
padding: 18px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: var(--ai-shadow);
|
transition: all 0.2s;
|
||||||
|
|
||||||
&.camera { background: var(--ai-gradient); }
|
&.camera {
|
||||||
&.album { background: var(--ai-gradient-purple); }
|
background: var(--ai-gradient);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 8px 22px rgba(99, 102, 241, 0.32);
|
||||||
|
.action-icon { color: #fff; }
|
||||||
|
.action-label { color: #fff; }
|
||||||
|
&:hover { transform: translateY(-2px); box-shadow: 0 10px 26px rgba(99, 102, 241, 0.38); }
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
}
|
||||||
|
|
||||||
|
&.album {
|
||||||
|
background: #fff;
|
||||||
|
border: 1.5px solid rgba(99, 102, 241, 0.22);
|
||||||
|
box-shadow: 0 2px 10px rgba(99, 102, 241, 0.06);
|
||||||
|
.action-icon { color: var(--ai-primary); }
|
||||||
|
.action-label { color: var(--ai-primary); }
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
box-shadow: 0 6px 18px rgba(99, 102, 241, 0.14);
|
||||||
|
}
|
||||||
|
&:active { transform: scale(0.98); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.action-emoji { font-size: 32px; }
|
.action-icon { font-size: 26px; }
|
||||||
.action-label { font-size: 15px; font-weight: 700; color: #fff; margin-top: 8px; }
|
.action-label { font-size: 15px; font-weight: 700; }
|
||||||
|
|
||||||
.preview-card { overflow: hidden; flex: 1; }
|
.preview-card { overflow: hidden; flex: 1; }
|
||||||
.preview-image {
|
.preview-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 4/3;
|
aspect-ratio: 4/3;
|
||||||
background: #F5F0E8;
|
background: #f1f0f7;
|
||||||
img { width: 100%; height: 100%; object-fit: cover; }
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
}
|
}
|
||||||
.preview-info { padding: 20px; text-align: center; }
|
.preview-info {
|
||||||
.preview-ok { font-size: 15px; font-weight: 600; color: var(--ai-success); }
|
padding: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.preview-ok-icon { font-size: 18px; color: var(--ai-success); }
|
||||||
|
.preview-ok-text { font-size: 14px; font-weight: 600; color: var(--ai-success); }
|
||||||
|
|
||||||
/* 儿童风格识别中动画 */
|
/* 识别中 */
|
||||||
.recognizing-box {
|
.recognizing-box {
|
||||||
background: linear-gradient(135deg, #FFF8E1, #FFFDE7);
|
background: linear-gradient(135deg, rgba(99,102,241,0.06), rgba(236,72,153,0.04));
|
||||||
border-radius: 0 0 16px 16px;
|
border-radius: 0 0 var(--ai-radius) var(--ai-radius);
|
||||||
padding: 20px 16px;
|
padding: 24px 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.recognizing-emojis {
|
.recognizing-spinner {
|
||||||
display: flex;
|
font-size: 32px;
|
||||||
justify-content: center;
|
color: var(--ai-primary);
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
padding: 8px 0;
|
|
||||||
}
|
|
||||||
.recognizing-emoji {
|
|
||||||
font-size: 28px;
|
|
||||||
display: inline-block;
|
|
||||||
animation: emojiPop 1.8s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.recognizing-emoji.e1 { animation-delay: 0s; }
|
|
||||||
.recognizing-emoji.e2 { animation-delay: 0.4s; }
|
|
||||||
.recognizing-emoji.e3 { animation-delay: 0.8s; }
|
|
||||||
@keyframes emojiPop {
|
|
||||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.5; }
|
|
||||||
50% { transform: scale(1.3) rotate(10deg); opacity: 1; }
|
|
||||||
}
|
}
|
||||||
.recognizing-text {
|
.recognizing-text {
|
||||||
font-size: 15px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: #F59E0B;
|
color: var(--ai-primary);
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待内容 — 填满空白
|
// 等待内容
|
||||||
.waiting-content {
|
.waiting-content {
|
||||||
display: flex; flex-direction: column; gap: 12px; flex: 1;
|
display: flex; flex-direction: column; gap: 12px; flex: 1;
|
||||||
}
|
}
|
||||||
.waiting-card {
|
.waiting-card {
|
||||||
background: rgba(255,255,255,0.92); border-radius: 20px;
|
background: #fff;
|
||||||
padding: 18px 20px; box-shadow: 0 4px 16px rgba(0,0,0,0.05);
|
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
|
border-radius: var(--ai-radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.06);
|
||||||
}
|
}
|
||||||
.waiting-title {
|
.waiting-title {
|
||||||
font-size: 15px; font-weight: 800; color: #1E293B; margin-bottom: 14px;
|
font-size: 15px; font-weight: 700; color: var(--ai-text); margin-bottom: 14px;
|
||||||
}
|
}
|
||||||
.waiting-steps { display: flex; flex-direction: column; gap: 10px; }
|
.waiting-steps { display: flex; flex-direction: column; gap: 10px; }
|
||||||
.w-step {
|
.w-step {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 10px 14px; border-radius: 14px; background: #F8F8F5;
|
padding: 10px 14px; border-radius: 12px;
|
||||||
font-size: 14px; color: #94A3B8; transition: all 0.3s;
|
background: rgba(99, 102, 241, 0.04);
|
||||||
&.active { background: #FFF8E7; color: #1E293B; font-weight: 600; }
|
font-size: 14px; color: var(--ai-text-sub); transition: all 0.3s;
|
||||||
|
&.active {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: var(--ai-text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.w-icon { font-size: 20px; }
|
.w-icon { font-size: 18px; color: var(--ai-primary); }
|
||||||
.w-done { margin-left: auto; color: #10B981; font-weight: 700; font-size: 16px; }
|
.w-done { margin-left: auto; color: var(--ai-success); font-size: 16px; }
|
||||||
|
|
||||||
.waiting-funfact {
|
.waiting-funfact {
|
||||||
display: flex; align-items: flex-start; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
background: linear-gradient(135deg, #EDE9FE, #F5F3FF);
|
background: linear-gradient(135deg, rgba(167, 139, 250, 0.1), rgba(236, 72, 153, 0.06));
|
||||||
border-radius: 16px; padding: 14px 16px;
|
border: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
|
border-radius: var(--ai-radius-sm);
|
||||||
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
.ff-icon { font-size: 20px; flex-shrink: 0; }
|
.ff-icon { font-size: 18px; flex-shrink: 0; color: #8b5cf6; }
|
||||||
.ff-text { font-size: 13px; color: #6D28D9; line-height: 1.6; }
|
.ff-text { font-size: 13px; color: var(--ai-text); line-height: 1.6; }
|
||||||
|
|
||||||
.quota-warn {
|
.quota-warn {
|
||||||
background: #FEF3C7; color: #92400E; font-size: 13px; text-align: center;
|
background: rgba(245, 158, 11, 0.1);
|
||||||
padding: 10px 16px; border-radius: 10px; margin-top: 12px; font-weight: 600;
|
color: #b45309;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.upload-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
.upload-error { color: #EF4444; font-size: 13px; text-align: center; margin-top: 12px; font-weight: 500; }
|
|
||||||
.preview-actions { display: flex; gap: 12px; margin-top: 12px; button { flex: 1; } }
|
.preview-actions { display: flex; gap: 12px; margin-top: 12px; button { flex: 1; } }
|
||||||
|
.preview-next-btn {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
:deep(.anticon) { font-size: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 开发模式跳过按钮 */
|
||||||
|
.dev-skip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.dev-skip-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ai-text-sub);
|
||||||
|
:deep(.anticon) { font-size: 12px; }
|
||||||
|
}
|
||||||
|
.dev-skip-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(99, 102, 241, 0.06);
|
||||||
|
color: var(--ai-primary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px dashed rgba(99, 102, 241, 0.32);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
&:hover {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border-color: var(--ai-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -1,75 +1,47 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="welcome-page">
|
<div class="welcome-page">
|
||||||
<!-- Hero(紧凑版) -->
|
<!-- Hero -->
|
||||||
<div class="hero-compact">
|
<section class="hero">
|
||||||
<div class="hero-bg-deco d1">⭐</div>
|
<div class="hero-deco">
|
||||||
<div class="hero-bg-deco d2">🌈</div>
|
<thunderbolt-outlined class="deco deco-1" />
|
||||||
<div class="hero-bg-deco d3">✨</div>
|
<star-outlined class="deco deco-2" />
|
||||||
<div class="hero-row">
|
<star-outlined class="deco deco-3" />
|
||||||
<div class="hero-books">
|
|
||||||
<span v-for="(b, i) in ['📕','📗','📘','📙']" :key="i" class="book-icon">{{ b }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="hero-text">
|
|
||||||
<div class="hero-title">{{ brandTitle }}</div>
|
|
||||||
<div class="hero-sub">{{ brandSubtitle }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-tag">✨ 拍一张画,AI帮你变成绘本</div>
|
<div class="hero-icon">
|
||||||
</div>
|
<book-outlined />
|
||||||
|
</div>
|
||||||
|
<h1 class="hero-title">AI 绘本创作</h1>
|
||||||
|
<p class="hero-sub">把你的画变成会讲故事的绘本</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 主内容区(flex:1 撑满中间) -->
|
<!-- 创作流程 -->
|
||||||
<div class="main-area">
|
<section class="card steps-card">
|
||||||
<!-- 流程步骤(垂直时间线) -->
|
<h2 class="card-title">创作流程</h2>
|
||||||
<div class="steps-card">
|
<div class="steps">
|
||||||
<div class="steps-header">🎯 创作流程</div>
|
<div v-for="(s, i) in steps" :key="i" class="step">
|
||||||
<div class="steps-timeline">
|
<div class="step-left">
|
||||||
<div v-for="(s, i) in steps" :key="i" class="step-item">
|
<div class="step-num">{{ i + 1 }}</div>
|
||||||
<div class="step-left">
|
<div v-if="i < steps.length - 1" class="step-line" />
|
||||||
<div class="step-num" :style="{ background: s.color }">{{ i + 1 }}</div>
|
</div>
|
||||||
<div v-if="i < steps.length - 1" class="step-line" :style="{ background: s.color + '40' }" />
|
<div class="step-right">
|
||||||
</div>
|
<div class="step-head">
|
||||||
<div class="step-right">
|
<component :is="s.icon" class="step-icon" />
|
||||||
<div class="step-head">
|
<span class="step-title">{{ s.title }}</span>
|
||||||
<span class="step-emoji">{{ s.emoji }}</span>
|
<span v-if="s.optional" class="step-tag">可选</span>
|
||||||
<span class="step-title">{{ s.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="step-desc">{{ s.desc }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="step-desc">{{ s.desc }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 特色标签 -->
|
<!-- 底部固定 CTA -->
|
||||||
<div class="features-row">
|
<div class="cta-fab">
|
||||||
<div class="feature-tag">🎨 AI绘画</div>
|
<button class="cta-btn" @click="handleStart">
|
||||||
<div class="feature-tag">📖 自动排版</div>
|
<rocket-outlined />
|
||||||
<div class="feature-tag">🔊 语音配音</div>
|
<span>开始创作</span>
|
||||||
<div class="feature-tag">🎤 人工配音</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 亮点描述 -->
|
|
||||||
<div class="highlights">
|
|
||||||
<div class="hl-item">
|
|
||||||
<span class="hl-icon">🖌️</span>
|
|
||||||
<span class="hl-text">上传孩子的画作,AI 自动识别角色</span>
|
|
||||||
</div>
|
|
||||||
<div class="hl-item">
|
|
||||||
<span class="hl-icon">📖</span>
|
|
||||||
<span class="hl-text">一键生成多页精美绘本故事</span>
|
|
||||||
</div>
|
|
||||||
<div class="hl-item">
|
|
||||||
<span class="hl-icon">🔊</span>
|
|
||||||
<span class="hl-text">AI 配音或亲自录音,让故事活起来</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 底部(固定) -->
|
|
||||||
<div class="bottom-area safe-bottom">
|
|
||||||
<button class="btn-primary start-btn" @click="handleStart">
|
|
||||||
<span class="btn-icon">🚀</span> 开始创作
|
|
||||||
</button>
|
</button>
|
||||||
<div class="slogan">让每个孩子都是小画家 ✨</div>
|
<p class="slogan">你的画作,会讲故事</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -77,24 +49,37 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
import {
|
||||||
|
CameraOutlined,
|
||||||
|
SmileOutlined,
|
||||||
|
BgColorsOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
ThunderboltOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
SoundOutlined,
|
||||||
|
BookOutlined,
|
||||||
|
RocketOutlined,
|
||||||
|
StarOutlined,
|
||||||
|
} from '@ant-design/icons-vue'
|
||||||
import { useAicreateStore } from '@/stores/aicreate'
|
import { useAicreateStore } from '@/stores/aicreate'
|
||||||
import config from '@/utils/aicreate/config'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useAicreateStore()
|
const store = useAicreateStore()
|
||||||
|
|
||||||
const steps = [
|
const steps = [
|
||||||
{ emoji: '📸', title: '拍照上传', desc: '拍下孩子的画作', color: '#FF6B35' },
|
{ icon: CameraOutlined, title: '拍照上传', desc: '拍下你的画作' },
|
||||||
{ emoji: '🎭', title: '角色提取', desc: 'AI智能识别角色', color: '#6C63FF' },
|
{ icon: SmileOutlined, title: '角色提取', desc: 'AI 智能识别画中角色' },
|
||||||
{ emoji: '✏️', title: '编排故事', desc: '选画风填要素', color: '#2EC4B6' },
|
{ icon: EditOutlined, title: '编排故事', desc: '起书名、定主角、填故事要素' },
|
||||||
{ emoji: '📖', title: '绘本创作', desc: 'AI生成完整绘本', color: '#FFD166' },
|
{ icon: BgColorsOutlined, title: '选择画风', desc: '水墨、3D 等多种风格' },
|
||||||
|
{ icon: ThunderboltOutlined, title: 'AI 生成', desc: '一键生成完整绘本' },
|
||||||
|
{ icon: EyeOutlined, title: '预览编目', desc: '浏览成果,补充作者署名' },
|
||||||
|
{ icon: SoundOutlined, title: '配音发布', desc: 'AI 配音或亲自录音', optional: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 检查恢复状态
|
// 检查恢复状态
|
||||||
const recovery = store.restoreRecoveryState()
|
const recovery = store.restoreRecoveryState()
|
||||||
if (recovery && recovery.path && recovery.path !== '/') {
|
if (recovery && recovery.path && recovery.path !== '/') {
|
||||||
// 将旧路径映射到新路径
|
|
||||||
const newPath = '/p/create' + recovery.path
|
const newPath = '/p/create' + recovery.path
|
||||||
router.push(newPath)
|
router.push(newPath)
|
||||||
}
|
}
|
||||||
@ -111,225 +96,203 @@ const brandSubtitle = config.brand.subtitle || 'AI智能儿童绘本创作'
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
$primary: #6366f1;
|
||||||
|
$accent: #ec4899;
|
||||||
|
$text-strong: #1e1b4b;
|
||||||
|
$text: #4b5563;
|
||||||
|
$text-muted: #9ca3af;
|
||||||
|
$gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
|
||||||
|
|
||||||
.welcome-page {
|
.welcome-page {
|
||||||
height: 100vh;
|
padding: 16px 16px 140px; // 底部留位给浮动 CTA
|
||||||
background: linear-gradient(180deg, #FFF8E7 0%, #FFF3E0 30%, #FFF0F0 60%, #FFFDF7 100%);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
gap: 16px;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Hero 紧凑版 ---- */
|
/* ---------- Hero ---------- */
|
||||||
.hero-compact {
|
.hero {
|
||||||
background: linear-gradient(135deg, #FF6B35 0%, #FF8F65 40%, #FFB088 70%, #FFCBA4 100%);
|
|
||||||
border-radius: 0 0 32px 32px;
|
|
||||||
padding: 40px 20px 18px;
|
|
||||||
text-align: center;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
padding: 28px 24px;
|
||||||
flex-shrink: 0;
|
border-radius: 20px;
|
||||||
}
|
background: $gradient;
|
||||||
.hero-bg-deco {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0.18;
|
|
||||||
pointer-events: none;
|
|
||||||
animation: twinkle 3s ease-in-out infinite;
|
|
||||||
&.d1 { top: 10px; left: 16px; font-size: 20px; }
|
|
||||||
&.d2 { top: 16px; right: 20px; font-size: 18px; animation-delay: 0.8s; }
|
|
||||||
&.d3 { bottom: 10px; right: 30%; font-size: 16px; animation-delay: 1.5s; }
|
|
||||||
}
|
|
||||||
@keyframes twinkle {
|
|
||||||
0%, 100% { transform: scale(1) rotate(0deg); opacity: 0.18; }
|
|
||||||
50% { transform: scale(1.15) rotate(8deg); opacity: 0.35; }
|
|
||||||
}
|
|
||||||
.hero-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 14px;
|
|
||||||
}
|
|
||||||
.hero-books {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
.book-icon {
|
|
||||||
font-size: 26px;
|
|
||||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.15));
|
|
||||||
animation: bookBounce 2.5s ease-in-out infinite;
|
|
||||||
&:nth-child(2) { animation-delay: 0.3s; }
|
|
||||||
&:nth-child(3) { animation-delay: 0.6s; }
|
|
||||||
&:nth-child(4) { animation-delay: 0.9s; }
|
|
||||||
}
|
|
||||||
@keyframes bookBounce {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-5px); }
|
|
||||||
}
|
|
||||||
.hero-text { text-align: left; }
|
|
||||||
.hero-title {
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 900;
|
|
||||||
color: #fff;
|
color: #fff;
|
||||||
letter-spacing: 3px;
|
text-align: center;
|
||||||
text-shadow: 0 2px 8px rgba(0,0,0,0.18);
|
overflow: hidden;
|
||||||
|
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.22);
|
||||||
}
|
}
|
||||||
.hero-sub {
|
.hero-deco {
|
||||||
font-size: 13px;
|
position: absolute;
|
||||||
color: rgba(255,255,255,0.9);
|
inset: 0;
|
||||||
margin-top: 2px;
|
pointer-events: none;
|
||||||
letter-spacing: 1.5px;
|
.deco {
|
||||||
font-weight: 500;
|
position: absolute;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
.deco-1 { top: 14px; right: 18px; font-size: 22px; }
|
||||||
|
.deco-2 { top: 18px; left: 22px; font-size: 14px; }
|
||||||
|
.deco-3 { bottom: 18px; right: 30%; font-size: 12px; }
|
||||||
}
|
}
|
||||||
.hero-tag {
|
.hero-icon {
|
||||||
margin-top: 10px;
|
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
justify-content: center;
|
||||||
background: rgba(255,255,255,0.22);
|
width: 56px;
|
||||||
border-radius: 20px;
|
height: 56px;
|
||||||
padding: 5px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #fff;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 主内容区 ---- */
|
|
||||||
.main-area {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: space-evenly;
|
|
||||||
padding: 10px 16px 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 流程步骤(垂直时间线) */
|
|
||||||
.steps-card {
|
|
||||||
background: rgba(255,255,255,0.92);
|
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 12px 14px;
|
background: rgba(255, 255, 255, 0.22);
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
|
backdrop-filter: blur(8px);
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.steps-header {
|
.hero-title {
|
||||||
font-size: 15px;
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #333;
|
letter-spacing: 2px;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
}
|
||||||
.steps-timeline { display: flex; flex-direction: column; }
|
.hero-sub {
|
||||||
.step-item {
|
margin: 6px 0 0;
|
||||||
display: flex;
|
font-size: 13px;
|
||||||
align-items: flex-start;
|
opacity: 0.92;
|
||||||
gap: 12px;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 通用卡片 ---------- */
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px;
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
|
box-shadow: 0 2px 12px rgba(99, 102, 241, 0.05);
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-strong;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 创作流程 ---------- */
|
||||||
|
.steps { display: flex; flex-direction: column; }
|
||||||
|
.step { display: flex; gap: 12px; }
|
||||||
.step-left {
|
.step-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 32px;
|
width: 28px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.step-num {
|
.step-num {
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
background: $gradient;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.25);
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 800;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
}
|
||||||
.step-line {
|
.step-line {
|
||||||
width: 3px;
|
flex: 1;
|
||||||
height: 12px;
|
width: 2px;
|
||||||
border-radius: 2px;
|
min-height: 14px;
|
||||||
margin: 3px 0;
|
background: linear-gradient(180deg, rgba(99, 102, 241, 0.35), rgba(236, 72, 153, 0.18));
|
||||||
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
.step-right { padding-top: 4px; }
|
.step-right {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
.step:last-child .step-right { padding-bottom: 0; }
|
||||||
.step-head {
|
.step-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
.step-emoji { font-size: 18px; }
|
.step-icon { color: $primary; font-size: 15px; }
|
||||||
.step-title { font-size: 14px; font-weight: 800; color: #333; }
|
.step-title { font-size: 14px; font-weight: 700; color: $text-strong; }
|
||||||
.step-desc { font-size: 11px; color: #999; margin-top: 1px; padding-left: 24px; }
|
.step-tag {
|
||||||
|
font-size: 10px;
|
||||||
/* 特色标签 */
|
|
||||||
.features-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 6px;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
.feature-tag {
|
|
||||||
background: rgba(255,255,255,0.85);
|
|
||||||
border: 1.5px solid #FFE4D0;
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--ai-primary, #FF6B35);
|
color: $primary;
|
||||||
white-space: nowrap;
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.step-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-muted;
|
||||||
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 亮点描述 */
|
/* ---------- 浮动 CTA ---------- */
|
||||||
.highlights {
|
.cta-fab {
|
||||||
background: rgba(255,255,255,0.88);
|
position: fixed;
|
||||||
border-radius: 16px;
|
left: 50%;
|
||||||
padding: 12px 14px;
|
transform: translateX(-50%);
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
|
width: calc(100% - 32px);
|
||||||
|
max-width: 398px;
|
||||||
|
z-index: 50;
|
||||||
|
text-align: center;
|
||||||
|
pointer-events: none; // 让蒙版区域不挡内容滚动
|
||||||
|
|
||||||
|
// 移动端:tabbar 上方
|
||||||
|
bottom: calc(64px + env(safe-area-inset-bottom));
|
||||||
|
|
||||||
|
// 桌面端:tabbar 隐藏,离底 24px
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
bottom: 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.hl-item {
|
.cta-btn {
|
||||||
|
pointer-events: auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
|
||||||
padding: 6px 0;
|
|
||||||
&:not(:last-child) { border-bottom: 1px dashed #FFE4D0; }
|
|
||||||
}
|
|
||||||
.hl-icon { font-size: 20px; flex-shrink: 0; }
|
|
||||||
.hl-text {
|
|
||||||
font-size: 13px;
|
|
||||||
color: #555;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 底部固定区 ---- */
|
|
||||||
.bottom-area {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 12px 16px 8px;
|
|
||||||
}
|
|
||||||
.start-btn {
|
|
||||||
display: flex !important;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px 0 !important;
|
padding: 14px 0;
|
||||||
font-size: 18px !important;
|
|
||||||
border-radius: 28px !important;
|
|
||||||
background: linear-gradient(135deg, #FF8C42, #FF6B35, #FF5722) !important;
|
|
||||||
box-shadow: 0 6px 24px rgba(255,107,53,0.35) !important;
|
|
||||||
letter-spacing: 2px;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: $gradient;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:active { transform: scale(0.98); opacity: 0.9; }
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38);
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
:deep(.anticon) { font-size: 18px; }
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 10px 28px rgba(99, 102, 241, 0.44);
|
||||||
|
}
|
||||||
|
&:active { transform: scale(0.98); opacity: 0.95; }
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
|
&:hover { transform: none; box-shadow: none; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.btn-icon { font-size: 20px; }
|
|
||||||
.slogan {
|
.slogan {
|
||||||
text-align: center;
|
pointer-events: auto;
|
||||||
|
margin: 8px 0 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--ai-primary, #FF6B35);
|
color: $primary;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-top: 8px;
|
|
||||||
letter-spacing: 1px;
|
letter-spacing: 1px;
|
||||||
|
text-shadow: 0 1px 4px rgba(248, 247, 252, 0.9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
<a-tag :color="child.status === 'enabled' ? 'green' : 'red'" size="small">
|
<a-tag :color="child.status === 'enabled' ? 'green' : 'red'" size="small">
|
||||||
{{ child.status === 'enabled' ? '正常' : '已禁用' }}
|
{{ child.status === 'enabled' ? '正常' : '已禁用' }}
|
||||||
</a-tag>
|
</a-tag>
|
||||||
<a-tag v-if="child.controlMode === 'restricted'" color="orange" size="small">受限模式</a-tag>
|
<a-tag v-if="child.controlMode === 'restricted'" color="purple" size="small">受限模式</a-tag>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="child-actions">
|
<div class="child-actions">
|
||||||
|
|||||||
@ -26,6 +26,14 @@
|
|||||||
<div v-else class="cover-placeholder">
|
<div v-else class="cover-placeholder">
|
||||||
<picture-outlined />
|
<picture-outlined />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 右下角 PIP:用户上传的原图 -->
|
||||||
|
<div
|
||||||
|
v-if="item.originalImageUrl && item.originalImageUrl !== item.coverUrl"
|
||||||
|
class="cover-pip"
|
||||||
|
title="原图"
|
||||||
|
>
|
||||||
|
<img :src="item.originalImageUrl" alt="原图" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h3>{{ item.title }}</h3>
|
<h3>{{ item.title }}</h3>
|
||||||
@ -65,6 +73,7 @@ interface FavoriteListItem {
|
|||||||
workId: number
|
workId: number
|
||||||
title: string
|
title: string
|
||||||
coverUrl?: string | null
|
coverUrl?: string | null
|
||||||
|
originalImageUrl?: string | null
|
||||||
likeCount?: number
|
likeCount?: number
|
||||||
viewCount?: number
|
viewCount?: number
|
||||||
}
|
}
|
||||||
@ -129,10 +138,36 @@ $primary: #6366f1;
|
|||||||
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
&:hover { box-shadow: 0 4px 20px rgba($primary, 0.1); transform: translateY(-2px); }
|
||||||
|
|
||||||
.card-cover {
|
.card-cover {
|
||||||
|
position: relative;
|
||||||
aspect-ratio: 3/4;
|
aspect-ratio: 3/4;
|
||||||
background: #f5f3ff;
|
background: #f5f3ff;
|
||||||
img { width: 100%; height: 100%; object-fit: cover; }
|
img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
|
.cover-placeholder { display: flex; align-items: center; justify-content: center; height: 100%; font-size: 28px; color: #d1d5db; }
|
||||||
|
|
||||||
|
/* 右下角 PIP:用户原图 */
|
||||||
|
.cover-pip {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 34%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 3px 10px rgba(15, 12, 41, 0.25);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .cover-pip {
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-body {
|
.card-body {
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="menu-item" @click="$router.push('/p/mine/favorites')">
|
<div class="menu-item" @click="$router.push('/p/mine/favorites')">
|
||||||
<div class="menu-icon" style="background: #fef3c7; color: #f59e0b">
|
<div class="menu-icon" style="background: #fce7f3; color: #ec4899">
|
||||||
<star-outlined />
|
<star-outlined />
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-content">
|
<div class="menu-content">
|
||||||
|
|||||||
@ -113,7 +113,7 @@ const statusLabel = (s: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = (s: string) => {
|
const statusColor = (s: string) => {
|
||||||
const map: Record<string, string> = { pending: 'orange', passed: 'green', rejected: 'red', withdrawn: 'default' }
|
const map: Record<string, string> = { pending: 'gold', passed: 'green', rejected: 'red', withdrawn: 'default' }
|
||||||
return map[s] || 'default'
|
return map[s] || 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,17 +2,39 @@
|
|||||||
<div class="work-detail-page">
|
<div class="work-detail-page">
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="loading">
|
||||||
<template v-if="work">
|
<template v-if="work">
|
||||||
<!-- 顶部信息 -->
|
<!-- 顶部 -->
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<a-button type="text" @click="$router.back()">
|
<button class="back-btn" @click="$router.back()">
|
||||||
<arrow-left-outlined /> 返回
|
<left-outlined />
|
||||||
</a-button>
|
</button>
|
||||||
<h1>{{ work.title }}</h1>
|
<h1>{{ work.title }}</h1>
|
||||||
<div class="header-actions" v-if="isOwner">
|
<span :class="['status-tag', work.status]">{{ statusTextMap[work.status] }}</span>
|
||||||
<a-button v-if="work.status === 'draft' || work.status === 'rejected'" type="primary" shape="round" size="small" @click="$router.push(`/p/works/${work.id}/publish`)">
|
</div>
|
||||||
发布作品
|
|
||||||
</a-button>
|
<!-- 拒绝原因(仅作者 + rejected)-->
|
||||||
<a-tag v-else :color="statusColorMap[work.status]">{{ statusTextMap[work.status] }}</a-tag>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -20,22 +42,36 @@
|
|||||||
<div class="book-reader" v-if="work.pages && work.pages.length > 0">
|
<div class="book-reader" v-if="work.pages && work.pages.length > 0">
|
||||||
<div class="page-display">
|
<div class="page-display">
|
||||||
<img v-if="currentPageData?.imageUrl" :src="currentPageData.imageUrl" :alt="`第${currentPageIndex + 1}页`" class="page-image" />
|
<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>
|
||||||
<div class="page-text" v-if="currentPageData?.text">
|
<div class="page-text" v-if="currentPageData?.text">
|
||||||
<p>{{ currentPageData.text }}</p>
|
{{ currentPageData.text }}
|
||||||
</div>
|
</div>
|
||||||
<div class="page-audio" v-if="currentPageData?.audioUrl">
|
<div class="page-audio" v-if="currentPageData?.audioUrl">
|
||||||
<audio :src="currentPageData.audioUrl" controls class="audio-player"></audio>
|
<audio :src="currentPageData.audioUrl" controls class="audio-player"></audio>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-nav">
|
<div class="page-nav">
|
||||||
<a-button :disabled="currentPageIndex === 0" @click="prevPage" shape="round">
|
<button class="nav-btn" :disabled="currentPageIndex === 0" @click="prevPage">
|
||||||
<left-outlined /> 上一页
|
<left-outlined />
|
||||||
</a-button>
|
</button>
|
||||||
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ work.pages.length }}</span>
|
<span class="page-indicator">{{ currentPageIndex + 1 }} / {{ work.pages.length }}</span>
|
||||||
<a-button :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage" shape="round">
|
<button class="nav-btn" :disabled="currentPageIndex === work.pages.length - 1" @click="nextPage">
|
||||||
下一页 <right-outlined />
|
<right-outlined />
|
||||||
</a-button>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 画作原图 -->
|
||||||
|
<div v-if="work.originalImageUrl" class="original-card">
|
||||||
|
<div class="original-thumb" @click="previewOriginal = work.originalImageUrl || ''">
|
||||||
|
<img :src="work.originalImageUrl" alt="画作原图" />
|
||||||
|
<div class="zoom-hint"><zoom-in-outlined /></div>
|
||||||
|
</div>
|
||||||
|
<div class="original-text">
|
||||||
|
<div class="original-title">画作原图</div>
|
||||||
|
<div class="original-desc">AI 根据这张画作生成的绘本</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,24 +88,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="work.description" class="description">{{ work.description }}</div>
|
<div v-if="work.description" class="description">{{ work.description }}</div>
|
||||||
<div v-if="work.tags?.length" class="tags-row">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- 互动栏 -->
|
<!-- 互动栏:仅在已发布作品上显示 -->
|
||||||
<div class="interaction-bar">
|
<div v-if="work.status === 'published'" class="interaction-bar">
|
||||||
<div
|
<div :class="['action-btn', { active: interaction.liked }]" @click="handleLike">
|
||||||
:class="['action-btn', { active: interaction.liked }]"
|
|
||||||
@click="handleLike"
|
|
||||||
>
|
|
||||||
<heart-filled v-if="interaction.liked" />
|
<heart-filled v-if="interaction.liked" />
|
||||||
<heart-outlined v-else />
|
<heart-outlined v-else />
|
||||||
<span>{{ displayLikeCount }}</span>
|
<span>{{ displayLikeCount }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div :class="['action-btn', { active: interaction.favorited }]" @click="handleFavorite">
|
||||||
:class="['action-btn', { active: interaction.favorited }]"
|
|
||||||
@click="handleFavorite"
|
|
||||||
>
|
|
||||||
<star-filled v-if="interaction.favorited" />
|
<star-filled v-if="interaction.favorited" />
|
||||||
<star-outlined v-else />
|
<star-outlined v-else />
|
||||||
<span>{{ displayFavoriteCount }}</span>
|
<span>{{ displayFavoriteCount }}</span>
|
||||||
@ -79,10 +109,103 @@
|
|||||||
<span>{{ work.viewCount || 0 }}</span>
|
<span>{{ work.viewCount || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<a-empty v-else-if="!loading" description="作品不存在" style="padding: 80px 0" />
|
<a-empty v-else-if="!loading" description="作品不存在" style="padding: 80px 0" />
|
||||||
</a-spin>
|
</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>
|
||||||
|
|
||||||
|
<!-- 原图全屏预览 -->
|
||||||
|
<Transition name="fade">
|
||||||
|
<div v-if="previewOriginal" class="preview-overlay" @click="previewOriginal = ''">
|
||||||
|
<img :src="previewOriginal" class="preview-full-img" alt="画作原图" />
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -91,27 +214,49 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
ArrowLeftOutlined, LeftOutlined, RightOutlined,
|
LeftOutlined, RightOutlined,
|
||||||
HeartOutlined, HeartFilled, StarOutlined, StarFilled, EyeOutlined,
|
HeartOutlined, HeartFilled,
|
||||||
|
StarOutlined, StarFilled,
|
||||||
|
EyeOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
WarningFilled,
|
||||||
|
InfoCircleOutlined,
|
||||||
|
SendOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
UndoOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
ZoomInOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} 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'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const workId = Number(route.params.id)
|
const workId = Number(route.params.id)
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
const work = ref<UserWork | null>(null)
|
const work = ref<UserWork | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const currentPageIndex = ref(0)
|
const currentPageIndex = ref(0)
|
||||||
const interaction = ref({ liked: false, favorited: false })
|
const interaction = ref({ liked: false, favorited: false })
|
||||||
const actionLoading = ref(false)
|
const actionLoading = ref(false)
|
||||||
|
const previewOriginal = ref('')
|
||||||
|
|
||||||
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
|
const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null)
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
const isLoggedIn = computed(() => !!localStorage.getItem('public_token'))
|
||||||
|
|
||||||
const isOwner = computed(() => {
|
const isOwner = computed(() => {
|
||||||
|
// dev mock 模式:mock 作品默认是当前用户作品
|
||||||
|
if (isDev && work.value && isMockWorkId(work.value.id)) return true
|
||||||
const u = localStorage.getItem('public_user')
|
const u = localStorage.getItem('public_user')
|
||||||
if (!u || !work.value) return false
|
if (!u || !work.value) return false
|
||||||
try { return JSON.parse(u).id === work.value.userId } catch { return false }
|
try { return JSON.parse(u).id === work.value.userId } catch { return false }
|
||||||
@ -121,10 +266,12 @@ const displayLikeCount = computed(() => work.value?.likeCount || 0)
|
|||||||
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
|
const displayFavoriteCount = computed(() => work.value?.favoriteCount || 0)
|
||||||
|
|
||||||
const statusTextMap: Record<string, string> = {
|
const statusTextMap: Record<string, string> = {
|
||||||
draft: '草稿', pending_review: '审核中', published: '已发布', rejected: '被拒绝', taken_down: '已下架',
|
draft: '草稿',
|
||||||
}
|
unpublished: '未发布',
|
||||||
const statusColorMap: Record<string, string> = {
|
pending_review: '审核中',
|
||||||
draft: 'default', pending_review: 'orange', published: 'green', rejected: 'red', taken_down: 'default',
|
published: '已发布',
|
||||||
|
rejected: '被拒绝',
|
||||||
|
taken_down: '已下架',
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
||||||
@ -132,11 +279,11 @@ const formatDate = (d: string) => dayjs(d).format('YYYY-MM-DD')
|
|||||||
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
const prevPage = () => { if (currentPageIndex.value > 0) currentPageIndex.value-- }
|
||||||
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
const nextPage = () => { if (work.value?.pages && currentPageIndex.value < work.value.pages.length - 1) currentPageIndex.value++ }
|
||||||
|
|
||||||
|
// ─── 互动 ───
|
||||||
const handleLike = async () => {
|
const handleLike = async () => {
|
||||||
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
if (!isLoggedIn.value) { router.push('/p/login'); return }
|
||||||
if (actionLoading.value) return
|
if (actionLoading.value) return
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
// 乐观更新
|
|
||||||
const wasLiked = interaction.value.liked
|
const wasLiked = interaction.value.liked
|
||||||
interaction.value.liked = !wasLiked
|
interaction.value.liked = !wasLiked
|
||||||
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
|
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? -1 : 1)
|
||||||
@ -145,7 +292,6 @@ const handleLike = async () => {
|
|||||||
interaction.value.liked = res.liked
|
interaction.value.liked = res.liked
|
||||||
if (work.value) work.value.likeCount = res.likeCount
|
if (work.value) work.value.likeCount = res.likeCount
|
||||||
} catch {
|
} catch {
|
||||||
// 回滚
|
|
||||||
interaction.value.liked = wasLiked
|
interaction.value.liked = wasLiked
|
||||||
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
|
if (work.value) work.value.likeCount = (work.value.likeCount || 0) + (wasLiked ? 1 : -1)
|
||||||
message.error('操作失败')
|
message.error('操作失败')
|
||||||
@ -174,8 +320,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 () => {
|
const fetchWork = async () => {
|
||||||
loading.value = true
|
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 {
|
||||||
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
// 优先尝试广场接口(公开作品),失败再尝试自己作品库
|
||||||
try {
|
try {
|
||||||
@ -183,14 +488,23 @@ const fetchWork = async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
work.value = await publicUserWorksApi.detail(workId)
|
work.value = await publicUserWorksApi.detail(workId)
|
||||||
}
|
}
|
||||||
// 已登录时获取交互状态
|
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
try {
|
try {
|
||||||
interaction.value = await publicInteractionApi.getInteraction(workId)
|
interaction.value = await publicInteractionApi.getInteraction(workId)
|
||||||
} catch { /* 忽略 */ }
|
} catch { /* 忽略 */ }
|
||||||
}
|
}
|
||||||
} 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -201,45 +515,216 @@ onMounted(fetchWork)
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
$primary: #6366f1;
|
$primary: #6366f1;
|
||||||
|
$accent: #ec4899;
|
||||||
|
|
||||||
.work-detail-page {
|
.work-detail-page {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding-bottom: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 顶部 ---------- */
|
||||||
.detail-header {
|
.detail-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
margin-bottom: 16px;
|
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; }
|
||||||
|
|
||||||
|
/* ---------- 画作原图卡片 ---------- */
|
||||||
|
.original-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgba($primary, 0.06);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||||
|
}
|
||||||
|
.original-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 84px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid rgba($primary, 0.18);
|
||||||
|
cursor: zoom-in;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #f5f3ff;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: $primary;
|
||||||
|
transform: scale(1.03);
|
||||||
|
.zoom-hint { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom-hint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 12, 41, 0.4);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.original-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.original-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1b4b;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.original-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 全屏原图预览 ---------- */
|
||||||
|
.preview-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999;
|
||||||
|
background: rgba(15, 12, 41, 0.88);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
.preview-full-img {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.fade-enter-active,
|
||||||
|
.fade-leave-active { transition: opacity 0.2s; }
|
||||||
|
.fade-enter-from,
|
||||||
|
.fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* ---------- 绘本阅读器 ---------- */
|
||||||
.book-reader {
|
.book-reader {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 14px;
|
||||||
border: 1px solid rgba($primary, 0.06);
|
border: 1px solid rgba($primary, 0.06);
|
||||||
|
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||||
|
|
||||||
// 不固定宽高比:横图/竖图均以「长边」受 max 约束,避免 3:4 框导致横图上下大片留白
|
|
||||||
.page-display {
|
.page-display {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 160px;
|
min-height: 200px;
|
||||||
padding: 8px 12px;
|
background: #1e1b4b;
|
||||||
box-sizing: border-box;
|
|
||||||
background: #f8f7fc;
|
|
||||||
|
|
||||||
.page-image {
|
.page-image {
|
||||||
display: block;
|
display: block;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
// 长边优先:横图限宽、竖图限高,另一维按比例收缩
|
|
||||||
max-height: min(72vh, 85vw);
|
max-height: min(72vh, 85vw);
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
@ -250,14 +735,17 @@ $primary: #6366f1;
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
color: #d1d5db;
|
color: rgba(255, 255, 255, 0.3);
|
||||||
font-size: 14px;
|
font-size: 36px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-text {
|
.page-text {
|
||||||
padding: 16px 20px;
|
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 {
|
.page-audio {
|
||||||
@ -270,17 +758,51 @@ $primary: #6366f1;
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 20px;
|
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 {
|
.info-section {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 18px 20px;
|
padding: 16px 18px;
|
||||||
border: 1px solid rgba($primary, 0.06);
|
border: 1px solid rgba($primary, 0.06);
|
||||||
|
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
|
||||||
.author-row {
|
.author-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -293,36 +815,55 @@ $primary: #6366f1;
|
|||||||
.create-time { font-size: 11px; color: #9ca3af; }
|
.create-time { font-size: 11px; color: #9ca3af; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.description { font-size: 13px; color: #4b5563; line-height: 1.6; margin-bottom: 10px; }
|
.description {
|
||||||
.tags-row { display: flex; gap: 6px; flex-wrap: wrap; }
|
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 {
|
.interaction-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
margin-top: 16px;
|
margin-bottom: 14px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 14px 0;
|
padding: 12px 0;
|
||||||
border: 1px solid rgba($primary, 0.06);
|
border: 1px solid rgba($primary, 0.06);
|
||||||
|
box-shadow: 0 2px 12px rgba($primary, 0.05);
|
||||||
|
|
||||||
.action-btn {
|
.action-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 20px;
|
padding: 8px 18px;
|
||||||
border-radius: 24px;
|
border-radius: 22px;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
span {
|
span { font-size: 13px; font-weight: 500; }
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba($primary, 0.04);
|
background: rgba($primary, 0.04);
|
||||||
@ -330,14 +871,10 @@ $primary: #6366f1;
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
color: #ec4899;
|
color: $accent;
|
||||||
|
&:hover { background: rgba($accent, 0.06); }
|
||||||
&:hover {
|
|
||||||
background: rgba(236, 72, 153, 0.06);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 点赞动效
|
|
||||||
&.active :deep(.anticon) {
|
&.active :deep(.anticon) {
|
||||||
animation: pop 0.3s ease;
|
animation: pop 0.3s ease;
|
||||||
}
|
}
|
||||||
@ -349,4 +886,81 @@ $primary: #6366f1;
|
|||||||
50% { transform: scale(1.3); }
|
50% { transform: scale(1.3); }
|
||||||
100% { transform: scale(1); }
|
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>
|
</style>
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
<div v-if="loading" class="loading-wrap"><a-spin /></div>
|
||||||
|
|
||||||
<div v-else-if="works.length === 0" class="empty-wrap">
|
<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-button type="primary" shape="round" @click="$router.push('/p/create')">开始创作</a-button>
|
||||||
</a-empty>
|
</a-empty>
|
||||||
</div>
|
</div>
|
||||||
@ -30,10 +30,19 @@
|
|||||||
<div v-else class="works-grid">
|
<div v-else class="works-grid">
|
||||||
<div v-for="work in works" :key="work.id" class="work-card" @click="$router.push(`/p/works/${work.id}`)">
|
<div v-for="work in works" :key="work.id" class="work-card" @click="$router.push(`/p/works/${work.id}`)">
|
||||||
<div class="work-cover">
|
<div class="work-cover">
|
||||||
|
<!-- 大图:AI 生成的绘本封面 -->
|
||||||
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title" />
|
<img v-if="work.coverUrl" :src="work.coverUrl" :alt="work.title" />
|
||||||
<div v-else class="cover-placeholder">
|
<div v-else class="cover-placeholder">
|
||||||
<picture-outlined />
|
<picture-outlined />
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 右下角 PIP:用户上传的原图 -->
|
||||||
|
<div
|
||||||
|
v-if="work.originalImageUrl && work.originalImageUrl !== work.coverUrl"
|
||||||
|
class="cover-pip"
|
||||||
|
:title="'原图'"
|
||||||
|
>
|
||||||
|
<img :src="work.originalImageUrl" alt="原图" />
|
||||||
|
</div>
|
||||||
<div class="work-status-tag" :class="work.status">
|
<div class="work-status-tag" :class="work.status">
|
||||||
{{ statusTextMap[work.status] || work.status }}
|
{{ statusTextMap[work.status] || work.status }}
|
||||||
</div>
|
</div>
|
||||||
@ -65,22 +74,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
import { PlusOutlined, PictureOutlined } from '@ant-design/icons-vue'
|
import { PlusOutlined, PictureOutlined } from '@ant-design/icons-vue'
|
||||||
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
import { publicUserWorksApi, type UserWork } from '@/api/public'
|
||||||
|
import { MOCK_USER_WORKS } from './_dev-mock'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
|
|
||||||
|
const isDev = import.meta.env.DEV
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const works = ref<UserWork[]>([])
|
const works = ref<UserWork[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = 12
|
const pageSize = 12
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const activeTab = ref('')
|
|
||||||
|
// 合法的 tab key,防止 query 注入非法值
|
||||||
|
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
|
||||||
|
: ''
|
||||||
|
const activeTab = ref(initialTab)
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: '', label: '全部' },
|
{ key: '', label: '全部' },
|
||||||
{ key: 'draft', label: '草稿' },
|
{ key: 'draft', label: '草稿' },
|
||||||
|
{ key: 'unpublished', label: '未发布' },
|
||||||
{ key: 'pending_review', label: '审核中' },
|
{ key: 'pending_review', label: '审核中' },
|
||||||
{ key: 'published', label: '已发布' },
|
{ key: 'published', label: '已发布' },
|
||||||
{ key: 'rejected', label: '被拒绝' },
|
{ key: 'rejected', label: '被拒绝' },
|
||||||
@ -88,6 +111,7 @@ const tabs = [
|
|||||||
|
|
||||||
const statusTextMap: Record<string, string> = {
|
const statusTextMap: Record<string, string> = {
|
||||||
draft: '草稿',
|
draft: '草稿',
|
||||||
|
unpublished: '未发布',
|
||||||
pending_review: '审核中',
|
pending_review: '审核中',
|
||||||
published: '已发布',
|
published: '已发布',
|
||||||
rejected: '被拒绝',
|
rejected: '被拒绝',
|
||||||
@ -96,6 +120,17 @@ const statusTextMap: Record<string, string> = {
|
|||||||
|
|
||||||
const formatDate = (d: string) => dayjs(d).format('MM-DD HH:mm')
|
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 () => {
|
const fetchWorks = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@ -107,7 +142,16 @@ const fetchWorks = async () => {
|
|||||||
works.value = res.list
|
works.value = res.list
|
||||||
total.value = res.total
|
total.value = res.total
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -116,9 +160,24 @@ const fetchWorks = async () => {
|
|||||||
const switchTab = (key: string) => {
|
const switchTab = (key: string) => {
|
||||||
activeTab.value = key
|
activeTab.value = key
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
// 同步到 URL,刷新或分享链接能保持当前 tab
|
||||||
|
router.replace({ query: { ...route.query, tab: key || undefined } })
|
||||||
fetchWorks()
|
fetchWorks()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 响应外部跳转带来的 query.tab 变化(如从 EditInfoView 跳过来)
|
||||||
|
watch(
|
||||||
|
() => route.query.tab,
|
||||||
|
(newTab) => {
|
||||||
|
const t = typeof newTab === 'string' && VALID_TABS.includes(newTab) ? newTab : ''
|
||||||
|
if (t !== activeTab.value) {
|
||||||
|
activeTab.value = t
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchWorks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(fetchWorks)
|
onMounted(fetchWorks)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -211,12 +270,39 @@ $primary: #6366f1;
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
&.draft { background: rgba(107,114,128,0.8); color: #fff; }
|
&.draft { background: rgba(107,114,128,0.85); color: #fff; }
|
||||||
&.pending_review { background: rgba(245,158,11,0.9); color: #fff; }
|
&.unpublished { background: rgba(99,102,241,0.9); color: #fff; }
|
||||||
&.published { background: rgba(16,185,129,0.9); color: #fff; }
|
&.pending_review { background: rgba(245,158,11,0.92); color: #fff; }
|
||||||
&.rejected { background: rgba(239,68,68,0.9); color: #fff; }
|
&.published { background: rgba(16,185,129,0.92); color: #fff; }
|
||||||
&.taken_down { background: rgba(107,114,128,0.8); color: #fff; }
|
&.rejected { background: rgba(239,68,68,0.92); color: #fff; }
|
||||||
|
&.taken_down { background: rgba(107,114,128,0.85); color: #fff; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 右下角 PIP:用户原图 */
|
||||||
|
.cover-pip {
|
||||||
|
position: absolute;
|
||||||
|
right: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 34%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid #fff;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 3px 10px rgba(15, 12, 41, 0.25);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .cover-pip {
|
||||||
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.work-info {
|
.work-info {
|
||||||
|
|||||||
@ -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: mockCover(40),
|
||||||
|
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: null,
|
||||||
|
description: null,
|
||||||
|
visibility: 'private',
|
||||||
|
status: 'draft',
|
||||||
|
reviewNote: null,
|
||||||
|
originalImageUrl: mockCover(60),
|
||||||
|
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: mockCover(80),
|
||||||
|
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: mockCover(120),
|
||||||
|
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: mockCover(160),
|
||||||
|
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