From fe210b52eed17305e6ce05bfcd700263365b8e26 Mon Sep 17 00:00:00 2001 From: zhonghua Date: Mon, 20 Apr 2026 12:41:24 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=85=AC=E4=BC=97=E7=AB=AF=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E9=93=BE=E8=B7=AF=E6=81=A2=E5=A4=8D=E4=B8=8E=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8A=A0=E8=BD=BD=EF=BC=88resumeWorkId=E3=80=81onMoun?= =?UTF-8?q?ted=E3=80=81extract=20=E8=A7=A3=E6=9E=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Made-with: Cursor --- .../pub/service/PublicGalleryService.java | 2 + lesingle-creation-frontend/src/api/public.ts | 2 + .../src/composables/useAicreateCreation.ts | 149 +++++++++++++++ .../src/stores/aicreate.ts | 176 +----------------- .../src/utils/aicreate/extractDraft.ts | 60 ------ .../src/utils/aicreate/resumeLeaiWork.ts | 53 ++---- .../src/utils/aicreate/runCreateShellEntry.ts | 83 +++++++++ .../src/utils/aicreate/status.ts | 36 +++- .../src/views/public/create/Index.vue | 28 ++- .../public/create/views/BookReaderView.vue | 4 +- .../public/create/views/CharactersView.vue | 83 ++++++--- .../public/create/views/CreatingView.vue | 105 +++++------ .../views/public/create/views/DubbingView.vue | 13 +- .../public/create/views/EditInfoView.vue | 12 +- .../views/public/create/views/PreviewView.vue | 16 +- .../public/create/views/SaveSuccessView.vue | 18 +- .../public/create/views/StoryInputView.vue | 12 +- .../public/create/views/StyleSelectView.vue | 8 +- .../views/public/create/views/UploadView.vue | 36 ++-- .../views/public/create/views/WelcomeView.vue | 145 +++++++-------- .../src/views/public/mine/Index.vue | 7 +- .../src/views/public/works/Detail.vue | 51 +++-- .../src/views/public/works/Index.vue | 114 ++++++++---- 23 files changed, 661 insertions(+), 552 deletions(-) create mode 100644 lesingle-creation-frontend/src/composables/useAicreateCreation.ts delete mode 100644 lesingle-creation-frontend/src/utils/aicreate/extractDraft.ts create mode 100644 lesingle-creation-frontend/src/utils/aicreate/runCreateShellEntry.ts diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/pub/service/PublicGalleryService.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/pub/service/PublicGalleryService.java index e103509..524c67f 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/pub/service/PublicGalleryService.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/pub/service/PublicGalleryService.java @@ -130,6 +130,8 @@ public class PublicGalleryService { result.put("coverUrl", work.getCoverUrl()); result.put("description", work.getDescription()); result.put("status", work.getStatus()); + // 乐读派侧创作阶段(与 t_ugc_work.leai_status 一致,供公众端详情展示/继续创作判断) + result.put("leaiStatus", work.getLeaiStatus()); result.put("viewCount", (work.getViewCount() != null ? work.getViewCount() : 0) + 1); result.put("likeCount", work.getLikeCount()); result.put("favoriteCount", work.getFavoriteCount()); diff --git a/lesingle-creation-frontend/src/api/public.ts b/lesingle-creation-frontend/src/api/public.ts index c8b0c6b..b8beb42 100644 --- a/lesingle-creation-frontend/src/api/public.ts +++ b/lesingle-creation-frontend/src/api/public.ts @@ -510,6 +510,8 @@ export interface UserWork { description: string | null; visibility: string; status: WorkStatus; + /** 乐读派创作阶段(整型,与库表 leai_status 一致;如 2 表示创作中) */ + leaiStatus?: number | null; /** * 审核备注:与超管 `POST /content-review/works/{id}/reject` 请求体 `reason`(及可选 `note`) * 落库的 `review_note` 一致;驳回后作者在作品详情见「审核拒绝原因」。 diff --git a/lesingle-creation-frontend/src/composables/useAicreateCreation.ts b/lesingle-creation-frontend/src/composables/useAicreateCreation.ts new file mode 100644 index 0000000..99731cc --- /dev/null +++ b/lesingle-creation-frontend/src/composables/useAicreateCreation.ts @@ -0,0 +1,149 @@ +/** + * AI 绘本创作流程状态(模块级单例 ref,非持久化) + * 与 useAicreateStore(仅会话 + Tab 路由)分离,避免与 localStorage 等「缓存」混在一起。 + */ +import { ref, type Ref } from "vue"; + +const imageUrl = ref(""); +const extractId = ref(""); +const characters = ref([]); +const selectedCharacter = ref(null); +const selectedStyle = ref(""); +const storyData = ref(null); +const workId = ref(""); +const originalWorkId = ref(""); +const workDetail = ref(null); + +export function resetCreation() { + imageUrl.value = ""; + extractId.value = ""; + characters.value = []; + selectedCharacter.value = null; + selectedStyle.value = ""; + storyData.value = null; + workId.value = ""; + originalWorkId.value = ""; + workDetail.value = null; +} + +/** + * 开发模式:填充 mock 角色数据 + * @param count 角色数量(1-3) + */ +export function fillMockData(count: number = 3) { + const mockSvg = (hue: number) => + "data:image/svg+xml;charset=utf-8," + + encodeURIComponent( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + + imageUrl.value = mockSvg(250); + extractId.value = "mock-extract-" + Date.now(); + selectedCharacter.value = null; + + 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 作品详情(预览/编目等 UI 调试) */ +export function fillMockWorkDetail() { + const mockPage = (hue: number) => + "data:image/svg+xml;charset=utf-8," + + encodeURIComponent( + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ); + + const pageTexts = [ + "", + "一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。", + "它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。", + "小主角轻轻抱起小鸟,决定送它回家。", + "路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。", + "小狐狸说它认识森林里所有的小路,愿意做大家的向导。", + "三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。", + "小鱼们告诉他们,那棵会发光的大树就在前方不远处。", + "森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。", + "原来这就是小鸟的家,妈妈正在树枝上焦急地张望。", + "小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。", + "夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。", + "小主角带着这份美好回到家,心里也开出了一朵花。", + ]; + + const wid = "mock-work-" + Date.now(); + workId.value = wid; + workDetail.value = { + workId: wid, + status: 3, + title: storyData.value?.title || "森林大冒险", + subtitle: "", + author: "", + coverUrl: mockPage(280), + pageList: pageTexts.map((text, i) => ({ + pageNum: i, + text, + imageUrl: mockPage((280 + i * 27) % 360), + })), + }; +} + +export function useAicreateCreation(): { + imageUrl: Ref; + extractId: Ref; + characters: Ref; + selectedCharacter: Ref; + selectedStyle: Ref; + storyData: Ref; + workId: Ref; + originalWorkId: Ref; + workDetail: Ref; + resetCreation: typeof resetCreation; + fillMockData: typeof fillMockData; + fillMockWorkDetail: typeof fillMockWorkDetail; +} { + return { + imageUrl, + extractId, + characters, + selectedCharacter, + selectedStyle, + storyData, + workId, + originalWorkId, + workDetail, + resetCreation, + fillMockData, + fillMockWorkDetail, + }; +} + +/** 供 resumeLeaiWork 等工具直接写入(与 useAicreateCreation() 为同一组 ref) */ +export function getCreationFlowRefs() { + return { + imageUrl, + extractId, + characters, + selectedCharacter, + selectedStyle, + storyData, + workId, + originalWorkId, + workDetail, + }; +} diff --git a/lesingle-creation-frontend/src/stores/aicreate.ts b/lesingle-creation-frontend/src/stores/aicreate.ts index cf95b9b..21b5971 100644 --- a/lesingle-creation-frontend/src/stores/aicreate.ts +++ b/lesingle-creation-frontend/src/stores/aicreate.ts @@ -1,34 +1,16 @@ /** - * AI 创作全局状态(Pinia Store) - * - * 敏感信息(phone/orgId/appSecret)不再存储在 localStorage - * orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化 + * AI 创作:Pinia 仅保存乐读派会话与「创作」Tab 子路由记忆。 + * 创作业务数据见 {@link useAicreateCreation}。 */ import { defineStore } from "pinia"; import { ref } from "vue"; -import { clearExtractDraft } from "@/utils/aicreate/extractDraft"; export const useAicreateStore = defineStore("aicreate", () => { - // ─── 认证信息(不再存储敏感信息到 localStorage) ─── const orgId = ref(sessionStorage.getItem("le_orgId") || ""); const sessionToken = ref(sessionStorage.getItem("le_sessionToken") || ""); - // ─── 创作流程数据 ─── - const imageUrl = ref(""); - const extractId = ref(""); - const characters = ref([]); - const selectedCharacter = ref(null); - const selectedStyle = ref(""); - const storyData = ref(null); - const workId = ref(""); - /** extract 接口可能返回的 workId,供下游使用 */ - const originalWorkId = ref(""); - const workDetail = ref(null); - - // ─── Tab 切换状态保存 ─── const lastCreateRoute = ref(""); - // ─── 方法 ─── function setSession(id: string, token: string) { orgId.value = id; sessionToken.value = token; @@ -51,165 +33,11 @@ export const useAicreateStore = defineStore("aicreate", () => { lastCreateRoute.value = ""; } - function reset() { - imageUrl.value = ""; - extractId.value = ""; - characters.value = []; - selectedCharacter.value = null; - selectedStyle.value = ""; - storyData.value = null; - workId.value = ""; - originalWorkId.value = ""; - workDetail.value = null; - lastCreateRoute.value = ""; - // 只清除创作流程数据,保留认证信息 - localStorage.removeItem("le_workId"); - // 清除 sessionStorage 中的恢复数据 - sessionStorage.removeItem("le_recovery"); - clearExtractDraft(); - } - - function saveRecoveryState() { - const recovery = { - path: window.location.pathname || "/", - workId: workId.value || localStorage.getItem("le_workId") || "", - imageUrl: imageUrl.value || "", - extractId: extractId.value || "", - selectedStyle: selectedStyle.value || "", - savedAt: Date.now(), - }; - 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( - `` + - `` + - `` + - `` + - `` + - `` + - ``, - ); - - 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( - `` + - `` + - `` + - `` + - `` + - `` + - ``, - ); - - // 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() { - const raw = sessionStorage.getItem("le_recovery"); - if (!raw) return null; - try { - const recovery = JSON.parse(raw); - if (Date.now() - recovery.savedAt > 30 * 60 * 1000) { - sessionStorage.removeItem("le_recovery"); - return null; - } - if (recovery.workId) workId.value = recovery.workId; - if (recovery.imageUrl) imageUrl.value = recovery.imageUrl; - if (recovery.extractId) extractId.value = recovery.extractId; - if (recovery.selectedStyle) selectedStyle.value = recovery.selectedStyle; - sessionStorage.removeItem("le_recovery"); - return recovery; - } catch { - sessionStorage.removeItem("le_recovery"); - return null; - } - } - return { - // 认证 orgId, sessionToken, setSession, clearSession, - // 创作流程 - imageUrl, - extractId, - characters, - selectedCharacter, - selectedStyle, - storyData, - workId, - originalWorkId, - workDetail, - reset, - saveRecoveryState, - restoreRecoveryState, - // 开发模式 - fillMockData, - fillMockWorkDetail, - // Tab 切换状态 lastCreateRoute, setLastCreateRoute, clearLastCreateRoute, diff --git a/lesingle-creation-frontend/src/utils/aicreate/extractDraft.ts b/lesingle-creation-frontend/src/utils/aicreate/extractDraft.ts deleted file mode 100644 index 9cd0764..0000000 --- a/lesingle-creation-frontend/src/utils/aicreate/extractDraft.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * 角色提取(extract)结果本地草稿:用于断线后继续创作,10 天过期 - */ -const STORAGE_KEY = 'le_extract_draft' -const TEN_DAYS_MS = 10 * 24 * 60 * 60 * 1000 - -export interface ExtractDraftPayload { - savedAt: number - imageUrl: string - extractId: string - characters: any[] - /** 接口原始响应,便于扩展 */ - raw?: unknown -} - -export function saveExtractDraft( - payload: Omit & { savedAt?: number } -): void { - const data: ExtractDraftPayload = { - savedAt: payload.savedAt ?? Date.now(), - imageUrl: payload.imageUrl, - extractId: payload.extractId ?? '', - characters: payload.characters ?? [], - ...(payload.raw !== undefined ? { raw: payload.raw } : {}), - } - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) - } catch { - // quota / 隐私模式等忽略 - } -} - -/** 未过期返回草稿并校验字段;过期或损坏则删除并返回 null */ -export function loadExtractDraft(): ExtractDraftPayload | null { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) return null - try { - const data = JSON.parse(raw) as ExtractDraftPayload - if (!data.savedAt || Date.now() - data.savedAt > TEN_DAYS_MS) { - localStorage.removeItem(STORAGE_KEY) - return null - } - if (!data.imageUrl || !Array.isArray(data.characters)) { - localStorage.removeItem(STORAGE_KEY) - return null - } - return data - } catch { - localStorage.removeItem(STORAGE_KEY) - return null - } -} - -export function clearExtractDraft(): void { - try { - localStorage.removeItem(STORAGE_KEY) - } catch { - /* ignore */ - } -} diff --git a/lesingle-creation-frontend/src/utils/aicreate/resumeLeaiWork.ts b/lesingle-creation-frontend/src/utils/aicreate/resumeLeaiWork.ts index 775b204..84cc132 100644 --- a/lesingle-creation-frontend/src/utils/aicreate/resumeLeaiWork.ts +++ b/lesingle-creation-frontend/src/utils/aicreate/resumeLeaiWork.ts @@ -2,13 +2,14 @@ * 根据乐读派作品详情恢复创作环节(对应上游 B2 query/work,经 /leai-proxy/work/{id}) */ import type { Router } from "vue-router"; +import type { Ref } from "vue"; import { getWorkDetail } from "@/api/aicreate"; -import { STATUS, getRouteByStatus } from "@/utils/aicreate/status"; -import { clearExtractDraft } from "@/utils/aicreate/extractDraft"; +import { getResumeNavigationByStatus } from "@/utils/aicreate/status"; +import { getCreationFlowRefs } from "@/composables/useAicreateCreation"; -type AicreateStoreLike = { - workId: string; - workDetail: any; +export type ResumeFlowWritable = { + workId: Ref; + workDetail: Ref; }; function parseWorkPayload(res: unknown): Record | null { @@ -20,13 +21,13 @@ function parseWorkPayload(res: unknown): Record | null { } /** - * 拉取作品详情、写入 store 与 le_workId,并按 status 跳转到对应子路由。 + * 拉取作品详情、写入创作流 refs,并按状态二选一跳转(创作中 → Creating + query;否则 → Preview)。 * @returns 是否已成功发起跳转(失败时返回 false,调用方可继续其它恢复逻辑) */ export async function resumeLeaiWorkFromApi( workId: string, router: Router, - store: AicreateStoreLike, + flow: ResumeFlowWritable = getCreationFlowRefs(), ): Promise { const id = String(workId || "").trim(); if (!id) return false; @@ -35,43 +36,25 @@ export async function resumeLeaiWorkFromApi( const res = await getWorkDetail(id); const work = parseWorkPayload(res); if (!work) { - localStorage.removeItem("le_workId"); return false; } const wid = String(work.workId ?? id); - store.workId = wid; - store.workDetail = work; - localStorage.setItem("le_workId", wid); + flow.workId.value = wid; + flow.workDetail.value = work; - const st = Number(work.status); - if (st === STATUS.FAILED) { - clearExtractDraft(); - await router.replace({ - name: "PublicCreateCreating", - query: { workId: wid }, - }); - return true; - } - - const route = getRouteByStatus( - work.status as Parameters[0], + const rawStatus = work.status; + const statusNum = + rawStatus === null || rawStatus === undefined + ? NaN + : Number(rawStatus); + const target = getResumeNavigationByStatus( + Number.isFinite(statusNum) ? statusNum : NaN, wid, ); - if (!route) { - clearExtractDraft(); - await router.replace({ - name: "PublicCreateCreating", - query: { workId: wid }, - }); - return true; - } - - clearExtractDraft(); - await router.replace(route); + await router.replace(target); return true; } catch { - localStorage.removeItem("le_workId"); return false; } } diff --git a/lesingle-creation-frontend/src/utils/aicreate/runCreateShellEntry.ts b/lesingle-creation-frontend/src/utils/aicreate/runCreateShellEntry.ts new file mode 100644 index 0000000..4a2c145 --- /dev/null +++ b/lesingle-creation-frontend/src/utils/aicreate/runCreateShellEntry.ts @@ -0,0 +1,83 @@ +/** + * 创作壳层入口:token 就绪后处理 ?resumeWorkId= 或欢迎页内存 workId + */ +import type { Router } from "vue-router"; +import type { RouteLocationNormalizedLoaded } from "vue-router"; +import { unref, type MaybeRef } from "vue"; +import { resumeLeaiWorkFromApi, type ResumeFlowWritable } from "./resumeLeaiWork"; + +function parseResumeWorkIdFromQuery(raw: string): string { + const t = String(raw || "").trim(); + if (!t) return ""; + try { + return decodeURIComponent(t); + } catch { + return t; + } +} + +let shellEntryRunning = false; + +/** + * 1) query 含 resumeWorkId:在 /p/create 下即可恢复(不限欢迎页) + * 2) 无 resumeWorkId:仅在欢迎页用内存 flow.workId 尝试恢复 + */ +export async function runCreateShellEntry( + router: Router, + route: RouteLocationNormalizedLoaded, + /** Pinia setup store 在组件侧访问 sessionToken 可能已是解包后的 string,须用 unref */ + sessionToken: MaybeRef, + flow: ResumeFlowWritable, +): Promise { + if (shellEntryRunning) return; + + const tokenReady = () => Boolean(unref(sessionToken)); + + const qResume = route.query.resumeWorkId; + const resumeFromQuery = + typeof qResume === "string" + ? qResume + : Array.isArray(qResume) && qResume[0] + ? qResume[0] + : ""; + + if (resumeFromQuery) { + if (!tokenReady()) { + return; + } + if (!route.path.startsWith("/p/create")) { + return; + } + shellEntryRunning = true; + try { + const decoded = parseResumeWorkIdFromQuery(String(resumeFromQuery)); + if (!decoded) { + await router.replace({ name: "PublicCreateWelcome", query: {} }); + return; + } + const ok = await resumeLeaiWorkFromApi(decoded, router, flow); + if (!ok) { + await router.replace({ name: "PublicCreateWelcome", query: {} }); + } + } finally { + shellEntryRunning = false; + } + return; + } + + if (route.name !== "PublicCreateWelcome") { + return; + } + + if (!tokenReady()) { + return; + } + + shellEntryRunning = true; + try { + const wid = flow.workId.value || ""; + await resumeLeaiWorkFromApi(wid, router, flow); + } finally { + shellEntryRunning = false; + } +} diff --git a/lesingle-creation-frontend/src/utils/aicreate/status.ts b/lesingle-creation-frontend/src/utils/aicreate/status.ts index d3ffa5e..061e489 100644 --- a/lesingle-creation-frontend/src/utils/aicreate/status.ts +++ b/lesingle-creation-frontend/src/utils/aicreate/status.ts @@ -16,27 +16,51 @@ export const STATUS = { export type StatusValue = (typeof STATUS)[keyof typeof STATUS]; +/** 与 Vue Router replace/push 兼容;Creating 须带 query.workId 便于刷新/分享 */ +export type CreateFlowRouteTarget = { + name: string; + params?: Record; + query?: Record; +} | null; + /** - * 根据作品状态决定应导航到的路由 + * 作品库 / URL ?resumeWorkId= 恢复:仅「创作中 vs 预览」二分 + * - PENDING / PROCESSING(排队、生成中)→ 创作进度页 CreatingView(带 workId query) + * - 其余(含 COMPLETED / CATALOGED / DUBBED / FAILED / 未知)→ 预览页 PreviewView + */ +export function getResumeNavigationByStatus( + status: number, + workId: string, +): { name: string; params?: Record; query?: Record } { + const wid = String(workId ?? "").trim(); + const s = Number(status); + + if (s === STATUS.PENDING || s === STATUS.PROCESSING) { + return { name: "PublicCreateCreating", query: { workId: wid } }; + } + + return { name: "PublicCreatePreview", params: { workId: wid } }; +} + +/** + * 根据作品状态决定应导航到的路由(创作流程内轮询/WebSocket 完成后的页内跳转) */ export function getRouteByStatus( status: StatusValue, workId: string, -): { name: string; params?: Record } | null { +): CreateFlowRouteTarget { switch (status) { case STATUS.PENDING: case STATUS.PROCESSING: - return { name: "PublicCreateCreating" }; + return { name: "PublicCreateCreating", query: { workId } }; case STATUS.COMPLETED: return { name: "PublicCreatePreview", params: { workId } }; case STATUS.CATALOGED: return { name: "PublicCreatePreview", params: { workId } }; - // return { name: 'PublicCreateDubbing', params: { workId } } case STATUS.DUBBED: return { name: "PublicCreatePreview", params: { workId } }; - // return { name: 'PublicCreateEditInfo', params: { workId } } case STATUS.FAILED: - return null; + return { name: "PublicCreateCreating", query: { workId } }; default: return null; } diff --git a/lesingle-creation-frontend/src/views/public/create/Index.vue b/lesingle-creation-frontend/src/views/public/create/Index.vue index 746dc86..b3d9668 100644 --- a/lesingle-creation-frontend/src/views/public/create/Index.vue +++ b/lesingle-creation-frontend/src/views/public/create/Index.vue @@ -13,12 +13,10 @@ export default { name: 'AiCreateShell' } 重新加载 - + - - - + @@ -36,14 +34,17 @@ const route = useRoute() const loading = ref(true) const loadError = ref('') -// 监听路由变化,保存最后创作路由到 store -watch(() => route.path, (path) => { - if (path.startsWith('/p/create')) { - store.setLastCreateRoute(path) - } -}, { immediate: true }) +watch( + () => route.path, + (path) => { + if (path.startsWith('/p/create')) { + store.setLastCreateRoute(path) + } + }, + { immediate: true }, +) -/** 获取乐读派 Token 并存入 store */ +/** 获取乐读派 Token 并存入 store;子路由(如 Welcome)再处理 resumeWorkId / 内存恢复 */ const initToken = async () => { loading.value = true loadError.value = '' @@ -62,13 +63,10 @@ const initToken = async () => { } onMounted(() => { - // 乐读派作品恢复(localStorage le_workId、路由 ?resumeWorkId=)在子页 WelcomeView 挂载后执行, - // 须先完成 initToken,故不在此壳层重复拉取,避免与 loading 竞态。 - // 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载 if (store.sessionToken && store.orgId) { loading.value = false } else { - initToken() + void initToken() } }) diff --git a/lesingle-creation-frontend/src/views/public/create/views/BookReaderView.vue b/lesingle-creation-frontend/src/views/public/create/views/BookReaderView.vue index 60d0803..767631e 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/BookReaderView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/BookReaderView.vue @@ -102,11 +102,13 @@ import { PlusOutlined, } from '@ant-design/icons-vue' import { useAicreateStore } from '@/stores/aicreate' +import { useAicreateCreation } from '@/composables/useAicreateCreation' import { getWorkDetail } from '@/api/aicreate' const route = useRoute() const router = useRouter() const store = useAicreateStore() +const { resetCreation } = useAicreateCreation() const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works' || sessionStorage.getItem('le_from') === 'works' @@ -171,7 +173,7 @@ const onTouchEnd = (e: TouchEvent) => { } const goHome = () => { - store.reset() + resetCreation() router.push('/p/create') } diff --git a/lesingle-creation-frontend/src/views/public/create/views/CharactersView.vue b/lesingle-creation-frontend/src/views/public/create/views/CharactersView.vue index 8ac7da0..58211df 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/CharactersView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/CharactersView.vue @@ -91,7 +91,7 @@ export default { name: 'CharactersView' } diff --git a/lesingle-creation-frontend/src/views/public/create/views/CreatingView.vue b/lesingle-creation-frontend/src/views/public/create/views/CreatingView.vue index 728d518..490a8e6 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/CreatingView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/CreatingView.vue @@ -44,10 +44,10 @@ export default { name: 'CreatingView' }
{{ error }}
- - @@ -86,13 +86,21 @@ import { InboxOutlined, } from '@ant-design/icons-vue' import { useAicreateStore } from '@/stores/aicreate' +import { useAicreateCreation } from '@/composables/useAicreateCreation' import { createStory, getWorkDetail } from '@/api/aicreate' import { STATUS, getRouteByStatus } from '@/utils/aicreate/status' -import { clearExtractDraft } from '@/utils/aicreate/extractDraft' import config from '@/utils/aicreate/config' const router = useRouter() -const store = useAicreateStore() +const sessionStore = useAicreateStore() +const { + workId, + imageUrl, + storyData, + selectedStyle, + selectedCharacter, + extractId, +} = useAicreateCreation() const progress = ref(0) const stage = ref('准备中…') const dots = ref('') @@ -158,47 +166,30 @@ function friendlyStage(pct: number, msg: string): string { return '绘本创作完成' } -// 持久化 workId 到 localStorage,页面刷新后可恢复轮询 +/** 仅内存中保存当前乐读派 workId(刷新页面后不恢复) */ function saveWorkId(id: string) { - store.workId = id - if (id) { - const urlWorkId = new URLSearchParams(window.location.search).get('workId'); - if (!urlWorkId) { - localStorage.setItem('le_workId', id) - } - } else { - localStorage.removeItem('le_workId') - } + workId.value = id } -function restoreWorkId() { - if (!store.workId) { - store.workId = localStorage.getItem('le_workId') || '' - } -} - -/** 创作已推进到预览/配音等后续步骤时清除 extract 本地草稿 */ +/** 创作已推进到后续步骤时的路由跳转 */ function replaceWhenCreationAdvances(route: ReturnType) { if (!route) return - if (route.name !== 'PublicCreateCreating') { - clearExtractDraft() - } setTimeout(() => router.replace(route), 800) } // ─── WebSocket 实时推送 (首次进入使用) ─── -const startWebSocket = (workId: string) => { +const startWebSocket = (remoteWid: string) => { wsDegraded = false const wsBase = config.wsBaseUrl ? config.wsBaseUrl : `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}` - const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(store.orgId)}` + const wsUrl = `${wsBase}/ws/websocket?orgId=${encodeURIComponent(sessionStore.orgId)}` stompClient = new Client({ brokerURL: wsUrl, reconnectDelay: 0, onConnect: () => { - stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => { + stompClient.subscribe(`/topic/progress/${remoteWid}`, (msg: any) => { try { const data = JSON.parse(msg.body) if (data.progress != null && data.progress > progress.value) progress.value = data.progress @@ -225,20 +216,20 @@ const startWebSocket = (workId: string) => { if (wsDegraded) return wsDegraded = true closeWebSocket() - startPolling(workId) + startPolling(remoteWid) }, onWebSocketError: () => { if (wsDegraded) return wsDegraded = true closeWebSocket() - startPolling(workId) + startPolling(remoteWid) }, onWebSocketClose: () => { if (wsDegraded) return - if (store.workId) { + if (workId.value) { wsDegraded = true closeWebSocket() - startPolling(workId) + startPolling(remoteWid) } } }) @@ -316,34 +307,34 @@ const startCreation = async () => { try { const res = await createStory({ - imageUrl: store.imageUrl, - storyHint: store.storyData?.storyHint || '', - style: store.selectedStyle, - title: store.storyData?.title || '', - heroName: store.storyData?.heroName || '', - author: store.storyData?.author, - heroCharId: store.selectedCharacter?.charId, - extractId: store.extractId, + imageUrl: imageUrl.value, + storyHint: storyData.value?.storyHint || '', + style: selectedStyle.value, + title: storyData.value?.title || '', + heroName: storyData.value?.heroName || '', + author: storyData.value?.author, + heroCharId: selectedCharacter.value?.charId, + extractId: extractId.value, }) - const workId = res?.workId - if (!workId) { + const wid = res?.workId + if (!wid) { error.value = res.msg || '创作提交失败' submitted = false return } - saveWorkId(workId) + saveWorkId(wid) progress.value = 0 stage.value = '故事构思中…' - // startWebSocket(workId) - startPolling(store.workId) + // startWebSocket(wid) + startPolling(workId.value) } catch (e: any) { console.error('e', e); - if (store.workId) { + if (workId.value) { progress.value = 0 stage.value = '创作已提交到后台…' - startPolling(store.workId) + startPolling(workId.value) } else { error.value = sanitizeError(e.message) submitted = false @@ -356,7 +347,7 @@ const resumePolling = () => { networkWarn.value = false progress.value = 0 stage.value = '正在查询创作进度…' - startPolling(store.workId) + startPolling(workId.value) } const retry = () => { @@ -366,7 +357,7 @@ const retry = () => { } const leaveToWorks = () => { - // 关闭前端监听,但后端任务继续;store.workId 仍在 localStorage,下次进入 CreatingView 会恢复 + // 关闭前端监听,后端任务仍继续 closeWebSocket() if (pollTimer) { clearInterval(pollTimer); pollTimer = null } router.push('/p/works?tab=draft') @@ -380,22 +371,20 @@ onMounted(() => { tipTimer = setInterval(() => { currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length }, 3500) - // 恢复 workId const urlWorkId = new URLSearchParams(window.location.search).get('workId') - console.log('store.workId', urlWorkId, window.location.search) - if (!urlWorkId) { - restoreWorkId() + if (urlWorkId) { + saveWorkId(urlWorkId) } - if (store.workId) { + if (workId.value) { try { - getWorkDetailApi(store.workId) + getWorkDetailApi(workId.value) } catch (error) { console.log('error', error); } submitted = true progress.value = 0 stage.value = '正在查询创作进度…' - startPolling(store.workId) + startPolling(workId.value) } else { startCreation() } @@ -405,11 +394,9 @@ onActivated(() => { const urlWorkId = new URLSearchParams(window.location.search).get('workId') if (urlWorkId) { saveWorkId(urlWorkId) - } else { - restoreWorkId() } - if (store.workId) { - void getWorkDetailApi(store.workId) + if (workId.value) { + void getWorkDetailApi(workId.value) } }) 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 1b765aa..5e4e559 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/DubbingView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/DubbingView.vue @@ -132,7 +132,7 @@ export default { name: 'DubbingView' } diff --git a/lesingle-creation-frontend/src/views/public/create/views/PreviewView.vue b/lesingle-creation-frontend/src/views/public/create/views/PreviewView.vue index 32d0f16..861df0a 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/PreviewView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/PreviewView.vue @@ -75,7 +75,7 @@ export default { name: 'PreviewView' }