diff --git a/game-studio/package-lock.json b/game-studio/package-lock.json index b0714cb2..50e577de 100644 --- a/game-studio/package-lock.json +++ b/game-studio/package-lock.json @@ -12,6 +12,7 @@ "pinia": "^3.0.4", "vant": "^4.9.24", "vue": "^3.5.34", + "vue-i18n": "^10.0.0", "vue-router": "^4.6.4" }, "devDependencies": { @@ -103,6 +104,50 @@ "tslib": "^2.4.0" } }, + "node_modules/@intlify/core-base": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-10.0.8.tgz", + "integrity": "sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "10.0.8", + "@intlify/shared": "10.0.8" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-10.0.8.tgz", + "integrity": "sha512-DV+sYXIkHVd5yVb2mL7br/NEUwzUoLBsMkV3H0InefWgmYa34NLZUvMCGi5oWX+Hqr2Y2qUxnVrnOWF4aBlgWg==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "10.0.8", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/@intlify/shared": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-10.0.8.tgz", + "integrity": "sha512-BcmHpb5bQyeVNrptC3UhzpBZB/YHHDoEREOUERrmF2BRxsyOEuRrq+Z96C/D4+2KJb8kuHiouzAei7BXlG0YYw==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -231,9 +276,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -251,9 +293,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -271,9 +310,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -291,9 +327,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -311,9 +344,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -331,9 +361,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1215,9 +1242,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1239,9 +1263,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1263,9 +1284,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1287,9 +1305,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1747,6 +1762,33 @@ } } }, + "node_modules/vue-i18n": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-10.0.8.tgz", + "integrity": "sha512-mIjy4utxMz9lMMo6G9vYePv7gUFt4ztOMhY9/4czDJxZ26xPeJ49MAGa9wBAE3XuXbYCrtVPmPxNjej7JJJkZQ==", + "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "10.0.8", + "@intlify/shared": "10.0.8", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-i18n/node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, "node_modules/vue-router": { "version": "4.6.4", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", diff --git a/game-studio/src/locales/create.en.ts b/game-studio/src/locales/create.en.ts index 58bd4b73..3db5fdac 100644 --- a/game-studio/src/locales/create.en.ts +++ b/game-studio/src/locales/create.en.ts @@ -7,6 +7,14 @@ export default { // —— 顶部标题区 —— title: 'One sentence, one game', subtitle: 'Pick a template, describe your idea in one line, and AI builds it', + // —— 新手引导(P-CRT-10)—— + guide: { + title: 'Three steps to your game', + step1: 'Pick a direction (or just write)', + step2: 'Describe your idea in one line', + step3: 'Tap “Generate” and preview in seconds', + collapseAria: 'Collapse onboarding guide', + }, // —— 模板选择区 —— pickTemplate: 'Pick a template', templateLoading: 'Loading templates…', @@ -15,14 +23,21 @@ export default { loadFailedDesc: 'Network error or service not ready, please retry', reload: 'Reload', // 空(非故障):玩法模板体系升级中 - upgradingTitle: 'Template system is upgrading', - upgradingDesc: 'The new one-sentence creator is coming soon — explore the feed first', - goFeed: 'Go to feed', + upgradingTitle: 'Just one sentence makes a game', + upgradingDesc: + 'Templates are upgrading, but you can already describe an idea in one line and let AI build it. Try the example below.', + goFeed: 'Explore the feed', // —— 一句话描述输入区 —— describe: 'Describe in one line', - promptPlaceholder: 'e.g. an idle game where tapping earns coins, faster and faster', + promptPlaceholder: 'e.g. a game about running a bubble tea shop', + // Prompt 输入引导(P-CRT-10) + promptHelper: 'Say “what + goal/gameplay”. The more specific, the closer the result.', + // —— 示例 Prompt 库(P-CRT-11)—— + exampleLabel: 'Examples', // 示例 Prompt 一键填入前缀(后接模板示例文案) useExample: 'Use example: ', + // 未选模板时的通用示例 + genericExample: 'a game about running a bubble tea shop', // —— 底部提交 —— submit: 'Generate', submitting: 'Creating…', diff --git a/game-studio/src/locales/create.zh.ts b/game-studio/src/locales/create.zh.ts index e49f4ac7..7b936f10 100644 --- a/game-studio/src/locales/create.zh.ts +++ b/game-studio/src/locales/create.zh.ts @@ -8,6 +8,16 @@ export default { // —— 顶部标题区 —— title: '一句话,做个小游戏', subtitle: '选一个玩法模板,用一句话描述你的创意,AI 帮你生成', + // —— 新手引导(P-CRT-10:极简三步,贴「一句话」简单基调,不过度引导)—— + guide: { + // 引导卡标题(首屏新人可见,点右上角可收起) + title: '三步,做出你的小游戏', + step1: '选一个玩法方向(或直接写)', + step2: '一句话描述你的创意', + step3: '点「开始生成」,几秒后预览试玩', + // 收起/展开无障碍标签 + collapseAria: '收起新手引导', + }, // —— 模板选择区 —— pickTemplate: '选择玩法模板', templateLoading: '模板加载中…', @@ -15,15 +25,21 @@ export default { loadFailedTitle: '模板加载失败', loadFailedDesc: '网络异常或服务未就绪,请重试', reload: '重新加载', - // 空(非故障):玩法模板体系升级中 - upgradingTitle: '玩法模板体系升级中', - upgradingDesc: '新版「一句话创作」即将上线,先去游戏流逛逛吧', - goFeed: '去游戏流', + // 空(非故障):玩法模板体系升级中(P-CRT-10:补「一句话造游戏」引导 + 示例预告,避免空态像故障) + upgradingTitle: '直接一句话,就能造游戏', + upgradingDesc: '玩法模板正在升级,但你现在就能用一句话描述创意、让 AI 生成。试试下面的示例。', + goFeed: '去游戏流逛逛', // —— 一句话描述输入区 —— describe: '用一句话描述', - promptPlaceholder: '例如:做一个点击就能赚金币、越点越快的放置小游戏', - // 示例 Prompt 一键填入前缀(后接模板示例文案) + promptPlaceholder: '例如:做一个经营奶茶店的小游戏', + // Prompt 输入引导(P-CRT-10(b):placeholder 之外的近场提示,告诉新人怎么写更好) + promptHelper: '写清「做什么 + 目标/玩法」,描述越具体,生成越贴你的想法。', + // —— 示例 Prompt 库(P-CRT-11:强化「示例库」语义)—— + exampleLabel: '示例库', + // 选中模板时:用该模板示例;未选模板时:用通用示例(保证一键填示例随时可用,P-CRT-10(c)) useExample: '用示例:', + // 未选模板时的通用示例(让「一键填示例」在没选模板前也能用) + genericExample: '做一个经营奶茶店的小游戏', // —— 底部提交 —— submit: '开始生成', submitting: '正在创建…', diff --git a/game-studio/src/mock/aigc.ts b/game-studio/src/mock/aigc.ts index 8c47f9c8..01b009fd 100644 --- a/game-studio/src/mock/aigc.ts +++ b/game-studio/src/mock/aigc.ts @@ -13,7 +13,13 @@ */ import type { MockRoute, MockRequest } from './shared' import { MockError, genTraceId, nowISO } from './shared' -import type { AigcTemplate, AigcTask, GenerateResp, AigcTaskStatus } from '../api/types' +import type { + AigcTemplate, + AigcTask, + GenerateResp, + AigcTaskStatus, + AigcFailureReason, +} from '../api/types' /** demo 产物版本 ID(与 feed/runtime mock 对齐:生成成功后预览试玩同一 demo 包) */ const DEMO_VERSION_ID = 9001 @@ -31,48 +37,74 @@ interface MockTask { traceId: string /** 手动终态覆盖(取消时设 canceled);为空则按时间推进 */ forcedStatus?: AigcTaskStatus + /** + * 终态失败注入(P-CRT-13 可测性):Prompt 命中触发词时落该结构, + * 任务先正常推进进度 0→99,到达 GEN_DURATION 后落「失败/超时」终态而非 succeeded。 + * - status:3 failed / 4 timed_out; + * - reason:契约 FailureReason 枚举,供失败态原因映射回显。 + * 注:这是「轮询查到的终态失败」(CommonResult code:0、data.status=3/4), + * 不是网络层错误——前端据此就地 Toast 一次,与 axios 拦截器的网络错误 Toast 互不重叠。 + */ + forcedFail?: { status: 3 | 4; reason: AigcFailureReason } retryOf?: number createTime: string } const tasks = new Map() let taskSeq = 7000 // taskId 起始 -/** 玩法模板(贴 TemplateRespVO;templateId 取契约示例集合) */ +/** + * 失败注入触发词解析(P-CRT-13 可测性)。 + * 仅 mock 生效:Prompt 含 `test_fail` → 终态 failed(3, llm_error);含 `test_timeout` → 终态 timed_out(4, timeout)。 + * 真实后端无此逻辑(联调 VITE_API_BASE 时本 mock 整体关闭)。返回 undefined 表示正常成功路径。 + */ +function parseForcedFail(prompt: string): MockTask['forcedFail'] { + const p = prompt.toLowerCase() + if (p.includes('test_timeout')) return { status: 4, reason: 'timeout' } + if (p.includes('test_fail')) return { status: 3, reason: 'llm_error' } + return undefined +} + +/** + * 玩法模板(贴 TemplateRespVO;P-CRT-11 与真实后端 U7 R-TPL 五品类逐字段对齐)。 + * 同源 = game-cloud AigcTaskServiceImpl.getTemplateList() + AigcTemplateConstants.SUPPORTED_TEMPLATE_IDS: + * templateId/name/description/examplePrompt 取真实后端字面值,确保 mock 与真连后端行为一致 + * (避免「mock 一套示例、真连后端另一套」的割裂)。coverUrl 后端为占位 PNG,mock 用内联 SVG 占位便于本地预览。 + */ const TEMPLATES: AigcTemplate[] = [ { - templateId: 'clicker', - name: '点击放置', - description: '点击累积数值的放置类玩法,适合“一句话”快速出成品', - coverUrl: tplCover('clicker', '#31e6ff'), - examplePrompt: '做一个点击就能赚金币、越点越快的放置小游戏', + templateId: 'business-sim', + name: '经营模拟', + description: '围绕资源、生产、收益与再投入的经济闭环,把摊位/小店经营到目标即通关。', + coverUrl: tplCover('经营模拟', '#31e6ff'), + examplePrompt: '做一个经营奶茶店的小游戏', }, { - templateId: 'dodge', - name: '躲避闪避', - description: '控制角色躲避来袭障碍,考验反应', - coverUrl: tplCover('dodge', '#ff669d'), - examplePrompt: '一个躲避陨石的太空船小游戏,越往后越快', - }, - { - templateId: 'runner', - name: '跑酷', - description: '横版自动奔跑 + 跳跃躲障碍', - coverUrl: tplCover('runner', '#7cf2a4'), - examplePrompt: '霓虹城市里的跑酷,跳过障碍吃金币', + templateId: 'narrative', + name: '剧情互动', + description: '以文本场景加分支选择推进的互动叙事,不同选择导向不同结局。', + coverUrl: tplCover('剧情互动', '#ff669d'), + examplePrompt: '做一个校园悬疑的剧情选择小游戏', }, { templateId: 'puzzle', - name: '消除益智', - description: '三消/连连看类益智玩法', - coverUrl: tplCover('puzzle', '#9d7cff'), - examplePrompt: '糖果主题的三消游戏,过关解锁新糖果', + name: '解谜闯关', + description: '一关一谜、有确定解的智力挑战,解出当前关推进到下一关、难度递增。', + coverUrl: tplCover('解谜闯关', '#9d7cff'), + examplePrompt: '做一个连线配对的解谜闯关小游戏', }, { - templateId: 'shooter', - name: '射击', - description: '弹幕/射击类玩法', - coverUrl: tplCover('shooter', '#ffc857'), - examplePrompt: '飞机大战,打 boss 掉装备', + templateId: 'trpg', + name: 'TRPG', + description: '带角色属性与骰子检定的桌面角色扮演,掷骰加属性决定剧情成败分支。', + coverUrl: tplCover('TRPG', '#7cf2a4'), + examplePrompt: '做一个勇者闯地下城的跑团小游戏', + }, + { + templateId: 'heritage', + name: '非遗科普', + description: '围绕一项非物质文化遗产寓教于乐,边玩边学传统文化知识点。', + coverUrl: tplCover('非遗科普', '#ffc857'), + examplePrompt: '做一个学剪纸的非遗科普小游戏', }, ] @@ -123,6 +155,25 @@ function projectTask(t: MockTask): AigcTask { createTime: t.createTime, } } + // 终态失败注入(P-CRT-13):触发词命中时落 failed(3)/timed_out(4) 终态 + 失败原因, + // 先走完上面的进度推进、再翻失败终态,完整演练「进行中→就地失败可重试」UI。 + if (t.forcedFail) { + return { + id: t.id, + prompt: t.prompt, + templateId: t.templateId, + gameId: t.gameId, + versionId: t.versionId, + status: t.forcedFail.status, + // 失败终态进度回 0(与取消终态一致语义) + progress: 0, + failureReason: t.forcedFail.reason, + traceId: t.traceId, + retryOf: t.retryOf, + createTime: t.createTime, + finishTime: nowISO(), + } + } // 终态:succeeded,回填产物 versionId + 生成质量分(0-1) + packageUrl(aigc 只读透传) return { id: t.id, @@ -163,11 +214,14 @@ function generateHandler(req: MockRequest): GenerateResp { tasks.set(id, { id, prompt: body.prompt, - templateId: body.templateId || 'clicker', + // 缺省 templateId 归一为 generic(对齐真实后端 submitGenerate 行为,避免 mock 用旧模板 id) + templateId: body.templateId || 'generic', gameId: body.gameId, versionId: body.versionId, startMs: Date.now(), traceId, + // P-CRT-13:据 Prompt 触发词注入终态失败/超时(test_fail / test_timeout) + forcedFail: parseForcedFail(body.prompt), createTime: nowISO(), }) return { taskId: id, status: 0, traceId } @@ -208,6 +262,8 @@ function retryHandler(req: MockRequest): GenerateResp { } const newId = ++taskSeq const traceId = genTraceId('gen') + // 重试新建任务「不」继承 forcedFail:演练「失败→重试→成功」闭环(否则会无限失败,CDP 门走不通)。 + // 想再演练失败请重新从创作页用触发词提交,而非靠重试。 tasks.set(newId, { id: newId, prompt: t.prompt, diff --git a/game-studio/src/views/create/Create.vue b/game-studio/src/views/create/Create.vue index 9955d3e7..40ffebb9 100644 --- a/game-studio/src/views/create/Create.vue +++ b/game-studio/src/views/create/Create.vue @@ -49,6 +49,19 @@ const selectedTemplate = computed(() => createStore.templates.find((t) => t.templateId === selectedTemplateId.value), ) +/** + * 生效示例 Prompt(P-CRT-10(c) + P-CRT-11): + * 优先取选中模板的 examplePrompt(真实来自 TemplateRespVO.examplePrompt); + * 未选模板(含 template/list 返空的空态)时回落通用示例 create.genericExample, + * 保证「一键填示例」在任何时刻都可用、不因没选模板而消失。 + */ +const effectiveExample = computed( + () => selectedTemplate.value?.examplePrompt || t('create.genericExample'), +) + +/** 新手引导卡是否展开(P-CRT-10(a);默认展开,用户可收起,会话内记忆) */ +const guideOpen = ref(true) + /** * 是否可提交:Prompt 非空 + 不在提交中。 * generic 桥接(W-CLEAN 废玩法模板层后,一句话创作无需选模板):去掉「必须选中模板」条件—— @@ -81,9 +94,12 @@ function selectTemplate(id: string) { selectedTemplateId.value = id } -/** 点击「示例」一键填入示例 Prompt(P-CRT-11 示例 Prompt 库) */ +/** + * 点击「示例」一键填入示例 Prompt(P-CRT-11 示例 Prompt 库 + P-CRT-10(c))。 + * 取 effectiveExample(选中模板示例 → 通用示例兜底),故未选模板时也能一键填入。 + */ function useExample() { - const ex = selectedTemplate.value?.examplePrompt + const ex = effectiveExample.value if (ex) prompt.value = ex } @@ -143,6 +159,29 @@ onMounted(loadTemplates)

{{ t('create.subtitle') }}

+ +
+
+ + + {{ t('create.guide.title') }} + + +
+
    +
  1. 1{{ t('create.guide.step1') }}
  2. +
  3. 2{{ t('create.guide.step2') }}
  4. +
  5. 3{{ t('create.guide.step3') }}
  6. +
+
+
+ + +
+ + + {{ t('create.exampleLabel') }} + + +
@@ -271,6 +314,70 @@ onMounted(loadTemplates) line-height: 1.5; } +/* —— 新手引导卡(P-CRT-10)—— */ +.create-guide { + margin-top: 14px; + padding: 12px 14px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} +.guide-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} +.guide-title { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + font-weight: 600; + color: var(--accent); +} +.guide-collapse { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border: 0; + background: transparent; + color: var(--muted); + border-radius: var(--radius-sm); +} +.guide-steps { + margin: 10px 0 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; +} +.guide-steps li { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--ink); + line-height: 1.4; +} +.guide-step-no { + flex: 0 0 auto; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + color: var(--on-accent); + background: var(--accent); + /* 统一圆角矩形(禁正圆) */ + border-radius: var(--radius-sm); +} + /* —— 区块通用 —— */ .create-section { margin-top: 20px; @@ -400,36 +507,72 @@ onMounted(loadTemplates) } .prompt-foot { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 12px; margin-top: 8px; } -.prompt-example { +/* 输入引导文案(P-CRT-10(b):近场写法提示,弱化但可读) */ +.prompt-helper { flex: 1; min-width: 0; - display: inline-flex; - align-items: center; - gap: 4px; - border: 0; - background: transparent; - /* 警示/进行中语义色(原 --amber → --warning) */ - color: var(--warning); + margin: 0; font-size: 12px; - text-align: left; - padding: 0; -} -/* 示例文案单行省略(图标不参与省略,故省略改挂内层文本) */ -.prompt-example-text { - min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + color: var(--muted); + line-height: 1.5; } .prompt-count { flex: 0 0 auto; font-size: 12px; color: var(--muted); + /* 与 helper 首行对齐 */ + line-height: 1.5; +} + +/* —— 示例 Prompt 库(P-CRT-11 + P-CRT-10(c))—— */ +.example-lib { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} +.example-lib-label { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; + color: var(--muted); +} +.example-chip { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + padding: 7px 12px; + background: var(--panel); + border: 1px solid var(--border); + /* 圆角矩形(禁药丸/正圆),统一 --radius-sm */ + border-radius: var(--radius-sm); + text-align: left; + transition: border-color 0.15s ease, transform 0.05s ease; +} +.example-chip:active { + transform: scale(0.99); +} +.example-chip:hover { + border-color: var(--accent); +} +/* 示例文案单行省略(点中可一键填入完整文案) */ +.example-chip-text { + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 12px; + /* 警示/进行中语义色,呼应「点我试试」的引导色 */ + color: var(--warning); } /* —— 底部提交 —— */ diff --git a/game-studio/src/views/create/Task.vue b/game-studio/src/views/create/Task.vue index a9312e8d..de1befa7 100644 --- a/game-studio/src/views/create/Task.vue +++ b/game-studio/src/views/create/Task.vue @@ -18,6 +18,7 @@ import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' +import { showToast } from 'vant' import { useCreateStore } from '../../store/create' import type { AigcTaskStatus, AigcFailureReason } from '../../api/types' import { LoadingBar, AppButton, EmptyState } from '../../components' @@ -114,6 +115,36 @@ const barColor = computed(() => { return 'var(--warning)' }) +/** + * 就地失败通知文案(P-CRT-13(c):区分超时与一般失败)。 + * = 状态级标题(status 4「生成超时」/ status 3「生成失败」)+ 失败原因详情。 + * 与页面失败态 EmptyState(title=stepText、desc=failureText)口径一致,Toast 一句话带全。 + */ +const failNoticeText = computed(() => `${stepText.value}:${failureText.value}`) + +/** + * 已就地通知过失败的 taskId(P-CRT-13(b) 去重): + * 轮询查到终态失败属「业务成功响应(CommonResult code:0、data.status=3/4)」, + * 不会触发 axios 拦截器的网络错误 Toast;本页就地 Toast 是该失败的唯一提示。 + * 用 taskId 作幂等键,确保同一任务的失败只 Toast 一次(防 watcher 重入/computed 重算重复弹)。 + */ +const notifiedFailTaskId = ref(null) + +/** + * 监听终态失败(failed/timed_out)→ 就地 Toast 一次(P-CRT-13(a)(b)(c))。 + * 重试按钮始终在失败态 EmptyState 的 #action 槽内可见,无需此处额外处理。 + */ +watch( + () => [isFailed.value, task.value?.id] as const, + ([failed, id]) => { + if (failed && id != null && notifiedFailTaskId.value !== id) { + notifiedFailTaskId.value = id + showToast({ message: failNoticeText.value, type: 'fail' }) + } + }, + { immediate: true }, +) + /** * 监听终态成功 → 自动跳预览页。 * 预览路由仅有 :versionId,故把 gameId 通过 query 一并带上(GamePlayer 需 gameId;