feat(studio): E6/U6 创作流改一步式——Create/Task/store 接 /studio/* 任务链

把创作流从旧两步(project.create + aigc.generate)改为一步式 studio.create(plan 002 U6):
- store/create.ts:重写为 studio 任务链编排——loadTemplates/create/modify/extend/pollTask(getTaskChain)/regenerate;终态集 {2完成,3失败}(studio 链无 4/5);currentTask→currentChain
- views/create/Create.vue:onSubmit 一次调 createStore.create({title,prompt,templateId}) 取任务链(含 gameId),去 projectStore 两步;模板类型 AigcTemplate→StudioTemplate
- views/create/Task.vue:轮询 studio 任务链(status 0初始/1生成中/2完成/3失败);去取消(studio 契约无 cancel 端点)、去 failureReason 逐项映射(链无该字段,通用兜底);retry→regenerate;成功→预览(versionId+gameId)
- 预览侧 GamePlayer 零改(已支持 versionId/gameId)

验证:`npm run build`(vue-tsc -b + vite build)绿;消费方仅 Create/Task(已同步)。运行态 create→poll→preview 全链 CDP 真机走查待 modify 入口落地后一并做。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-17 15:31:32 -07:00
parent c2b7a572a1
commit 9300ae2d6d
3 changed files with 144 additions and 192 deletions

View File

@ -1,25 +1,39 @@
/**
* useCreateStore + +
* spec §3.2state templates/currentTask/progressactions loadTemplates/generate/pollTask/cancel/retry
* getTask//
* useCreateStore studio.yaml /studio/* + +
* plan 002 U6 / execution §5.6state templates/currentChain/progress
* actions loadTemplates/create/modify/extend/pollTask/regenerate/reset
*
*
* create draft+generate
* modify deterministic / regenerate-module behavior
* extend additive
* StudioTaskChain task/{id} status=2 versionId / 3
* /studio regenerate
*/
import { defineStore } from 'pinia'
import { ref } from 'vue'
import * as aigcApi from '../api/aigc'
import type { AigcTemplate, AigcTask, AigcTaskStatus } from '../api/types'
import * as studioApi from '../api/studio'
import type {
StudioTemplate,
StudioTaskChain,
StudioTaskChainStatus,
StudioCreateReq,
StudioModifyReq,
StudioExtendReq,
} from '../api/types'
/** 终态集合succeeded(2)/failed(3)/timed_out(4)/canceled(5) → 停止轮询 */
const TERMINAL: ReadonlySet<AigcTaskStatus> = new Set<AigcTaskStatus>([2, 3, 4, 5])
/** 终态集合:完成(2)/失败(3) → 停止轮询studio 任务链无 4/5 */
const TERMINAL: ReadonlySet<StudioTaskChainStatus> = new Set<StudioTaskChainStatus>([2, 3])
/** 轮询间隔毫秒。mock 进度递增节奏与之匹配(见 mock/aigc。 */
/** 轮询间隔毫秒。mock 进度递增节奏与之匹配(见 mock/studio)。 */
const POLL_INTERVAL = 1200
export const useCreateStore = defineStore('create', () => {
/** 玩法模板列表 */
const templates = ref<AigcTemplate[]>([])
/** 当前生成任务(轮询对象) */
const currentTask = ref<AigcTask | null>(null)
/** 进度百分比0-100进度条用冗余自 currentTask.progress 便于直接绑定) */
/** 玩法模板列表(透传 aigc 模板;一句话直生可不选) */
const templates = ref<StudioTemplate[]>([])
/** 当前创作任务链(轮询对象) */
const currentChain = ref<StudioTaskChain | null>(null)
/** 进度百分比0-100进度条用冗余自 currentChain.progress 便于直接绑定) */
const progress = ref<number>(0)
/** 是否轮询中 */
const polling = ref<boolean>(false)
@ -38,18 +52,18 @@ export const useCreateStore = defineStore('create', () => {
/** 加载玩法模板列表 */
async function loadTemplates() {
templates.value = await aigcApi.getTemplates()
templates.value = await studioApi.getTemplates()
}
/**
* state
* @param taskId ID
* state
* @param id ID
*/
async function fetchOnce(taskId: number) {
const task = await aigcApi.getTask(taskId)
currentTask.value = task
progress.value = task.progress
if (TERMINAL.has(task.status)) {
async function fetchOnce(id: number) {
const chain = await studioApi.getTaskChain(id)
currentChain.value = chain
progress.value = chain.progress
if (TERMINAL.has(chain.status)) {
stopPolling()
}
}
@ -57,81 +71,87 @@ export const useCreateStore = defineStore('create', () => {
/**
*
*
* @param taskId ID
* @param id ID
*/
async function pollTask(taskId: number) {
async function pollTask(id: number) {
// 切换任务前先清理旧轮询
stopPolling()
polling.value = true
await fetchOnce(taskId)
await fetchOnce(id)
// 已是终态则无需起定时器
if (!polling.value) return
timer = setInterval(() => {
// 轮询内异常已被 axios 拦截器 Toast这里吞掉 reject 防止未捕获
void fetchOnce(taskId).catch(() => {})
void fetchOnce(id).catch(() => {})
}, POLL_INTERVAL)
}
/**
* taskId /create/task/:taskId
* @param prompt Prompt
* @param templateId ID genericW-CLEAN
* @param extra 稿gameId/versionId
* draft+generate id/gameId
* @param req StudioCreateReqtitle/prompt templateId genericassetContext
*/
async function generate(
prompt: string,
templateId?: string,
extra?: { gameId?: number; versionId?: number },
): Promise<number> {
const resp = await aigcApi.generate(prompt, templateId, extra)
progress.value = 0
// 不阻塞返回 taskId轮询在后台进行
void pollTask(resp.taskId)
return resp.taskId
async function create(req: StudioCreateReq): Promise<StudioTaskChain> {
const chain = await studioApi.createGame(req)
currentChain.value = chain
progress.value = chain.progress
void pollTask(chain.id)
return chain
}
/**
* / queued/running
* @param taskId ID
* currentVersion
* @param req StudioModifyReqbaseVersionId + mode + target + payload
*/
async function cancel(taskId: number): Promise<boolean> {
const ok = await aigcApi.cancelTask(taskId)
if (ok) {
stopPolling()
// 立即刷新一次拿到 canceled 终态
await fetchOnce(taskId).catch(() => {})
}
return ok
async function modify(req: StudioModifyReq): Promise<StudioTaskChain> {
const chain = await studioApi.modifyGame(req)
currentChain.value = chain
progress.value = chain.progress
void pollTask(chain.id)
return chain
}
/**
* failed/timed_out taskId
* @param taskId ID
* additive
* @param req StudioExtendReqbaseVersionId + intent
*/
async function retry(taskId: number): Promise<number> {
const resp = await aigcApi.retryTask(taskId)
progress.value = 0
void pollTask(resp.taskId)
return resp.taskId
async function extend(req: StudioExtendReq): Promise<StudioTaskChain> {
const chain = await studioApi.extendGame(req)
currentChain.value = chain
progress.value = chain.progress
void pollTask(chain.id)
return chain
}
/**
* /retryOf
* @param id ID
*/
async function regenerate(id: number): Promise<StudioTaskChain> {
const chain = await studioApi.regenerate(id)
currentChain.value = chain
progress.value = chain.progress
void pollTask(chain.id)
return chain
}
/** 重置(离开创作流时调用,清理状态与计时器) */
function reset() {
stopPolling()
currentTask.value = null
currentChain.value = null
progress.value = 0
}
return {
templates,
currentTask,
currentChain,
progress,
polling,
loadTemplates,
generate,
create,
modify,
extend,
pollTask,
cancel,
retry,
regenerate,
reset,
stopPolling,
}

View File

@ -3,23 +3,20 @@
* 创作入口页路由 /createspec §3.1 / §4.2 创作者闭环第 1
* ----------------------------------------------------------------------------
* 职责
* 1. 拉取玩法模板列表aigc.getTemplates契约 GET /app-api/aigc/template/listMVP 3-5
* 1. 拉取玩法模板列表studio.getTemplates契约 GET /app-api/studio/template/listMVP 3-5
* 2. 选模板 + 一句话自然语言描述输入 + 示例 Prompt 提示点击示例可一键填入
* 3. 提交生成先建草稿项目project.create契约 POST /app-api/project projectId
* 再投递生成任务create.generate 契约 POST /app-api/aigc/generate taskId
* 最后跳进度页 /create/task/:taskId携带 gameId 便于终态后续链路
* 3. 一步式创作create.create 契约 POST /app-api/studio/createdraft+generate 封装一次调用拿任务链
* 最后跳进度页 /create/task/:taskChainId携带任务链返回的 gameId 便于终态后续链路
*
* 契约纪律
* - 仅消费 useCreateStore / useProjectStore地基 store不直连后端不发明端点
* - 生成请求体严格贴 AigcGenerateReqVOprompt + templateId+ gameId 关联草稿
* - 创建草稿请求体严格贴 ProjectCreateReqVOtitle + templateId均必填
* - 仅消费 useCreateStore地基 store不直连后端不发明端点
* - 创作请求体严格贴 StudioCreateReqVOtitle + prompt 必填templateId 缺省 genericassetContext 六类素材
*/
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCreateStore } from '../../store/create'
import { useProjectStore } from '../../store/project'
import type { AigcTemplate } from '../../api/types'
import type { StudioTemplate } from '../../api/types'
import { LoadingBar, EmptyState, AppButton } from '../../components'
import Icon from '../../components/Icon.vue'
@ -28,7 +25,6 @@ const { t } = useI18n()
const router = useRouter()
const createStore = useCreateStore()
const projectStore = useProjectStore()
/** 当前选中的模板 ID默认空需用户主动选 */
const selectedTemplateId = ref<string>('')
@ -38,14 +34,14 @@ const prompt = ref<string>('')
const loadingTpl = ref<boolean>(false)
/** 模板加载失败标记(用于空态重试) */
const tplError = ref<boolean>(false)
/** 提交中(建草稿 + 投递生成期间禁用按钮,防重复提交) */
/** 提交中(一步式创作期间禁用按钮,防重复提交) */
const submitting = ref<boolean>(false)
/** Prompt 最大长度(贴契约 AigcGenerateReqVO.prompt maxLength=1000 */
/** Prompt 最大长度(贴契约 StudioCreateReqVO.prompt maxLength=1000 */
const PROMPT_MAX = 1000
/** 当前选中的模板对象(用于取示例 Prompt / 作为草稿标题兜底) */
const selectedTemplate = computed<AigcTemplate | undefined>(() =>
const selectedTemplate = computed<StudioTemplate | undefined>(() =>
createStore.templates.find((t) => t.templateId === selectedTemplateId.value),
)
@ -97,34 +93,29 @@ function goFeed() {
}
/**
* 提交生成创作者闭环核心动作
* 步骤建草稿项目 projectId 作为 gameId 投递生成任务 跳进度页
* 失败路径任一步异常 错误码 Toast拦截器+ 解除提交锁停留本页可重试
* 一步式创作创作者闭环核心动作
* 步骤studio.createdraft+generate 封装 拿任务链(id/gameId) 跳进度页
* 失败路径异常 错误码 Toast拦截器+ 解除提交锁停留本页可重试
*/
async function onSubmit() {
if (!canSubmit.value) return
submitting.value = true
try {
// generic W-CLEAN templateId 'generic'
// generic templateId 'generic'
// template/list
const templateId = selectedTemplateId.value || 'generic'
// 1) 稿title Prompt 30
// ProjectCreateReqVO required:[title]templateId generic
// title Prompt 30 StudioCreateReqVO.title 128
const title = prompt.value.trim().slice(0, 30) || selectedTemplate.value?.name || '未命名作品'
const projectId = await projectStore.create({
title,
templateId,
})
// 2) 稿gameId=projectIdstore
// AigcGenerateReqVOprompt+ gameId 稿templateId generic
const taskId = await createStore.generate(prompt.value.trim(), templateId, {
gameId: projectId,
})
// POST /app-api/studio/create ( id/gameId)store
const chain = await createStore.create({ title, prompt: prompt.value.trim(), templateId })
// 3) gameId/使
router.push({ name: 'create-task', params: { taskId: String(taskId) }, query: { gameId: String(projectId) } })
// gameId/使
router.push({
name: 'create-task',
params: { taskId: String(chain.id) },
query: chain.gameId != null ? { gameId: String(chain.gameId) } : {},
})
} catch {
// Toast
submitting.value = false

View File

@ -1,25 +1,23 @@
<script setup lang="ts">
/**
* 生成进度页路由 /create/task/:taskIdspec §3.1 / §4.2 创作者闭环第 2
* 生成进度页路由 /create/task/:taskIdtaskId 语义=taskChainIdspec §3.1 / §4.2 创作者闭环第 2
* ----------------------------------------------------------------------------
* 职责
* 1. 进入即对该 taskId 启动轮询create.pollTask 契约 GET /app-api/aigc/task/{id}
* 1. 进入即对该 taskChainId 启动轮询create.pollTask 契约 GET /app-api/studio/task/{id}
* LoadingBar 展示步骤文案 + 百分比状态机驱动 UI
* 2. 终态 succeeded(2)自动跳生成后预览页 /create/preview/:versionId携带 gameId
* 3. 终态 failed(3)/timed_out(4)展示失败态 + 失败原因FailureReason 映射可读提示
* + 重试按钮create.retry 契约 POST /app-api/aigc/task/{id}/retry新建任务
* 4. 进行中 queued(0)/running(1)提供取消按钮create.cancel 契约 POST .../cancel
* 2. 终态 完成(2)自动跳生成后预览页 /create/preview/:versionId携带 gameId
* 3. 终态 失败(3)展示失败态 + 重生成按钮create.regenerate 契约 POST /app-api/studio/task/{id}/regenerate新建任务链
* 4. 进行中 初始(0)/生成中(1)展示进度studio 契约无取消端点故不提供取消
*
* 契约纪律
* - 仅消费 useCreateStore轮询/取消/重试逻辑封装其中不直连后端不发明端点/字段
* - 状态机取值与 AigcTaskRespVO.status 一致0 queued/1 running/2 succeeded/3 failed/4 timed_out/5 canceled
* - FailureReason 映射严格取契约 aigc.yaml FailureReason 七种枚举不私造
* - 仅消费 useCreateStore轮询/重生成逻辑封装其中不直连后端不发明端点/字段
* - 状态机取值与 StudioTaskChainRespVO.status 一致0 初始 / 1 生成中 / 2 完成 / 3 失败
*/
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useCreateStore } from '../../store/create'
import type { AigcTaskStatus, AigcFailureReason } from '../../api/types'
import type { StudioTaskChainStatus } from '../../api/types'
import { LoadingBar, AppButton, EmptyState } from '../../components'
import Icon from '../../components/Icon.vue'
import taskflowZh from '../../locales/taskflow.zh'
@ -38,26 +36,22 @@ const { t } = useI18n({
const router = useRouter()
const createStore = useCreateStore()
/** 是否正在执行取消(防重复点击) */
const cancelling = ref<boolean>(false)
/** 是否正在执行重试(防重复点击) */
const retrying = ref<boolean>(false)
/** 是否正在执行重生成(防重复点击) */
const regenerating = ref<boolean>(false)
/** 当前 taskId(数字化 */
const taskId = computed(() => Number(props.taskId))
/** 当前任务链 ID数字化=taskChainId */
const chainId = computed(() => Number(props.taskId))
/** 当前任务(来自 store轮询持续刷新 */
const task = computed(() => createStore.currentTask)
/** 当前状态机值(无任务时视为 queued 占位) */
const status = computed<AigcTaskStatus>(() => task.value?.status ?? 0)
/** 当前任务(来自 store轮询持续刷新 */
const chain = computed(() => createStore.currentChain)
/** 当前状态机值(无任务链时视为初始(0)占位) */
const status = computed<StudioTaskChainStatus>(() => chain.value?.status ?? 0)
/** 是否终态 */
/** 是否终态成 */
const isSucceeded = computed(() => status.value === 2)
/** 是否终态失败failed/timed_out */
const isFailed = computed(() => status.value === 3 || status.value === 4)
/** 是否已取消 */
const isCanceled = computed(() => status.value === 5)
/** 是否进行中queued/running */
/** 是否终态失败 */
const isFailed = computed(() => status.value === 3)
/** 是否进行中(初始/生成中) */
const isRunning = computed(() => status.value === 0 || status.value === 1)
/**
@ -79,33 +73,13 @@ const stepText = computed(() => {
return t('task.step.succeeded')
case 3:
return t('task.step.failed')
case 4:
return t('task.step.timedOut')
case 5:
return t('task.step.canceled')
default:
return t('task.step.fallback')
}
})
/**
* 失败原因可读映射严格对齐契约 aigc.yaml FailureReason 七种枚举
* 仅用于把结构化错误码翻译成用户提示不新增枚举值
* 放在 computed 内构建使文案随语言切换实时重算key 即契约枚举值不可改
*/
const failureText = computed(() => {
const FAILURE_TEXT: Record<AigcFailureReason, string> = {
unsafe_prompt: t('task.failure.unsafePrompt'),
intent_unclear: t('task.failure.intentUnclear'),
no_template_match: t('task.failure.noTemplateMatch'),
config_invalid: t('task.failure.configInvalid'),
llm_error: t('task.failure.llmError'),
timeout: t('task.failure.timeout'),
asset_gen_failed: t('task.failure.assetGenFailed'),
}
const reason = task.value?.failureReason
return (reason && FAILURE_TEXT[reason]) || t('task.failure.fallback')
})
/** 失败提示文案studio 任务链无 failureReason 结构化字段,用通用兜底提示)。 */
const failureText = computed(() => t('task.failure.fallback'))
/** 进度条颜色:成功绿 / 失败红 / 进行中琥珀(裸旧令牌改语义名,主题切换只动语义层) */
const barColor = computed(() => {
@ -120,10 +94,10 @@ const barColor = computed(() => {
* 发布跳转 /project/:id/publish 也需 projectId=gameId
*/
watch(
() => [isSucceeded.value, task.value?.versionId] as const,
() => [isSucceeded.value, chain.value?.versionId] as const,
([ok, versionId]) => {
if (ok && versionId != null) {
const gameId = task.value?.gameId
const gameId = chain.value?.gameId
router.replace({
name: 'create-preview',
params: { versionId: String(versionId) },
@ -133,41 +107,26 @@ watch(
},
)
/** 取消生成任务(仅进行中可取消;成功后任务转 canceled 终态) */
async function onCancel() {
if (cancelling.value) return
cancelling.value = true
try {
await createStore.cancel(taskId.value)
} catch {
// Toast 1-101-002-001
} finally {
cancelling.value = false
}
}
/**
* 重试生成 failed/timed_out 可重试新建任务并跳到新任务进度页
* 携带原 gameId 关联草稿从当前任务取
* 重生成失败/不满意时新建任务链并跳到新链进度页
*/
async function onRetry() {
if (retrying.value) return
retrying.value = true
async function onRegenerate() {
if (regenerating.value) return
regenerating.value = true
try {
const newTaskId = await createStore.retry(taskId.value)
const gameId = task.value?.gameId
// replace 退
const chainNew = await createStore.regenerate(chainId.value)
// replace 退
router.replace({
name: 'create-task',
params: { taskId: String(newTaskId) },
query: gameId != null ? { gameId: String(gameId) } : {},
params: { taskId: String(chainNew.id) },
query: chainNew.gameId != null ? { gameId: String(chainNew.gameId) } : {},
})
} catch {
retrying.value = false
regenerating.value = false
}
}
/** 返回创作入口(取消/失败后可重新创作) */
/** 返回创作入口(失败后可重新创作) */
function backToCreate() {
router.replace({ name: 'create' })
}
@ -175,11 +134,11 @@ function backToCreate() {
/**
* 进入页面即启动轮询pollTask 幂等会先清理旧轮询再起新的
* 覆盖两种入口 Create 提交跳入store 已在轮询这里重入也安全
* 直接 URL/刷新进入currentTask 为空需主动起轮询
* 直接 URL/刷新进入currentChain 为空需主动起轮询
*/
onMounted(() => {
if (Number.isFinite(taskId.value)) {
void createStore.pollTask(taskId.value)
if (Number.isFinite(chainId.value)) {
void createStore.pollTask(chainId.value)
}
})
@ -191,7 +150,7 @@ onBeforeUnmount(() => {
<template>
<div class="task-page">
<!-- 中部状态区进行中 / 成功瞬时随即跳转/ 失败 / 取消 -->
<!-- 中部状态区进行中 / 成功瞬时随即跳转/ 失败 -->
<div class="task-body">
<!-- 进行中 + 成功瞬间展示进度环境 -->
<template v-if="isRunning || isSucceeded">
@ -211,14 +170,10 @@ onBeforeUnmount(() => {
<p class="task-hint">
{{ isSucceeded ? t('task.hintSucceeded') : t('task.hintRunning') }}
</p>
<!-- 进行中可取消 -->
<div v-if="isRunning" class="task-actions">
<AppButton type="ghost" :loading="cancelling" @click="onCancel">{{ t('task.cancelAction') }}</AppButton>
</div>
<!-- studio 契约无取消端点进行中不提供取消等生成完成/失败 -->
</template>
<!-- 失败态原因 + 重试 / 返回失败图标用 SVG close 替原 emoji满足 no-emoji-icons 红线 -->
<!-- 失败态提示 + 重新生成 / 返回失败图标用 SVG close 替原 emoji满足 no-emoji-icons 红线 -->
<EmptyState
v-else-if="isFailed"
icon-name="close"
@ -228,21 +183,7 @@ onBeforeUnmount(() => {
<template #action>
<div class="task-actions">
<AppButton type="ghost" @click="backToCreate">{{ t('common.action.back') }}</AppButton>
<AppButton type="primary" :loading="retrying" @click="onRetry">{{ t('task.retryAction') }}</AppButton>
</div>
</template>
</EmptyState>
<!-- 已取消态返回创作取消图标用 SVG ban 替原 emoji -->
<EmptyState
v-else-if="isCanceled"
icon-name="ban"
:title="t('task.canceledTitle')"
:description="t('task.canceledDesc')"
>
<template #action>
<div class="task-actions">
<AppButton type="primary" @click="backToCreate">{{ t('task.backToCreate') }}</AppButton>
<AppButton type="primary" :loading="regenerating" @click="onRegenerate">{{ t('task.retryAction') }}</AppButton>
</div>
</template>
</EmptyState>