diff --git a/docs/api/public-leai-work-form.md b/docs/api/public-leai-work-form.md new file mode 100644 index 0000000..310504e --- /dev/null +++ b/docs/api/public-leai-work-form.md @@ -0,0 +1,169 @@ +# 乐读派作品表单(本库快照)API + +面向外部系统与本平台前端「创作壳」对齐:**不经过乐读派 B2 的 GET/PUT 作品详情**,直接读写本库 `t_ugc_work` / `t_ugc_work_page` 中的编目与分页快照。 + +| 项 | 说明 | +|----|------| +| Base URL | 部署域名 + **context-path `/api`**(见 `application.yml` `server.servlet.context-path`) | +| 完整路径前缀 | `/api/public/leai-works` | +| 认证 | **需要登录**。请求头携带 `Authorization: Bearer `;与 Web 端一致时可加 `X-Tenant-Code`、`X-Tenant-Id`(见前端 `request.ts`) | +| 安全说明 | 该路径**不在** Spring Security 匿名白名单中,未带有效 Token 将返回 **401** | + +--- + +## 1. 保存编目 / 分页快照 + +### 请求 + +| 项 | 值 | +|----|-----| +| Method | **PUT** | +| URL | `/api/public/leai-works/{remoteWorkId}/work-form` | +| Content-Type | `application/json` | + +### 路径参数 + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `remoteWorkId` | string | 是 | 乐读派侧作品 ID(可能为大整数,**建议按字符串传输**;路径中需 URL 编码) | + +**示例** + +```http +PUT /api/public/leai-works/2044362486899150848/work-form HTTP/1.1 +Host: +Authorization: Bearer +Content-Type: application/json + +{ + "title": "绘本标题", + "author": "作者署名", + "subtitle": "副标题", + "intro": "简介", + "tags": ["冒险", "成长"], + "pageList": [ + { + "pageNum": 1, + "imageUrl": "https://...", + "text": "本页文案", + "audioUrl": "https://..." + } + ] +} +``` + +### 请求体(JSON 对象) + +- **不能为空**:`{}` 会返回 **400**(`请求体不能为空`)。 +- 服务端从 body 中读取两类数据: + 1. **编目字段**(可选子集):仅处理以下键,其余顶层键若存在会被忽略(除 `pageList` 外): + `author`、`title`、`subtitle`、`intro`、`tags` + 2. **分页**:`pageList` 为数组时,按乐读派 `pageList` 形态写入本库分页表。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `title` | string | 标题 | +| `author` | string | 作者署名 | +| `subtitle` | string | 副标题 | +| `intro` | string | 简介 | +| `tags` | string[] | 标签列表 | +| `pageList` | object[] | 分页列表;元素字段见下表 | + +`pageList` 元素: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `pageNum` | number | 页码(与后端 `page_no` 对应) | +| `imageUrl` | string | 插图 URL | +| `text` | string | 本页文案 | +| `audioUrl` | string | 配音 URL;可为空字符串表示未配 | + +### 业务规则(摘要) + +- 作品必须属于**当前登录用户**且 `remote_work_id` 匹配,否则 **404**(`作品不存在或无权操作`)。 +- 若本次请求更新了编目,且作品为草稿、且乐读派进度已 ≥「生成完成」,可能将发布状态从草稿置为未发布(见 `PublicLeaiWorkFormService`)。 +- 若 `pageList` 非空,且**每一页**的 `audioUrl` 均为非空白字符串,则将 `leai_status` 更新为 **已配音(5)**。 + +### 成功响应 + +HTTP **200**,Body 为统一包装: + +```json +{ + "code": 200, + "message": "success", + "data": null +} +``` + +(`data` 可能为 `null`,以实际返回为准。) + +### 错误响应 + +| HTTP | code(body) | 典型 message | +|------|----------------|--------------| +| 400 | 400 | `remoteWorkId 无效` / `请求体不能为空` | +| 401 | 401 | 未认证或 Token 无效 | +| 404 | 404 | `作品不存在或无权操作` | + +错误体结构与项目统一 `Result` 一致(含 `message` 等字段)。 + +--- + +## 2. 查询本库作品详情(建议与 PUT 配合:先 GET 再改后 PUT) + +便于外部系统做「读-改-写」合并,避免只提交部分字段时覆盖丢失其它字段。 + +| 项 | 值 | +|----|-----| +| Method | **GET** | +| URL | `/api/public/leai-works/{remoteWorkId}/work-form` | +| 认证 | 同 PUT,需 **Bearer JWT** | + +### 成功响应 `data` 结构(对象) + +| 字段 | 类型 | 说明 | +|------|------|------| +| `workId` | string | 与路径中 `remoteWorkId` 一致(乐读派 ID) | +| `status` | number | 乐读派创作进度 `leai_status`,见下表 | +| `title` | string | 标题 | +| `author` | string | 作者 | +| `coverUrl` | string | 封面 URL | +| `subtitle` | string | 副标题 | +| `intro` | string | 简介 | +| `tags` | array | 标签 | +| `pageList` | object[] | 分页;元素含 `pageNum`、`imageUrl`、`text`、`audioUrl` | + +### `status`(`leai_status`)取值参考 + +| 值 | 含义 | +|----|------| +| -1 | 失败 | +| 0 | 草稿 | +| 1 | 待处理 | +| 2 | 处理中 | +| 3 | 生成完成 | +| 4 | 已编目 | +| 5 | 已配音 | + +(定义见后端 `LeaiCreationStatus`。) + +--- + +## 3. 对接建议 + +1. **大整数 ID**:`remoteWorkId` 在部分语言中超出 IEEE754 安全整数,**全程使用字符串**。 +2. **合并提交**:若只改编目,可先 **GET** 全量,在内存中覆盖 `author`/`subtitle`/`intro`/`tags`/`title` 等字段后再 **PUT** 全量,与当前前端「快照 + 覆盖」策略一致,避免清空 `pageList`。 +3. **仅改分页**:可只带 `pageList`(及必须的非空 body);编目键可选。 +4. **Swagger**:若服务开启 Knife4j,可在 `/doc.html` 查看 **公众端-乐读派作品表单** 分组(以部署为准)。 + +--- + +## 4. 实现位置(源码索引) + +| 说明 | 路径 | +|------|------| +| Controller | `lesingle-creation-backend/.../pub/controller/PublicLeaiWorkController.java` | +| 业务 | `lesingle-creation-backend/.../pub/service/PublicLeaiWorkFormService.java` | + +文档版本与接口行为以服务端代码为准;若行为变更请同步更新本文档。 diff --git a/lesingle-creation-frontend/src/views/public/create/views/DubbingView.vue b/lesingle-creation-frontend/src/views/public/create/views/DubbingView.vue index 2138e16..6d0b99a 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/DubbingView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/DubbingView.vue @@ -146,18 +146,28 @@ import { ArrowRightOutlined, } from '@ant-design/icons-vue' import PageHeader from '@/components/aicreate/PageHeader.vue' -import { voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api/aicreate' +import { voicePage, ossUpload } from '@/api/aicreate' import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public' -import { useAicreateStore } from '@/stores/aicreate' -import { STATUS, getRouteByStatus } from '@/utils/aicreate/status' const router = useRouter() const route = useRoute() -const store = useAicreateStore() -const workId = computed(() => route.params.workId || store.workId) +const workId = computed(() => route.params.workId) const loading = ref(true) const submitting = ref(false) + +/** getLeaiWorkFormDetail 完整快照;提交时在快照上覆盖当前页的 pageList(及配音结果),整包提交避免丢失编目等字段 */ +const workFormSnapshot = ref | null>(null) + +function cloneWorkFormSnapshot(raw: Record | null) { + if (raw == null) return null + try { + return JSON.parse(JSON.stringify(raw)) as Record + } catch { + return null + } +} + const pages = ref([]) const idx = ref(0) const toast = ref('') @@ -430,23 +440,22 @@ async function voiceAllConfirm() { } } -/** 乐读派分页形态 + 可选编目字段,写入本库 t_ugc_work / t_ugc_work_page */ +/** 详情快照 + 当前分页/配音,整包 PUT,避免只传 pageList 时清空编目等字段 */ function buildWorkFormPayload() { - const pageList = pages.value.map(p => ({ + const base = workFormSnapshot.value + if (!base) { + throw new Error('作品详情未加载') + } + const body = cloneWorkFormSnapshot(base) + if (!body) { + throw new Error('数据异常') + } + body.pageList = pages.value.map(p => ({ pageNum: p.pageNum, imageUrl: p.imageUrl, text: p.text, audioUrl: p.audioUrl || undefined, })) - const wd = store.workDetail - const body = { pageList } as Record - if (wd) { - if (wd.author) body.author = wd.author - if (wd.subtitle != null && wd.subtitle !== '') body.subtitle = wd.subtitle - if (wd.intro != null && wd.intro !== '') body.intro = wd.intro - if (Array.isArray(wd.tags) && wd.tags.length) body.tags = [...wd.tags] - if (wd.title) body.title = wd.title - } return body } @@ -457,25 +466,23 @@ async function finish() { const pendingLocal = pages.value.filter(p => p.localBlob) if (pendingLocal.length > 0) { - const audioPages = [] for (let i = 0; i < pendingLocal.length; i++) { const p = pendingLocal[i] showToast(`上传录音 ${i + 1}/${pendingLocal.length}…`) const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a' const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext }) - audioPages.push({ pageNum: p.pageNum, audioUrl: ossUrl }) p.audioUrl = ossUrl p.localBlob = null } showToast('保存配音…') - await batchUpdateAudio(workId.value, audioPages) } else { showToast('完成配音…') - await finishDubbing(workId.value) } - await saveLeaiWorkForm(String(workId.value || ''), buildWorkFormPayload()) - store.workDetail = null + // 与编目页一致:仅 PUT /public/leai-works/{remoteWorkId}/work-form 写入本库分页与配音,不走 POST /leai-proxy/batch-audio + const payload = buildWorkFormPayload() + await saveLeaiWorkForm(String(workId.value || ''), payload) + workFormSnapshot.value = cloneWorkFormSnapshot(payload) showToast('配音完成') setTimeout( () => @@ -489,12 +496,14 @@ async function finish() { try { const check = await getLeaiWorkFormDetail(String(workId.value || '')) if (check?.status >= 5) { + workFormSnapshot.value = cloneWorkFormSnapshot(check as Record) try { - await saveLeaiWorkForm(String(workId.value || ''), buildWorkFormPayload()) + const retryPayload = buildWorkFormPayload() + await saveLeaiWorkForm(String(workId.value || ''), retryPayload) + workFormSnapshot.value = cloneWorkFormSnapshot(retryPayload) } catch { /* 本库无记录时忽略 */ } - store.workDetail = null showToast('配音已完成') setTimeout( () => @@ -515,23 +524,11 @@ async function finish() { // --- Load --- async function loadWork() { - store.reset(); loading.value = true try { - if (!store.workDetail || store.workDetail.workId !== workId.value) { - store.workDetail = null - const res = await getLeaiWorkFormDetail(String(workId.value || '')) - store.workDetail = res - } - const w = store.workDetail - - // 仅当尚未编目完成(CATALOGED)时按流程跳转(如 COMPLETED→预览);已 DUBBED 仍允许停留本页, - // 否则从编辑页点「去配音」会立刻被 getRouteByStatus(DUBBED→EditInfo) 打回当前页。 - if (w.status < STATUS.CATALOGED) { - const wid = String(w.workId ?? workId.value ?? '') - const nextRoute = getRouteByStatus(w.status, wid) - if (nextRoute) { router.replace(nextRoute); return } - } + const res = await getLeaiWorkFormDetail(String(workId.value || '')) + workFormSnapshot.value = cloneWorkFormSnapshot(res as Record) + const w = res as any pages.value = (w.pageList || []).map((p: any) => ({ pageNum: p.pageNum, @@ -541,7 +538,9 @@ async function loadWork() { localBlob: null, isAiVoice: p.audioUrl ? true : null, })) - } catch { /* fallback */ } + } catch { + workFormSnapshot.value = null + } loading.value = false } diff --git a/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue b/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue index 6e8cc63..eff272f 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue @@ -14,7 +14,7 @@ export default { name: 'EditInfoView' }
-
{{ store.workDetail?.title || '未命名' }}
+
{{ catalogMeta.title || '未命名' }}
@@ -132,14 +132,10 @@ import { import { message } from 'ant-design-vue' import PageHeader from '@/components/aicreate/PageHeader.vue' import { getLeaiWorkFormDetail, publicUserWorksApi, saveLeaiWorkForm } from '@/api/public' -import { useAicreateStore } from '@/stores/aicreate' -import { clearExtractDraft } from '@/utils/aicreate/extractDraft' -import { STATUS, getRouteByStatus } from '@/utils/aicreate/status' const router = useRouter() const route = useRoute() -const store = useAicreateStore() -const workId = computed(() => route.params.workId || store.workId) +const workId = computed(() => route.params.workId) /** 乐读派 workId 可能超过 JS 安全整数,请求与比较一律用路由字符串 */ function resolvedWorkIdStr() { @@ -152,12 +148,28 @@ const loading = ref(true) const processing = ref(false) const coverUrl = ref('') +/** 只读:详情接口中的非表单项(封面标题、流程状态),不参与 v-model */ +const catalogMeta = ref({ title: '', status: 0 }) + +/** 仅编辑:作者 / 副标题 / 简介 / 标签(与 saveLeaiWorkForm 编目字段一致) */ const form = ref({ author: '', subtitle: '', intro: '' }) const selectedTags = ref([]) const addingTag = ref(false) const newTag = ref('') const tagInput = ref(null) +/** getLeaiWorkFormDetail 返回的完整快照(深拷贝),保存时用表单同名字段覆盖后再整包提交,避免丢失 pageList 等未编辑字段 */ +const workFormSnapshot = ref(null) + +function cloneWorkFormSnapshot(raw) { + if (raw == null) return null + try { + return JSON.parse(JSON.stringify(raw)) + } catch { + return null + } +} + const PRESET_TAGS = ['冒险', '成长', '友谊', '魔法', '勇敢', '快乐', '温暖', '探索', '梦想', '好奇'] const availablePresets = computed(() => PRESET_TAGS.filter(t => !selectedTags.value.includes(t)) @@ -187,6 +199,21 @@ function confirmAddTag() { addingTag.value = false } +/** 将详情接口数据灌入表单与只读展示(本页不持有整份 workDetail,仅映射所需字段) */ +function applyDetailToForm(w) { + catalogMeta.value = { + title: w.title || '', + status: Number(w.status) || 0, + } + form.value = { + author: w.author || '', + subtitle: w.subtitle || '', + intro: w.intro || '', + } + selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险'] + coverUrl.value = w.coverUrl || w.pageList?.[0]?.imageUrl || '' +} + async function loadWork() { const id = resolvedWorkIdStr() if (!id) { @@ -195,24 +222,11 @@ async function loadWork() { } loading.value = true try { - // 必须每次拉取详情:创作壳 keep-alive 会缓存组件,仅靠 store 命中会跳过请求,二次进入表单仍是旧值 - store.workDetail = null const res = await getLeaiWorkFormDetail(id) - store.workDetail = res - const w = store.workDetail - - // 已配音(DUBBED)仍可在本页编辑元数据/发布;仅当状态高于当前流程终态时再按 status 跳转 - if (w.status > STATUS.DUBBED) { - const nextRoute = getRouteByStatus(w.status, id) - if (nextRoute) { router.replace(nextRoute); return } - } - - 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.coverUrl || w.pageList?.[0]?.imageUrl || '' + workFormSnapshot.value = cloneWorkFormSnapshot(res) + applyDetailToForm(res) } catch (e) { + workFormSnapshot.value = null // fallback: proceed with empty form } finally { loading.value = false @@ -241,19 +255,28 @@ async function saveFormToServer() { message.error('作品 ID 无效') return false } - const data = { tags: selectedTags.value } - data.author = form.value.author.trim() - data.subtitle = form.value.subtitle.trim() - data.intro = form.value.intro.trim() - - await saveLeaiWorkForm(id, data) - - if (store.workDetail) { - store.workDetail.author = data.author - store.workDetail.subtitle = data.subtitle - store.workDetail.intro = data.intro - store.workDetail.tags = [...selectedTags.value] + const base = workFormSnapshot.value + if (!base || typeof base !== 'object') { + message.error('作品详情未加载,请刷新后重试') + return false } + const payload = cloneWorkFormSnapshot(base) + if (!payload) { + message.error('数据异常,请刷新后重试') + return false + } + payload.author = form.value.author.trim() + payload.subtitle = form.value.subtitle.trim() + payload.intro = form.value.intro.trim() + payload.tags = [...selectedTags.value] + + await saveLeaiWorkForm(id, payload) + + workFormSnapshot.value = cloneWorkFormSnapshot(payload) + form.value.author = payload.author + form.value.subtitle = payload.subtitle + form.value.intro = payload.intro + selectedTags.value = Array.isArray(payload.tags) ? [...payload.tags] : [...selectedTags.value] return true } catch (e) { message.error(e.message || '保存失败,请重试') @@ -267,7 +290,6 @@ async function handleSave() { processing.value = true try { if (await saveFormToServer()) { - store.workDetail = null router.push({ name: 'PublicCreateSaveSuccess', params: { workId: resolvedWorkIdStr() }, @@ -284,7 +306,6 @@ async function handleGoDubbing() { processing.value = true try { if (await saveFormToServer()) { - store.workDetail = null router.push(`/p/create/dubbing/${resolvedWorkIdStr()}`) } } finally { @@ -336,7 +357,6 @@ async function handlePublish() { } } - store.workDetail = null message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度') router.push({ name: 'PublicCreateSaveSuccess', @@ -360,8 +380,6 @@ watch( ) onActivated(() => { - store.reset(); - clearExtractDraft() loadWork() nextTick(() => { if (tagInput.value) tagInput.value.focus() }) })