diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java index 213d0e1..44e37b2 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java @@ -321,6 +321,16 @@ public class LeaiSyncService implements ILeaiSyncService { ugcWorkPageMapper.insert(page); } + // 列表封面与前端创作页一致:使用 pageList[0] 插画,而非远程 originalImageUrl/coverUrl 元数据 + String firstCover = LeaiUtil.toString(pageList.get(0).get("imageUrl"), null); + if (firstCover != null && !firstCover.isEmpty()) { + LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); + uw.eq(UgcWork::getId, workId) + .set(UgcWork::getCoverUrl, firstCover) + .set(UgcWork::getModifyTime, LocalDateTime.now()); + ugcWorkMapper.update(null, uw); + } + log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size()); } diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java index 55539d8..ad6cc1f 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java @@ -221,6 +221,19 @@ public class PublicUserWorkService { // 插入新页面 saveWorkPages(workId, pages); + + // 与乐读派同步逻辑一致:首图作为作品库列表封面 + if (pages != null && !pages.isEmpty()) { + Object img = pages.get(0).get("imageUrl"); + String firstCover = img != null ? img.toString().trim() : null; + if (firstCover != null && !firstCover.isEmpty()) { + LambdaUpdateWrapper uw = new LambdaUpdateWrapper<>(); + uw.eq(UgcWork::getId, workId) + .set(UgcWork::getCoverUrl, firstCover) + .set(UgcWork::getModifyTime, LocalDateTime.now()); + ugcWorkMapper.update(null, uw); + } + } } private void saveWorkPages(Long workId, List> pages) { diff --git a/backend-java/src/main/resources/db/migration/V18__backfill_cover_url_from_first_page.sql b/backend-java/src/main/resources/db/migration/V18__backfill_cover_url_from_first_page.sql new file mode 100644 index 0000000..ffe7035 --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V18__backfill_cover_url_from_first_page.sql @@ -0,0 +1,8 @@ +-- 历史数据:列表封面与创作页对齐,用首页插画(page_no=1)回填 cover_url +UPDATE t_ugc_work w +INNER JOIN t_ugc_work_page p ON p.work_id = w.id AND p.page_no = 1 +SET w.cover_url = p.image_url, + w.modify_time = NOW() +WHERE w.is_deleted = 0 + AND p.image_url IS NOT NULL + AND TRIM(p.image_url) <> ''; diff --git a/docs/design/org-admin/analytics-dashboard-mockup.html b/docs/design/org-admin/analytics-dashboard-mockup.html index f54cc61..3da7484 100644 --- a/docs/design/org-admin/analytics-dashboard-mockup.html +++ b/docs/design/org-admin/analytics-dashboard-mockup.html @@ -1,445 +1,620 @@ + - - -数据统计 — 活动管理平台 - - - - - + + + + - + + + - -
- -
-

数据统计

-
- - + +
+ +
+

数据统计

+
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+
+ + + +
+
+
6
+
活动总数
+
+
+
+
+
+
+ + + +
+
+
12
+
累计报名
+
+
+
+
+
+
+ + + +
+
+
10
+
报名通过
+
+
+
+
+
+
+ + + +
+
+
8
+
作品总数
+
+
+
+
+
+
+ + + + +
+
+
5
+
已完成评审
+
+
+
+
+
+
+ + + +
+
+
3
+
获奖作品
+
+
+
+
+ + +
+ +
+

报名转化漏斗

+
+
+
+ 报名 + 12 +
+
+
+
+
+
+
+ 通过审核 +
83.3%10
+
+
+
+
+
+
+
+ 提交作品 +
80.0%8
+
+
+
+
+
+
+
+ 评审完成 +
62.5%5
+
+
+
+
+
+
+
+ 获奖 +
60.0%3
+
+
+
+
+
+
+
+ + +
+

月度趋势

+
+
+
+ + +
+

