From 951346a7a8dc9cebdd2b2a7b216dcf0da0e7afcf Mon Sep 17 00:00:00 2001 From: aid Date: Thu, 9 Apr 2026 18:14:26 +0800 Subject: [PATCH 1/5] =?UTF-8?q?refactor:=20AI=20=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=2011=20=E9=A1=B5=E7=95=8C=E9=9D=A2=E5=85=A8?= =?UTF-8?q?=E9=9D=A2=E9=87=8D=E5=81=9A=E4=B8=8E=E7=B4=AB=E7=B2=89=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - aicreate.scss 主题变量紫粉化,对齐 PublicLayout 设计语言 - 11 个创作流程 view 清理 emoji 改 antd 图标,文案去除"孩子/家长"等第三人称 - 路由调整:编排故事改到选画风之前(更顺的产品逻辑) - WelcomeView 浮动 CTA + 完整 7 步流程引导 - CharactersView 单角色大图 / 多角色网格自适应 - StyleSelectView 预设路径 /aicreate/styles/{styleId}.jpg + SVG fallback - CreatingView 改为异步任务式说明 + 去作品库入口 - PreviewView / DubbingView 缩略图统一为横向胶卷 - EditInfoView 底部三按钮(保存草稿 / 去配音 / 发布作品),配音改为可选 - BookReaderView 修复 dev 模式数据加载 + 紫粉封面 - DubbingView / BookReaderView 改用 page-fullscreen 布局类避免被 tabbar 遮挡 - store 新增 fillMockData / fillMockWorkDetail,支持 dev 无后端走通完整流程 - works/Index.vue 加 query.tab 双向同步,支持跳转携带 tab 参数 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/assets/styles/aicreate.scss | 32 +- frontend/src/router/index.ts | 12 +- frontend/src/stores/aicreate.ts | 86 +++ frontend/src/views/public/create/Index.vue | 9 +- .../public/create/views/BookReaderView.vue | 561 +++++++++----- .../public/create/views/CharactersView.vue | 585 +++++++++----- .../public/create/views/CreatingView.vue | 444 +++++++---- .../views/public/create/views/DubbingView.vue | 724 ++++++++++-------- .../public/create/views/EditInfoView.vue | 541 ++++++++++--- .../views/public/create/views/PreviewView.vue | 362 ++++++--- .../public/create/views/StoryInputView.vue | 275 ++++--- .../public/create/views/StyleSelectView.vue | 229 +++--- .../views/public/create/views/UploadView.vue | 313 ++++++-- .../views/public/create/views/WelcomeView.vue | 461 ++++++----- frontend/src/views/public/works/Index.vue | 29 +- 15 files changed, 3046 insertions(+), 1617 deletions(-) diff --git a/frontend/src/assets/styles/aicreate.scss b/frontend/src/assets/styles/aicreate.scss index c629bcf..2dd2f9c 100644 --- a/frontend/src/assets/styles/aicreate.scss +++ b/frontend/src/assets/styles/aicreate.scss @@ -1,24 +1,24 @@ -// 乐读派 C端 — AI 创作专用样式(隔离在 .ai-create-shell 容器内) -// 暖橙 + 奶油白 儿童绘本风格 +// AI 创作专用样式(隔离在 .ai-create-shell 容器内) +// 紫粉风格,与 PublicLayout 设计语言保持一致 // 所有 CSS 变量使用 --ai- 前缀,避免与主前端冲突 .ai-create-shell { - --ai-primary: #FF6B35; - --ai-primary-light: #FFF0E8; - --ai-secondary: #6C63FF; - --ai-accent: #FFD166; - --ai-success: #2EC4B6; - --ai-bg: #FFFDF7; - --ai-card: #FFFFFF; - --ai-text: #2D2D3F; - --ai-text-sub: #8E8EA0; - --ai-border: #F0EDE8; + --ai-primary: #6366f1; + --ai-primary-light: #eef0ff; + --ai-secondary: #ec4899; + --ai-accent: #a78bfa; + --ai-success: #10b981; + --ai-bg: #f8f7fc; + --ai-card: #ffffff; + --ai-text: #1e1b4b; + --ai-text-sub: #6b7280; + --ai-border: #e5e7eb; --ai-radius: 20px; --ai-radius-sm: 14px; - --ai-shadow: 0 8px 32px rgba(255, 107, 53, 0.12); - --ai-shadow-soft: 0 4px 20px rgba(0, 0, 0, 0.06); - --ai-gradient: linear-gradient(135deg, #FF6B35 0%, #FF8F65 50%, #FFB088 100%); - --ai-gradient-purple: linear-gradient(135deg, #6C63FF 0%, #9B93FF 100%); + --ai-shadow: 0 8px 28px rgba(99, 102, 241, 0.22); + --ai-shadow-soft: 0 4px 20px rgba(99, 102, 241, 0.06); + --ai-gradient: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%); + --ai-gradient-purple: linear-gradient(135deg, #a78bfa 0%, #c084fc 100%); --ai-font: 'PingFang SC', 'Noto Sans SC', 'Microsoft YaHei', -apple-system, sans-serif; font-family: var(--ai-font); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1a372be..6ef60df 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -28,12 +28,12 @@ const baseRoutes: RouteRecordRaw[] = [ }, { path: "/p", - name: "PublicMain", component: () => import("@/layouts/PublicLayout.vue"), meta: { requiresAuth: false }, children: [ { path: "", + name: "PublicMain", redirect: "/p/gallery", }, { @@ -100,16 +100,16 @@ const baseRoutes: RouteRecordRaw[] = [ name: "PublicCreateCharacters", component: () => import("@/views/public/create/views/CharactersView.vue"), }, - { - path: "style", - name: "PublicCreateStyle", - component: () => import("@/views/public/create/views/StyleSelectView.vue"), - }, { path: "story", name: "PublicCreateStory", component: () => import("@/views/public/create/views/StoryInputView.vue"), }, + { + path: "style", + name: "PublicCreateStyle", + component: () => import("@/views/public/create/views/StyleSelectView.vue"), + }, { path: "creating", name: "PublicCreateCreating", diff --git a/frontend/src/stores/aicreate.ts b/frontend/src/stores/aicreate.ts index 7610a8d..e7505cd 100644 --- a/frontend/src/stores/aicreate.ts +++ b/frontend/src/stores/aicreate.ts @@ -93,6 +93,89 @@ export const useAicreateStore = defineStore('aicreate', () => { 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 @@ -122,6 +205,9 @@ export const useAicreateStore = defineStore('aicreate', () => { imageUrl, extractId, characters, selectedCharacter, selectedStyle, storyData, workId, workDetail, reset, saveRecoveryState, restoreRecoveryState, + // 开发模式 + fillMockData, + fillMockWorkDetail, // Tab 切换状态 lastCreateRoute, setLastCreateRoute, clearLastCreateRoute, } diff --git a/frontend/src/views/public/create/Index.vue b/frontend/src/views/public/create/Index.vue index c87b547..f1f0a36 100644 --- a/frontend/src/views/public/create/Index.vue +++ b/frontend/src/views/public/create/Index.vue @@ -74,16 +74,13 @@ onMounted(() => { diff --git a/frontend/src/views/public/create/views/CharactersView.vue b/frontend/src/views/public/create/views/CharactersView.vue index 6558e2e..1c19f76 100644 --- a/frontend/src/views/public/create/views/CharactersView.vue +++ b/frontend/src/views/public/create/views/CharactersView.vue @@ -1,36 +1,57 @@ diff --git a/frontend/src/views/public/create/views/CreatingView.vue b/frontend/src/views/public/create/views/CreatingView.vue index e8de623..c9a839e 100644 --- a/frontend/src/views/public/create/views/CreatingView.vue +++ b/frontend/src/views/public/create/views/CreatingView.vue @@ -1,31 +1,42 @@ @@ -57,6 +94,13 @@ import { ref, onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { Client } from '@stomp/stompjs' +import { + ExperimentOutlined, + FrownOutlined, + WifiOutlined, + CloudServerOutlined, + InboxOutlined, +} from '@ant-design/icons-vue' import { useAicreateStore } from '@/stores/aicreate' import { createStory, getWorkDetail } from '@/api/aicreate' import { STATUS, getRouteByStatus } from '@/utils/aicreate/status' @@ -65,19 +109,21 @@ import config from '@/utils/aicreate/config' const router = useRouter() const store = useAicreateStore() const progress = ref(0) -const stage = ref('准备中...') +const stage = ref('准备中…') const dots = ref('') const error = ref('') const networkWarn = ref(false) const currentTipIdx = ref(0) const creatingTips = [ - 'AI 画师正在构思精彩故事...', - '魔法画笔正在绘制插画...', - '故事世界正在成形...', - '角色们正在准备登场...', - '色彩魔法正在施展中...', + 'AI 正在为你构思故事', + '画笔正在绘制插画', + '故事世界正在成形', + '角色们正在准备登场', + '色彩正在调和', ] +const isDev = import.meta.env.DEV + let pollTimer: ReturnType | null = null let dotTimer: ReturnType | null = null let tipTimer: ReturnType | null = null @@ -93,35 +139,33 @@ function sanitizeError(msg: string | undefined): string { if (!msg) return '创作遇到问题,请重新尝试' if (msg.includes('400') || msg.includes('status code')) return '创作请求异常,请返回重新操作' if (msg.includes('火山引擎') || msg.includes('VolcEngine')) return 'AI 服务暂时繁忙,请稍后重试' - if (msg.includes('额度')) return msg // 额度提示保留原文 - if (msg.includes('重复') || msg.includes('DUPLICATE')) return '您有正在创作的作品,请等待完成' + if (msg.includes('额度')) return msg + if (msg.includes('重复') || msg.includes('DUPLICATE')) return '你有正在创作的作品,请等待完成' if (msg.includes('timeout') || msg.includes('超时')) return '创作超时,请重新尝试' if (msg.includes('OSS') || msg.includes('MiniMax')) return '服务暂时不可用,请稍后重试' if (msg.length > 50) return '创作遇到问题,请重新尝试' return msg } -// 将运维级消息转为用户友好消息(隐藏分组/模型/耗时等内部细节) +// 后端进度消息 → 用户友好阶段文案(不带任何 emoji 前缀) function friendlyStage(pct: number, msg: string): string { - if (!msg) return '创作中...' - // 按关键词匹配,优先级从高到低 - if (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('补生成')) 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 (pct < 20) return '✨ 正在提交创作...' - if (pct < 50) return '📝 正在编写故事...' - if (pct < 80) return '🎨 正在绘制插画...' - if (pct < 100) return '🔊 即将完成...' - return '🎉 绘本创作完成!' + if (!msg) return '创作中…' + if (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('补生成')) 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 (pct < 20) return '正在提交创作…' + if (pct < 50) return '正在编写故事…' + if (pct < 80) return '正在绘制插画…' + if (pct < 100) return '即将完成…' + return '绘本创作完成' } // 持久化 workId 到 localStorage,页面刷新后可恢复轮询 @@ -150,7 +194,7 @@ const startWebSocket = (workId: string) => { stompClient = new Client({ brokerURL: wsUrl, - reconnectDelay: 0, // 不自动重连,失败直接降级轮询 + reconnectDelay: 0, onConnect: () => { stompClient.subscribe(`/topic/progress/${workId}`, (msg: any) => { try { @@ -160,7 +204,7 @@ const startWebSocket = (workId: string) => { if (data.progress >= 100) { progress.value = 100 - stage.value = '🎉 绘本创作完成!' + stage.value = '绘本创作完成' closeWebSocket() saveWorkId('') const route = getRouteByStatus(STATUS.COMPLETED, workId) @@ -204,7 +248,7 @@ const closeWebSocket = () => { } } -// ─── B2 轮询 (重进 / WebSocket 降级使用) ─── +// ─── B2 轮询 ─── const startPolling = (workId: string) => { if (pollTimer) clearInterval(pollTimer) consecutiveErrors = 0 @@ -216,7 +260,6 @@ const startPolling = (workId: string) => { const work = detail.data if (!work) return - // 轮询成功,清除网络异常状态 if (consecutiveErrors > 0 || networkWarn.value) { consecutiveErrors = 0 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.progressMessage) stage.value = friendlyStage(progress.value, work.progressMessage) - // 状态 >= COMPLETED(3) 表示创作已结束,根据具体状态导航 if (work.status >= STATUS.COMPLETED) { progress.value = 100 - stage.value = '🎉 绘本创作完成!' + stage.value = '绘本创作完成' clearInterval(pollTimer!) pollTimer = null saveWorkId('') @@ -243,27 +285,24 @@ const startPolling = (workId: string) => { } catch { consecutiveErrors++ if (consecutiveErrors > MAX_POLL_ERRORS) { - // 连续失败太多次,暂停轮询,让用户手动恢复 clearInterval(pollTimer!) pollTimer = null networkWarn.value = false error.value = '网络连接异常,创作仍在后台进行中' } else if (consecutiveErrors > MAX_SILENT_ERRORS) { - // 连续失败超过阈值,提示网络波动但继续轮询 networkWarn.value = true } - // 前几次静默忽略,避免偶尔的网络抖动触发提示 } }, 8000) } const startCreation = async () => { - if (submitted) return // 防重复 + if (submitted) return submitted = true error.value = '' progress.value = 5 - stage.value = '📝 正在提交创作请求...' + stage.value = '正在提交创作请求…' try { const res = await createStory({ @@ -285,15 +324,13 @@ const startCreation = async () => { saveWorkId(workId) progress.value = 10 - stage.value = '📝 故事构思中...' - // 首次提交:优先 WebSocket 实时推送 + stage.value = '故事构思中…' startWebSocket(workId) } catch (e: any) { - // 创作提交可能已入库(超时但服务端已接收) if (store.workId) { progress.value = 10 - stage.value = '📝 创作已提交到后台...' + stage.value = '创作已提交到后台…' startPolling(store.workId) } else { error.value = sanitizeError(e.message) @@ -306,16 +343,52 @@ const resumePolling = () => { error.value = '' networkWarn.value = false progress.value = 10 - stage.value = '📝 正在查询创作进度...' + stage.value = '正在查询创作进度…' startPolling(store.workId) } const retry = () => { + if (isDev && !store.imageUrl) { + enterMockProgress() + return + } saveWorkId('') submitted = false 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(() => { dotTimer = setInterval(() => { dots.value = dots.value.length >= 3 ? '' : dots.value + '.' @@ -325,7 +398,7 @@ onMounted(() => { currentTipIdx.value = (currentTipIdx.value + 1) % creatingTips.length }, 3500) - // 恢复 workId:优先从URL参数(作品列表跳入),其次从localStorage(页面刷新) + // 恢复 workId const urlWorkId = new URLSearchParams(window.location.search).get('workId') if (urlWorkId) { saveWorkId(urlWorkId) @@ -333,11 +406,16 @@ onMounted(() => { restoreWorkId() } - // 如果已有进行中的任务,恢复轮询而非重新提交 + // 开发模式兜底:缺关键数据时直接进入模拟态,避免真实接口失败 + if (isDev && !store.workId && (!store.imageUrl || !store.storyData)) { + enterMockProgress() + return + } + if (store.workId) { submitted = true progress.value = 10 - stage.value = '📝 正在查询创作进度...' + stage.value = '正在查询创作进度…' startPolling(store.workId) } else { startCreation() @@ -355,41 +433,61 @@ onUnmounted(() => { diff --git a/frontend/src/views/public/create/views/DubbingView.vue b/frontend/src/views/public/create/views/DubbingView.vue index 0053002..22baef7 100644 --- a/frontend/src/views/public/create/views/DubbingView.vue +++ b/frontend/src/views/public/create/views/DubbingView.vue @@ -1,53 +1,49 @@ @@ -207,6 +226,7 @@ import { UndoOutlined, InboxOutlined, DeleteOutlined, + ZoomInOutlined, } from '@ant-design/icons-vue' import { publicUserWorksApi, @@ -228,6 +248,7 @@ const loading = ref(true) const currentPageIndex = ref(0) const interaction = ref({ liked: false, favorited: false }) const actionLoading = ref(false) +const previewOriginal = ref('') const currentPageData = computed(() => work.value?.pages?.[currentPageIndex.value] || null) @@ -592,6 +613,96 @@ $accent: #ec4899; .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 { background: #fff; diff --git a/frontend/src/views/public/works/Index.vue b/frontend/src/views/public/works/Index.vue index af699a7..00612e4 100644 --- a/frontend/src/views/public/works/Index.vue +++ b/frontend/src/views/public/works/Index.vue @@ -30,10 +30,19 @@
+
+ +
+ 原图 +
{{ statusTextMap[work.status] || work.status }}
@@ -268,6 +277,32 @@ $primary: #6366f1; &.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 { diff --git a/frontend/src/views/public/works/_dev-mock.ts b/frontend/src/views/public/works/_dev-mock.ts index 3a8cf4e..712eb67 100644 --- a/frontend/src/views/public/works/_dev-mock.ts +++ b/frontend/src/views/public/works/_dev-mock.ts @@ -48,7 +48,7 @@ export const MOCK_USER_WORKS: UserWork[] = [ visibility: 'private', status: 'unpublished', reviewNote: null, - originalImageUrl: null, + originalImageUrl: mockCover(40), voiceInputUrl: null, textInput: null, aiMeta: null, @@ -67,12 +67,12 @@ export const MOCK_USER_WORKS: UserWork[] = [ id: 102, userId: 1, title: '正在创作中…', - coverUrl: mockCover(180), + coverUrl: null, description: null, visibility: 'private', status: 'draft', reviewNote: null, - originalImageUrl: null, + originalImageUrl: mockCover(60), voiceInputUrl: null, textInput: null, aiMeta: null, @@ -96,7 +96,7 @@ export const MOCK_USER_WORKS: UserWork[] = [ visibility: 'public', status: 'pending_review', reviewNote: null, - originalImageUrl: null, + originalImageUrl: mockCover(80), voiceInputUrl: null, textInput: null, aiMeta: null, @@ -120,7 +120,7 @@ export const MOCK_USER_WORKS: UserWork[] = [ visibility: 'public', status: 'published', reviewNote: null, - originalImageUrl: null, + originalImageUrl: mockCover(120), voiceInputUrl: null, textInput: null, aiMeta: null, @@ -144,7 +144,7 @@ export const MOCK_USER_WORKS: UserWork[] = [ visibility: 'public', status: 'rejected', reviewNote: '内容包含疑似版权角色,请修改后重新提交', - originalImageUrl: null, + originalImageUrl: mockCover(160), voiceInputUrl: null, textInput: null, aiMeta: null, From 45d4ac2216868e4c3a4a296b755b4fb2b72ccac4 Mon Sep 17 00:00:00 2001 From: aid Date: Thu, 9 Apr 2026 20:08:38 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(leai):=20originalImageUrl=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=85=9C=E5=BA=95=E5=90=8C=E6=AD=A5=E4=B8=8E=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E6=95=B0=E6=8D=AE=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: leai webhook 同步作品时大多只传 coverUrl 不传 originalImageUrl, 导致前端作品库 PIP 画中画与详情页「画作原图」卡片不展示 (v-if="work.originalImageUrl" 检查为 null/空字符串时屏蔽)。 修复: - LeaiSyncService 三处(createWork/updateProcessing/updateStatusForward) 加 fallback:originalImageUrl 没传但 coverUrl 有传时,用 coverUrl 兜底 - V13 migration 回填历史数据(IS NULL 条件) - V14 force 重跑(V13 因 history 表残留同版本号脏数据被 repair 跳过) - V15 补充:部分作品 original_image_url 是空字符串而非 NULL, V14 的 IS NULL 没匹配到,V15 用 (IS NULL OR = '') 兼容空串 剩余 TODO(留给后端联调): - leai webhook 后续若拆分独立的 cover 字段,前端 PIP 将自动展现 "AI 封面 + 原图" 的真实区分(当前所有作品大小图相同时由 前端 !== 检查屏蔽 PIP) - 详见 docs/design/public/ugc-work-status-redesign.md Co-Authored-By: Claude Opus 4.6 (1M context) --- .../modules/leai/service/LeaiSyncService.java | 15 ++++++++++ .../V13__backfill_original_image_url.sql | 29 +++++++++++++++++++ ...V14__force_backfill_original_image_url.sql | 15 ++++++++++ ...ckfill_original_image_url_empty_string.sql | 22 ++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 backend-java/src/main/resources/db/migration/V13__backfill_original_image_url.sql create mode 100644 backend-java/src/main/resources/db/migration/V14__force_backfill_original_image_url.sql create mode 100644 backend-java/src/main/resources/db/migration/V15__backfill_original_image_url_empty_string.sql 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 62a83fd..ccb3ffa 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 @@ -129,6 +129,13 @@ public class LeaiSyncService implements ILeaiSyncService { if (coverUrl == null) coverUrl = remoteData.get("cover_url"); 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(多租户场景) if (phone != null && work.getUserId() == null) { Long userId = findUserIdByPhone(phone); @@ -189,6 +196,10 @@ public class LeaiSyncService implements ILeaiSyncService { if (coverUrl != null) { 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()); ugcWorkMapper.update(null, wrapper); @@ -237,6 +248,10 @@ public class LeaiSyncService implements ILeaiSyncService { if (coverUrl != null) { 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()); diff --git a/backend-java/src/main/resources/db/migration/V13__backfill_original_image_url.sql b/backend-java/src/main/resources/db/migration/V13__backfill_original_image_url.sql new file mode 100644 index 0000000..6f5b3fe --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V13__backfill_original_image_url.sql @@ -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; diff --git a/backend-java/src/main/resources/db/migration/V14__force_backfill_original_image_url.sql b/backend-java/src/main/resources/db/migration/V14__force_backfill_original_image_url.sql new file mode 100644 index 0000000..e04b197 --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V14__force_backfill_original_image_url.sql @@ -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; diff --git a/backend-java/src/main/resources/db/migration/V15__backfill_original_image_url_empty_string.sql b/backend-java/src/main/resources/db/migration/V15__backfill_original_image_url_empty_string.sql new file mode 100644 index 0000000..c2a81e5 --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V15__backfill_original_image_url_empty_string.sql @@ -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 != '';