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 @@