活动对比

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
活动名称报名数通过率提交率评审完成率获奖率平均分
2026年少儿绘本创作大赛560%100%100%100%84.89
第三届亲子阅读绘画展4100%75%0%0%-
寒假绘本阅读打卡活动3100%67%100%0%85.33
+
+
+
+ + + + +
- -
-
- - -
-
- - -
-
+ + // Export placeholders + function exportPDF() { message.warning('PDF 导出功能将在开发时实现'); } + function exportExcel() { message.warning('Excel 导出功能将在开发时实现'); } + - + + \ No newline at end of file diff --git a/frontend/src/api/aicreate/index.ts b/frontend/src/api/aicreate/index.ts index 189bfe3..6edf3a5 100644 --- a/frontend/src/api/aicreate/index.ts +++ b/frontend/src/api/aicreate/index.ts @@ -47,9 +47,29 @@ export function createStory(params: CreateStoryParams) { return publicApi.post('/leai-proxy/create-story', body) } +/** + * 乐读派 B2 作品详情:响应体常为 { code, data: Work },经 public 拦截器可能已剥一层, + * 仍可能出现嵌套 data,统一解包为 Work 对象(与 CreatingView 原 detail.data 语义一致)。 + */ +export function unwrapLeaiWorkDetail(raw: unknown): any { + let cur: any = raw + for (let i = 0; i < 5; i++) { + if (!cur || typeof cur !== 'object') return cur + if (cur.workId != null || Array.isArray(cur.pageList)) return cur + if (cur.data != null && typeof cur.data === 'object') { + cur = cur.data + continue + } + break + } + return cur +} + /** 查询作品详情 */ export function getWorkDetail(workId: string) { - return publicApi.get(`/leai-proxy/work/${workId}`) + return publicApi + .get(`/leai-proxy/work/${workId}`) + .then(unwrapLeaiWorkDetail) } /** 额度校验 */ diff --git a/frontend/src/api/public.ts b/frontend/src/api/public.ts index 5f179bb..47e9e9a 100644 --- a/frontend/src/api/public.ts +++ b/frontend/src/api/public.ts @@ -47,7 +47,13 @@ publicApi.interceptors.response.use( // 后端返回格式:{ code: 200, message: "success", data: xxx } // 检查业务状态码,非 200 视为业务错误 const resData = response.data; - if (resData && resData.code !== undefined && resData.code !== 200) { + // 后端统一 Result 为 200;乐读派 B2/B3 等原始体常用 0 表示成功(见 lesingle-aicreate-client) + if ( + resData && + resData.code !== undefined && + resData.code !== 200 && + resData.code !== 0 + ) { // 兼容后端 Result.message 和乐读派原始响应的 msg 字段 const error: any = new Error( resData.message || resData.msg || "请求失败", @@ -432,6 +438,8 @@ export type WorkStatus = export interface UserWork { id: number; userId: number; + /** 乐读派 remote work id,与创作路由参数一致 */ + remoteWorkId?: string | null; title: string; coverUrl: string | null; description: string | null; diff --git a/frontend/src/stores/aicreate.ts b/frontend/src/stores/aicreate.ts index dfc3203..cf95b9b 100644 --- a/frontend/src/stores/aicreate.ts +++ b/frontend/src/stores/aicreate.ts @@ -4,81 +4,81 @@ * 敏感信息(phone/orgId/appSecret)不再存储在 localStorage * orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化 */ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import { clearExtractDraft } from '@/utils/aicreate/extractDraft' +import { defineStore } from "pinia"; +import { ref } from "vue"; +import { clearExtractDraft } from "@/utils/aicreate/extractDraft"; -export const useAicreateStore = defineStore('aicreate', () => { +export const useAicreateStore = defineStore("aicreate", () => { // ─── 认证信息(不再存储敏感信息到 localStorage) ─── - const orgId = ref(sessionStorage.getItem('le_orgId') || '') - const sessionToken = ref(sessionStorage.getItem('le_sessionToken') || '') + 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('') + 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) + const originalWorkId = ref(""); + const workDetail = ref(null); // ─── Tab 切换状态保存 ─── - const lastCreateRoute = ref('') + const lastCreateRoute = ref(""); // ─── 方法 ─── function setSession(id: string, token: string) { - orgId.value = id - sessionToken.value = token - sessionStorage.setItem('le_orgId', id) - sessionStorage.setItem('le_sessionToken', token) + orgId.value = id; + sessionToken.value = token; + sessionStorage.setItem("le_orgId", id); + sessionStorage.setItem("le_sessionToken", token); } function clearSession() { - sessionToken.value = '' - orgId.value = '' - sessionStorage.removeItem('le_sessionToken') - sessionStorage.removeItem('le_orgId') + sessionToken.value = ""; + orgId.value = ""; + sessionStorage.removeItem("le_sessionToken"); + sessionStorage.removeItem("le_orgId"); } function setLastCreateRoute(path: string) { - lastCreateRoute.value = path + lastCreateRoute.value = path; } function clearLastCreateRoute() { - lastCreateRoute.value = '' + 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 = '' + 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') + localStorage.removeItem("le_workId"); // 清除 sessionStorage 中的恢复数据 - sessionStorage.removeItem('le_recovery') - clearExtractDraft() + 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)) + 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)); } /** @@ -89,28 +89,29 @@ export const useAicreateStore = defineStore('aicreate', () => { function fillMockData(count: number = 3) { // 纯渐变占位图(不带文字,模拟真实 AI 抠图返回的角色形象) const mockSvg = (hue: number) => - 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + "data:image/svg+xml;charset=utf-8," + + encodeURIComponent( `` + - `` + - `` + - `` + - `` + - `` + - `` - ) + `` + + `` + + `` + + `` + + `` + + ``, + ); - imageUrl.value = mockSvg(250) - extractId.value = 'mock-extract-' + Date.now() - selectedCharacter.value = null + 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) + { 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); } /** @@ -120,83 +121,97 @@ export const useAicreateStore = defineStore('aicreate', () => { function fillMockWorkDetail() { // 16:9 渐变占位图(800x450),模拟真实绘本插画 const mockPage = (hue: number) => - 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent( + "data:image/svg+xml;charset=utf-8," + + encodeURIComponent( `` + - `` + - `` + - `` + - `` + - `` + - `` - ) + `` + + `` + + `` + + `` + + `` + + ``, + ); // 13 页(封面 + 12 内页),模拟真实绘本规模,便于测试横向胶卷式缩略图 const pageTexts = [ - '', // 封面 - '一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。', - '它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。', - '小主角轻轻抱起小鸟,决定送它回家。', - '路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。', - '小狐狸说它认识森林里所有的小路,愿意做大家的向导。', - '三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。', - '小鱼们告诉他们,那棵会发光的大树就在前方不远处。', - '森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。', - '原来这就是小鸟的家,妈妈正在树枝上焦急地张望。', - '小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。', - '夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。', - '小主角带着这份美好回到家,心里也开出了一朵花。', - ] + "", // 封面 + "一个阳光明媚的早晨,小主角推开窗,看见远处的森林闪着金光。", + "它沿着小路走啊走,遇到了一只迷路的小鸟,羽毛湿漉漉的。", + "小主角轻轻抱起小鸟,决定送它回家。", + "路过一片野花田时,一只小狐狸从草丛里跳出来打招呼。", + "小狐狸说它认识森林里所有的小路,愿意做大家的向导。", + "三个朋友来到一条清澈的溪水边,溪里有一群闪闪发光的小鱼。", + "小鱼们告诉他们,那棵会发光的大树就在前方不远处。", + "森林深处真的有一棵会发光的大树,挂满了亮晶晶的果实。", + "原来这就是小鸟的家,妈妈正在树枝上焦急地张望。", + "小鸟欢快地飞回妈妈身边,鸟妈妈给大家每人一颗果实。", + "夕阳下,小主角和小狐狸告别,小狐狸送他们到森林边缘。", + "小主角带着这份美好回到家,心里也开出了一朵花。", + ]; - const wid = 'mock-work-' + Date.now() - workId.value = wid + const wid = "mock-work-" + Date.now(); + workId.value = wid; workDetail.value = { workId: wid, status: 3, // COMPLETED - title: storyData.value?.title || '森林大冒险', - subtitle: '', - author: '', + 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 + const raw = sessionStorage.getItem("le_recovery"); + if (!raw) return null; try { - const recovery = JSON.parse(raw) + const recovery = JSON.parse(raw); if (Date.now() - recovery.savedAt > 30 * 60 * 1000) { - sessionStorage.removeItem('le_recovery') - return null + 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 + 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 + sessionStorage.removeItem("le_recovery"); + return null; } } return { // 认证 - orgId, sessionToken, - setSession, clearSession, + orgId, + sessionToken, + setSession, + clearSession, // 创作流程 - imageUrl, extractId, characters, selectedCharacter, - selectedStyle, storyData, workId, originalWorkId, workDetail, - reset, saveRecoveryState, restoreRecoveryState, + imageUrl, + extractId, + characters, + selectedCharacter, + selectedStyle, + storyData, + workId, + originalWorkId, + workDetail, + reset, + saveRecoveryState, + restoreRecoveryState, // 开发模式 fillMockData, fillMockWorkDetail, // Tab 切换状态 - lastCreateRoute, setLastCreateRoute, clearLastCreateRoute, - } -}) + lastCreateRoute, + setLastCreateRoute, + clearLastCreateRoute, + }; +}); diff --git a/frontend/src/utils/aicreate/resumeLeaiWork.ts b/frontend/src/utils/aicreate/resumeLeaiWork.ts new file mode 100644 index 0000000..775b204 --- /dev/null +++ b/frontend/src/utils/aicreate/resumeLeaiWork.ts @@ -0,0 +1,77 @@ +/** + * 根据乐读派作品详情恢复创作环节(对应上游 B2 query/work,经 /leai-proxy/work/{id}) + */ +import type { Router } from "vue-router"; +import { getWorkDetail } from "@/api/aicreate"; +import { STATUS, getRouteByStatus } from "@/utils/aicreate/status"; +import { clearExtractDraft } from "@/utils/aicreate/extractDraft"; + +type AicreateStoreLike = { + workId: string; + workDetail: any; +}; + +function parseWorkPayload(res: unknown): Record | null { + if (!res || typeof res !== "object") return null; + const r = res as Record; + const inner = r.data !== undefined ? r.data : r; + if (!inner || typeof inner !== "object") return null; + return inner as Record; +} + +/** + * 拉取作品详情、写入 store 与 le_workId,并按 status 跳转到对应子路由。 + * @returns 是否已成功发起跳转(失败时返回 false,调用方可继续其它恢复逻辑) + */ +export async function resumeLeaiWorkFromApi( + workId: string, + router: Router, + store: AicreateStoreLike, +): Promise { + const id = String(workId || "").trim(); + if (!id) return false; + + try { + 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); + + 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], + wid, + ); + if (!route) { + clearExtractDraft(); + await router.replace({ + name: "PublicCreateCreating", + query: { workId: wid }, + }); + return true; + } + + clearExtractDraft(); + await router.replace(route); + return true; + } catch { + localStorage.removeItem("le_workId"); + return false; + } +} diff --git a/frontend/src/utils/aicreate/status.ts b/frontend/src/utils/aicreate/status.ts index 10527a1..b2cdfe5 100644 --- a/frontend/src/utils/aicreate/status.ts +++ b/frontend/src/utils/aicreate/status.ts @@ -29,7 +29,7 @@ export function getRouteByStatus(status: StatusValue, workId: string): { name: s case STATUS.CATALOGED: return { name: 'PublicCreateDubbing', params: { workId } } case STATUS.DUBBED: - return { name: 'PublicCreateRead', params: { workId } } + return { name: 'PublicCreateEditInfo', params: { workId } } case STATUS.FAILED: return null default: diff --git a/frontend/src/views/public/create/Index.vue b/frontend/src/views/public/create/Index.vue index efd5342..746dc86 100644 --- a/frontend/src/views/public/create/Index.vue +++ b/frontend/src/views/public/create/Index.vue @@ -62,6 +62,8 @@ const initToken = async () => { } onMounted(() => { + // 乐读派作品恢复(localStorage le_workId、路由 ?resumeWorkId=)在子页 WelcomeView 挂载后执行, + // 须先完成 initToken,故不在此壳层重复拉取,避免与 loading 竞态。 // 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载 if (store.sessionToken && store.orgId) { loading.value = false @@ -103,7 +105,9 @@ onMounted(() => { } @keyframes spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } .loading-text { @@ -122,7 +126,15 @@ onMounted(() => { .ai-slide-leave-active { transition: all 0.3s ease; } - .ai-slide-enter-from { opacity: 0; transform: translateX(30px); } - .ai-slide-leave-to { opacity: 0; transform: translateX(-30px); } + + .ai-slide-enter-from { + opacity: 0; + transform: translateX(30px); + } + + .ai-slide-leave-to { + opacity: 0; + transform: translateX(-30px); + } } diff --git a/frontend/src/views/public/create/views/BookReaderView.vue b/frontend/src/views/public/create/views/BookReaderView.vue index 8e54f30..34548d6 100644 --- a/frontend/src/views/public/create/views/BookReaderView.vue +++ b/frontend/src/views/public/create/views/BookReaderView.vue @@ -111,7 +111,6 @@ const route = useRoute() const router = useRouter() const store = useAicreateStore() -const isDev = import.meta.env.DEV const fromWorks = new URLSearchParams(window.location.search).get('from') === 'works' || sessionStorage.getItem('le_from') === 'works' @@ -195,13 +194,6 @@ function applyWork(work: any) { onMounted(async () => { 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 try { let work diff --git a/frontend/src/views/public/create/views/CharactersView.vue b/frontend/src/views/public/create/views/CharactersView.vue index 72758d9..311075b 100644 --- a/frontend/src/views/public/create/views/CharactersView.vue +++ b/frontend/src/views/public/create/views/CharactersView.vue @@ -29,10 +29,8 @@ export default { name: 'CharactersView' }
-
+
@@ -52,13 +50,8 @@ export default { name: 'CharactersView' }
-
+
@@ -72,10 +65,7 @@ export default { name: 'CharactersView' }
-
+
@@ -212,6 +202,7 @@ const goNext = () => { display: flex; flex-direction: column; } + .content { flex: 1; padding: 16px 20px; @@ -228,16 +219,19 @@ const goNext = () => { justify-content: center; padding: 60px 0; } + .loading-spinner { font-size: 44px; color: var(--ai-primary); margin-bottom: 18px; } + .loading-title { font-size: 16px; font-weight: 700; color: var(--ai-text); } + .loading-sub { font-size: 13px; color: var(--ai-text-sub); @@ -254,16 +248,19 @@ const goNext = () => { 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; @@ -279,6 +276,7 @@ const goNext = () => { gap: 24px; padding: 12px 0 24px; } + .single-card { width: 100%; max-width: 360px; @@ -288,6 +286,7 @@ const goNext = () => { padding: 14px; box-shadow: 0 14px 36px rgba(99, 102, 241, 0.22); } + .single-img-wrap { position: relative; width: 100%; @@ -299,18 +298,26 @@ const goNext = () => { align-items: center; justify-content: center; - &:hover .zoom-hint { opacity: 1; } - &:active { transform: scale(0.98); } + &: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; @@ -345,6 +352,7 @@ const goNext = () => { margin: 0 2px; } } + .result-icon { font-size: 18px; color: var(--ai-primary); @@ -395,13 +403,17 @@ const goNext = () => { align-items: center; justify-content: center; - &:hover .zoom-hint { opacity: 1; } + &:hover .zoom-hint { + opacity: 1; + } } + .char-img { width: 100%; height: 100%; object-fit: cover; } + .char-placeholder { font-size: 36px; color: var(--ai-text-sub); @@ -423,7 +435,9 @@ const goNext = () => { font-weight: 700; box-shadow: 0 2px 8px rgba(99, 102, 241, 0.4); - :deep(.anticon) { font-size: 9px; } + :deep(.anticon) { + font-size: 9px; + } } .check-badge { @@ -489,6 +503,7 @@ const goNext = () => { justify-content: center; cursor: zoom-out; } + .preview-full-img { max-width: 90%; max-height: 80vh; @@ -496,10 +511,12 @@ const goNext = () => { 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; diff --git a/frontend/src/views/public/create/views/CreatingView.vue b/frontend/src/views/public/create/views/CreatingView.vue index a795b44..72bdd51 100644 --- a/frontend/src/views/public/create/views/CreatingView.vue +++ b/frontend/src/views/public/create/views/CreatingView.vue @@ -14,18 +14,8 @@ export default { name: 'CreatingView' } - +
{{ progress }}%
@@ -57,7 +47,8 @@ export default { name: 'CreatingView' } -
@@ -264,8 +255,7 @@ const startPolling = (workId: string) => { pollTimer = setInterval(async () => { try { - const detail = await getWorkDetail(workId) - const work = detail.data + const work = await getWorkDetail(workId) if (!work) return if (consecutiveErrors > 0 || networkWarn.value) { @@ -422,8 +412,15 @@ onUnmounted(() => { height: 180px; margin-bottom: 28px; } -.ring-svg { transform: rotate(-90deg); } -.ring-fill { transition: stroke-dashoffset 0.8s ease; } + +.ring-svg { + transform: rotate(-90deg); +} + +.ring-fill { + transition: stroke-dashoffset 0.8s ease; +} + .ring-center { position: absolute; inset: 0; @@ -432,6 +429,7 @@ onUnmounted(() => { align-items: center; justify-content: center; } + .ring-pct { font-size: 38px; font-weight: 900; @@ -441,6 +439,7 @@ onUnmounted(() => { background-clip: text; letter-spacing: -1px; } + .ring-label { font-size: 12px; color: var(--ai-text-sub); @@ -464,6 +463,7 @@ onUnmounted(() => { align-items: center; justify-content: center; } + .rotating-tip { font-size: 13px; color: var(--ai-text-sub); @@ -471,12 +471,21 @@ onUnmounted(() => { text-align: center; letter-spacing: 0.3px; } + .tip-fade-enter-active, .tip-fade-leave-active { transition: opacity 0.5s ease, transform 0.5s ease; } -.tip-fade-enter-from { opacity: 0; transform: translateY(8px); } -.tip-fade-leave-to { opacity: 0; transform: translateY(-8px); } + +.tip-fade-enter-from { + opacity: 0; + transform: translateY(8px); +} + +.tip-fade-leave-to { + opacity: 0; + transform: translateY(-8px); +} /* ---------- 网络警告 ---------- */ .network-warn { @@ -491,7 +500,9 @@ onUnmounted(() => { align-items: center; gap: 6px; - :deep(.anticon) { font-size: 13px; } + :deep(.anticon) { + font-size: 13px; + } } /* ---------- 错误状态 ---------- */ @@ -502,11 +513,13 @@ onUnmounted(() => { align-items: center; text-align: center; } + .error-icon { font-size: 44px; color: var(--ai-text-sub); margin-bottom: 12px; } + .error-text { color: #ef4444; font-size: 14px; @@ -514,6 +527,7 @@ onUnmounted(() => { line-height: 1.6; max-width: 280px; } + .error-actions { display: flex; flex-direction: column; @@ -522,11 +536,13 @@ onUnmounted(() => { 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; @@ -544,6 +560,7 @@ onUnmounted(() => { width: 100%; max-width: 320px; } + .task-hint-row { display: flex; align-items: center; @@ -553,16 +570,19 @@ onUnmounted(() => { 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; @@ -579,13 +599,18 @@ onUnmounted(() => { cursor: pointer; transition: all 0.2s; - :deep(.anticon) { font-size: 15px; } + :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); } + + &:active { + transform: scale(0.98); + } } diff --git a/frontend/src/views/public/create/views/DubbingView.vue b/frontend/src/views/public/create/views/DubbingView.vue index b5ca0e4..6d6c1ae 100644 --- a/frontend/src/views/public/create/views/DubbingView.vue +++ b/frontend/src/views/public/create/views/DubbingView.vue @@ -175,8 +175,6 @@ const route = useRoute() const store = useAicreateStore() const workId = computed(() => route.params.workId || store.workId) -const isDev = import.meta.env.DEV - const loading = ref(true) const submitting = ref(false) const pages = ref([]) @@ -289,12 +287,6 @@ function togglePlay() { const src = currentAudioSrc.value if (!src) return - // dev mock 兜底:mock 音频直接 toast 不真实播放 - if (typeof src === 'string' && src.startsWith('mock-audio-')) { - showToast('模拟音频暂不支持播放') - return - } - if (isPlaying.value) { audioEl?.pause() isPlaying.value = false @@ -404,19 +396,6 @@ function autoAdvance() { async function voiceSingle() { voicingSingle.value = true try { - // dev 兜底:mock workId 直接 mock 配音 - if (isDev && String(workId.value || '').startsWith('mock-')) { - await new Promise(r => setTimeout(r, 400)) - const p = pages.value[idx.value] - if (p) { - p.audioUrl = 'mock-audio-' + p.pageNum - p.localBlob = null - p.isAiVoice = true - } - showToast('AI 配音完成') - return - } - const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum }) const data = res if (data.voicedPages?.length) { @@ -449,19 +428,6 @@ async function voiceAllConfirm() { voicingAll.value = true try { - // dev 兜底:mock workId 直接 mock 全部配音 - if (isDev && String(workId.value || '').startsWith('mock-')) { - await new Promise(r => setTimeout(r, 800)) - pages.value.forEach(p => { - if (!p.audioUrl && !p.localBlob) { - p.audioUrl = 'mock-audio-' + p.pageNum - p.isAiVoice = true - } - }) - showToast('全部 AI 配音完成') - return - } - const res = await voicePage({ workId: workId.value, voiceAll: true }) const data = res if (data.voicedPages) { @@ -487,15 +453,6 @@ async function voiceAllConfirm() { async function finish() { submitting.value = true try { - // dev 兜底:mock workId 跳过真实上传与提交 - if (isDev && String(workId.value || '').startsWith('mock-')) { - await new Promise(r => setTimeout(r, 500)) - store.workDetail = null - showToast('配音完成') - setTimeout(() => router.push(`/p/create/read/${workId.value}`), 600) - return - } - const pendingLocal = pages.value.filter(p => p.localBlob) if (pendingLocal.length > 0) { @@ -518,14 +475,28 @@ async function finish() { store.workDetail = null showToast('配音完成') - setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800) + setTimeout( + () => + router.push({ + name: 'PublicCreateEditInfo', + params: { workId: String(workId.value || '') }, + }), + 800, + ) } catch (e: any) { try { const check = await getWorkDetail(workId.value) if (check?.status >= 5) { store.workDetail = null showToast('配音已完成') - setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800) + setTimeout( + () => + router.push({ + name: 'PublicCreateEditInfo', + params: { workId: String(workId.value || '') }, + }), + 800, + ) return } } catch { /* ignore */ } @@ -539,24 +510,6 @@ async function finish() { async function loadWork() { loading.value = true try { - const wid = String(workId.value || '') - - // dev 兜底:mock workId 直接用 store.workDetail - if (isDev && wid.startsWith('mock-')) { - if (!store.workDetail) store.fillMockWorkDetail() - const w = store.workDetail - pages.value = (w.pageList || []).map((p: any) => ({ - pageNum: p.pageNum, - text: p.text, - imageUrl: p.imageUrl, - audioUrl: p.audioUrl || null, - localBlob: null, - isAiVoice: p.audioUrl ? true : null, - })) - loading.value = false - return - } - if (!store.workDetail || store.workDetail.workId !== workId.value) { store.workDetail = null const res = await getWorkDetail(workId.value) diff --git a/frontend/src/views/public/create/views/EditInfoView.vue b/frontend/src/views/public/create/views/EditInfoView.vue index a6c9099..734e434 100644 --- a/frontend/src/views/public/create/views/EditInfoView.vue +++ b/frontend/src/views/public/create/views/EditInfoView.vue @@ -129,6 +129,7 @@ import { AudioOutlined, SendOutlined, } from '@ant-design/icons-vue' +import { message } from 'ant-design-vue' import PageHeader from '@/components/aicreate/PageHeader.vue' import { getWorkDetail, updateWork } from '@/api/aicreate' import { useAicreateStore } from '@/stores/aicreate' @@ -140,8 +141,6 @@ const route = useRoute() const store = useAicreateStore() const workId = computed(() => route.params.workId || store.workId) -const isDev = import.meta.env.DEV - const loading = ref(true) const processing = ref(false) const coverUrl = ref('') @@ -184,21 +183,6 @@ function confirmAddTag() { async function loadWork() { loading.value = true try { - 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) { store.workDetail = null @@ -207,7 +191,8 @@ async function loadWork() { } const w = store.workDetail - if (w.status > STATUS.CATALOGED) { + // 已配音(DUBBED)仍可在本页编辑元数据/发布;仅当状态高于当前流程终态时再按 status 跳转 + if (w.status > STATUS.DUBBED) { const nextRoute = getRouteByStatus(w.status, w.workId) if (nextRoute) { router.replace(nextRoute); return } } @@ -240,20 +225,6 @@ function validate() { * 不做跳转,由各 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 { const data = { tags: selectedTags.value } data.author = form.value.author.trim() @@ -273,21 +244,24 @@ async function saveFormToServer() { // 容错:保存报错时检查实际状态,可能已经成功但重试导致 CAS 失败 try { const check = await getWorkDetail(workId.value) - if (check?.data?.status >= 4) return true + if (check?.status >= 4) return true } catch { /* ignore */ } - alert(e.message || '保存失败,请重试') + message.error(e.message || '保存失败,请重试') return false } } -/** 保存(编目完成 → unpublished)→ 跳作品库未发布 tab */ +/** 保存(编目完成 → unpublished)→ 保存成功页,可继续配音或进作品库 */ async function handleSave() { if (!validate()) return processing.value = true try { if (await saveFormToServer()) { store.workDetail = null - router.push('/p/works?tab=unpublished') + router.push({ + name: 'PublicCreateSaveSuccess', + params: { workId: String(workId.value || '') }, + }) } } finally { processing.value = false @@ -308,27 +282,21 @@ async function handleGoDubbing() { } } -/** 发布作品 → 进入超管端待审核,跳作品库审核中 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') + message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度') + router.push({ + name: 'PublicCreateSaveSuccess', + params: { workId: String(workId.value || '') }, + }); } finally { processing.value = false } diff --git a/frontend/src/views/public/create/views/PreviewView.vue b/frontend/src/views/public/create/views/PreviewView.vue index aa6d794..b479685 100644 --- a/frontend/src/views/public/create/views/PreviewView.vue +++ b/frontend/src/views/public/create/views/PreviewView.vue @@ -99,8 +99,6 @@ const router = useRouter() const route = useRoute() const store = useAicreateStore() -const isDev = import.meta.env.DEV - const loading = ref(true) const error = ref('') const pages = ref([]) @@ -134,17 +132,8 @@ async function loadWork() { loading.value = true 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, - })) + if (!workId.value) { + error.value = '缺少作品信息' loading.value = false return } diff --git a/frontend/src/views/public/create/views/SaveSuccessView.vue b/frontend/src/views/public/create/views/SaveSuccessView.vue index 918b5a3..548d01a 100644 --- a/frontend/src/views/public/create/views/SaveSuccessView.vue +++ b/frontend/src/views/public/create/views/SaveSuccessView.vue @@ -3,7 +3,6 @@ export default { name: 'SaveSuccessView' }