补齐 plan 002 U6 的 modify 入口(预览页可达,非孤儿):
- views/create/Modify.vue(新建,路由 /create/modify/:versionId,versionId=baseVersionId):三方式分段映射契约——
扩展(extend,intent-only additive 零门槛 NL)/ 改玩法(modify regenerate-module,target.kind=behavior + intent,目标可空默认首个)/ 调参(modify deterministic,kind∈config/asset/level + path + value JSON,JSON 解析失败 Toast 不提交)
提交→任务链→复用 /create/task 进度页→预览看改进结果
- views/create/Preview.vue:底部加「改进作品」按钮 → goModify 跳改进页(versionId 作 baseVersionId,带 gameId)
- router:注册 create-modify 路由
- locales/taskflow.{zh,en}:补 modify.* + preview.refine 文案(中英同构,禁裸中文)
验证:`npm run build`(vue-tsc -b + vite build)绿。全链 create→task→preview→modify CDP 真机走查下一步做(U6 execution note 必做项)。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
336 lines
11 KiB
Vue
336 lines
11 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 改进作品页(路由 /create/modify/:versionId,plan 002 U6 modify 入口)
|
||
* ----------------------------------------------------------------------------
|
||
* 职责:在某个产物版本(baseVersionId=versionId)上发起「改进」,产新预览版后跳进度页。
|
||
* 三种改进方式映射 studio 契约:
|
||
* - 扩展(extend) → POST /app-api/studio/extend(intent-only,additive 加内容,零门槛 NL);
|
||
* - 改玩法(behavior) → POST /app-api/studio/modify mode=regenerate-module(target.kind=behavior + payload.intent);
|
||
* - 调参(tweak) → POST /app-api/studio/modify mode=deterministic(target.kind∈config/asset/level + payload.value)。
|
||
*
|
||
* 契约纪律:
|
||
* - 仅消费 useCreateStore(modify/extend + 轮询封装其中),不直连后端、不发明端点/字段。
|
||
* - mode×target.kind 锁定映射由后端可信边界校验;前端按方式构造合法请求(deterministic 必带 value、regenerate-module 必带 intent)。
|
||
* - deterministic 须知源结构(path/value),属进阶;零门槛主路径=扩展/改玩法(NL)。
|
||
*/
|
||
import { ref, computed } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { showToast } from 'vant'
|
||
import { useCreateStore } from '../../store/create'
|
||
import type { StudioModifyKind } from '../../api/types'
|
||
import { AppButton } from '../../components'
|
||
import Icon from '../../components/Icon.vue'
|
||
import taskflowZh from '../../locales/taskflow.zh'
|
||
import taskflowEn from '../../locales/taskflow.en'
|
||
|
||
/** 路由 props(path 定义 props:true,versionId 即被改的 baseVersionId) */
|
||
const props = defineProps<{ versionId: string }>()
|
||
|
||
const { t } = useI18n({
|
||
useScope: 'local',
|
||
messages: { 'zh-CN': taskflowZh, 'en-US': taskflowEn },
|
||
})
|
||
|
||
const router = useRouter()
|
||
const createStore = useCreateStore()
|
||
|
||
/** 被改的 base 产物版本 ID(契约 modify/extend baseVersionId) */
|
||
const baseVersionId = computed(() => Number(props.versionId))
|
||
|
||
/** 当前改进方式 tab */
|
||
type RefineTab = 'extend' | 'behavior' | 'tweak'
|
||
const tab = ref<RefineTab>('extend')
|
||
|
||
/** 扩展 / 改玩法 的自然语言意图 */
|
||
const intent = ref<string>('')
|
||
/** 改玩法的目标行为(可空,默认首个 behavior 路径) */
|
||
const targetInput = ref<string>('')
|
||
/** 调参:目标类别(config/asset/level) */
|
||
const tweakKind = ref<Exclude<StudioModifyKind, 'behavior'>>('config')
|
||
/** 调参:源项目 JSON 指针路径 */
|
||
const tweakPath = ref<string>('')
|
||
/** 调参:新值(JSON 文本,提交时解析) */
|
||
const tweakValue = ref<string>('')
|
||
|
||
/** 提交中(防重复提交) */
|
||
const submitting = ref<boolean>(false)
|
||
|
||
const PROMPT_MAX = 1000
|
||
|
||
/** 调参的类别选项(排除 behavior,behavior 走「改玩法」走 regenerate-module) */
|
||
const TWEAK_KINDS: { kind: Exclude<StudioModifyKind, 'behavior'>; labelKey: string }[] = [
|
||
{ kind: 'config', labelKey: 'modify.kindConfig' },
|
||
{ kind: 'asset', labelKey: 'modify.kindAsset' },
|
||
{ kind: 'level', labelKey: 'modify.kindLevel' },
|
||
]
|
||
|
||
/** 是否可提交:按 tab 校验必填项 + 不在提交中 */
|
||
const canSubmit = computed(() => {
|
||
if (submitting.value) return false
|
||
if (tab.value === 'tweak') return tweakPath.value.trim().length > 0 && tweakValue.value.trim().length > 0
|
||
return intent.value.trim().length > 0 // extend / behavior 需意图
|
||
})
|
||
|
||
/** 切 tab(清空与该 tab 无关的输入避免误带) */
|
||
function switchTab(next: RefineTab) {
|
||
tab.value = next
|
||
}
|
||
|
||
/** 返回(回上一页,通常是预览页) */
|
||
function goBack() {
|
||
router.back()
|
||
}
|
||
|
||
/**
|
||
* 提交改进:按 tab 构造合法请求 → 拿任务链 → 跳进度页(与 Create 同一进度/预览链路)。
|
||
* 失败路径:校验失败 Toast 不提交;请求异常由拦截器 Toast,解除提交锁。
|
||
*/
|
||
async function onSubmit() {
|
||
if (!canSubmit.value) return
|
||
submitting.value = true
|
||
try {
|
||
let chain
|
||
if (tab.value === 'extend') {
|
||
// 扩展:intent-only additive
|
||
chain = await createStore.extend({ baseVersionId: baseVersionId.value, intent: intent.value.trim() })
|
||
} else if (tab.value === 'behavior') {
|
||
// 改玩法:regenerate-module + target.kind=behavior(path 以 / 开头按路径,否则按 id;空则默认首个 behavior 路径)
|
||
const raw = targetInput.value.trim()
|
||
const target = raw
|
||
? raw.startsWith('/')
|
||
? { kind: 'behavior' as const, path: raw }
|
||
: { kind: 'behavior' as const, id: raw }
|
||
: { kind: 'behavior' as const, path: '/gameDefinition/behaviors/0' }
|
||
chain = await createStore.modify({
|
||
baseVersionId: baseVersionId.value,
|
||
mode: 'regenerate-module',
|
||
target,
|
||
payload: { intent: intent.value.trim() },
|
||
})
|
||
} else {
|
||
// 调参:deterministic + value(JSON 解析失败则 Toast 不提交)
|
||
let value: Record<string, unknown>
|
||
try {
|
||
value = JSON.parse(tweakValue.value)
|
||
} catch {
|
||
showToast(t('modify.valueInvalid'))
|
||
submitting.value = false
|
||
return
|
||
}
|
||
chain = await createStore.modify({
|
||
baseVersionId: baseVersionId.value,
|
||
mode: 'deterministic',
|
||
target: { kind: tweakKind.value, path: tweakPath.value.trim() },
|
||
payload: { value },
|
||
})
|
||
}
|
||
// 跳进度页(携带任务链返回的 gameId);终态完成后进预览看改进结果
|
||
router.push({
|
||
name: 'create-task',
|
||
params: { taskId: String(chain.id) },
|
||
query: chain.gameId != null ? { gameId: String(chain.gameId) } : {},
|
||
})
|
||
} catch {
|
||
// 请求异常已由拦截器 Toast;解除锁,停留本页
|
||
submitting.value = false
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="modify-page">
|
||
<!-- 顶部状态条(沉浸页无 TabBar) -->
|
||
<header class="modify-head">
|
||
<button class="modify-back" type="button" @click="goBack" :aria-label="t('common.action.back')">
|
||
<Icon name="back" :size="22" />
|
||
</button>
|
||
<span class="modify-title">{{ t('modify.title') }}</span>
|
||
<span class="modify-base">{{ t('modify.baseLabel') }} #{{ baseVersionId }}</span>
|
||
</header>
|
||
|
||
<div class="modify-body">
|
||
<!-- 改进方式分段(扩展 / 改玩法 / 调参) -->
|
||
<div class="seg">
|
||
<button class="seg-item" :class="{ 'is-on': tab === 'extend' }" type="button" @click="switchTab('extend')">{{ t('modify.tabExtend') }}</button>
|
||
<button class="seg-item" :class="{ 'is-on': tab === 'behavior' }" type="button" @click="switchTab('behavior')">{{ t('modify.tabBehavior') }}</button>
|
||
<button class="seg-item" :class="{ 'is-on': tab === 'tweak' }" type="button" @click="switchTab('tweak')">{{ t('modify.tabTweak') }}</button>
|
||
</div>
|
||
|
||
<!-- 扩展:intent-only -->
|
||
<section v-if="tab === 'extend'" class="field">
|
||
<p class="field-hint">{{ t('modify.extendHint') }}</p>
|
||
<textarea v-model="intent" class="ta" :maxlength="PROMPT_MAX" rows="4" :placeholder="t('modify.extendPlaceholder')" />
|
||
</section>
|
||
|
||
<!-- 改玩法:intent + 目标行为(可空默认首个) -->
|
||
<section v-else-if="tab === 'behavior'" class="field">
|
||
<p class="field-hint">{{ t('modify.behaviorHint') }}</p>
|
||
<textarea v-model="intent" class="ta" :maxlength="PROMPT_MAX" rows="4" :placeholder="t('modify.behaviorPlaceholder')" />
|
||
<label class="field-label">{{ t('modify.targetLabel') }}</label>
|
||
<input v-model="targetInput" class="inp" type="text" :placeholder="t('modify.targetPlaceholder')" />
|
||
</section>
|
||
|
||
<!-- 调参:kind + path + value(JSON) -->
|
||
<section v-else class="field">
|
||
<label class="field-label">{{ t('modify.tweakKindLabel') }}</label>
|
||
<div class="seg seg-sm">
|
||
<button
|
||
v-for="k in TWEAK_KINDS"
|
||
:key="k.kind"
|
||
class="seg-item"
|
||
:class="{ 'is-on': tweakKind === k.kind }"
|
||
type="button"
|
||
@click="tweakKind = k.kind"
|
||
>{{ t(k.labelKey) }}</button>
|
||
</div>
|
||
<label class="field-label">{{ t('modify.tweakPathLabel') }}</label>
|
||
<input v-model="tweakPath" class="inp" type="text" :placeholder="t('modify.tweakPathPlaceholder')" />
|
||
<label class="field-label">{{ t('modify.tweakValueLabel') }}</label>
|
||
<textarea v-model="tweakValue" class="ta ta-mono" rows="3" :placeholder="t('modify.tweakValuePlaceholder')" />
|
||
</section>
|
||
</div>
|
||
|
||
<!-- 底部提交 -->
|
||
<footer class="modify-foot">
|
||
<AppButton block size="large" :loading="submitting" :disabled="!canSubmit" @click="onSubmit">
|
||
{{ submitting ? t('modify.submitting') : t('modify.submit') }}
|
||
</AppButton>
|
||
</footer>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
/* D4 深色科技风(token 来自全局 src/styles/tokens.css),结构参照 Create.vue / Preview.vue */
|
||
.modify-page {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
}
|
||
|
||
/* —— 顶部状态条 —— */
|
||
.modify-head {
|
||
flex: 0 0 auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
height: 48px;
|
||
padding: 0 12px;
|
||
background: var(--panel);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.modify-back {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--ink);
|
||
}
|
||
.modify-title {
|
||
flex: 1;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
}
|
||
.modify-base {
|
||
font-size: 12px;
|
||
padding: 3px 10px;
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
}
|
||
|
||
/* —— 主体 —— */
|
||
.modify-body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
padding: 16px;
|
||
}
|
||
|
||
/* —— 分段控件 —— */
|
||
.seg {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 4px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
}
|
||
.seg-sm {
|
||
margin-top: 8px;
|
||
}
|
||
.seg-item {
|
||
flex: 1;
|
||
padding: 8px 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
border-radius: var(--radius-sm);
|
||
transition: background 0.15s ease, color 0.15s ease;
|
||
}
|
||
.seg-item.is-on {
|
||
color: var(--on-accent);
|
||
background: var(--accent);
|
||
}
|
||
|
||
/* —— 字段区 —— */
|
||
.field {
|
||
margin-top: 16px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.field-hint {
|
||
margin: 0 0 8px;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
line-height: 1.5;
|
||
}
|
||
.field-label {
|
||
margin: 14px 0 6px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--ink);
|
||
}
|
||
.ta,
|
||
.inp {
|
||
width: 100%;
|
||
border: 1px solid var(--border);
|
||
outline: 0;
|
||
background: var(--panel);
|
||
color: var(--ink);
|
||
font-size: 15px;
|
||
line-height: 1.6;
|
||
font-family: inherit;
|
||
border-radius: var(--radius-md);
|
||
padding: 12px;
|
||
}
|
||
.ta {
|
||
resize: none;
|
||
}
|
||
.ta-mono {
|
||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||
font-size: 13px;
|
||
}
|
||
.ta::placeholder,
|
||
.inp::placeholder {
|
||
color: var(--muted);
|
||
opacity: 0.7;
|
||
}
|
||
|
||
/* —— 底部提交 —— */
|
||
.modify-foot {
|
||
flex: 0 0 auto;
|
||
padding: 12px 16px calc(12px + var(--safe-bottom));
|
||
background: var(--panel);
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
</style>
|