feat(admin): WallScenarioCard 双状态场景卡组件

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-14 19:08:59 +08:00
parent 36fe534029
commit ab0cf9f64e
@@ -0,0 +1,224 @@
<!--
双状态场景卡(spec §5.2):同一场景 × 未留资/已留资两池位(WALL_SCENARIOS)。
语言 tabs = poolLanguages(两池并集)(必留语言不可删;附加语言可删/可加);
每语言 tab 下两池位各一行编辑器(一行一句,增删行)。
直接改传入的 reactive WallEditModel.pools(与 WallPane 现状同模式)。
-->
<template>
<div class="scenario-card">
<div class="sc-head">
<div>
<p class="sc-title">{{ def.title }}</p>
<p class="sc-note">{{ def.note }}</p>
</div>
<ElTag v-if="requiredMissing.length" type="danger" size="small">
缺必留语言:{{ requiredMissing.join(' / ') }}
</ElTag>
</div>
<ElTabs v-if="langs.length" v-model="activeLang" class="sc-tabs">
<ElTabPane v-for="lang in langs" :key="lang" :name="lang">
<template #label>
<span class="sc-lang-label">
{{ lang }}
<span v-if="isExtraLang(lang)" class="sc-lang-del" title="删除此语言"
@click.prevent="removeLang(lang)">×</span>
</span>
</template>
<div v-for="pool in def.pools" :key="pool.field" class="sc-pool">
<p class="sc-pool-label">{{ SCENARIO_WHEN_LABELS[pool.when] }}</p>
<div v-for="(row, i) in poolRows(pool.field, lang)" :key="i" class="sc-row">
<ElInput
:model-value="row"
placeholder="话术句子(一行一句)"
class="sc-input"
@update:model-value="setPoolRow(pool.field, lang, i, $event)"
/>
<ElButton
link type="danger" size="small"
@click="removePoolRow(pool.field, lang, i)"
>删除</ElButton>
</div>
<ElButton size="small" text bg class="sc-add"
@click="addPoolRow(pool.field, lang)">+ 添加一句</ElButton>
</div>
</ElTabPane>
</ElTabs>
<p v-else class="sc-empty">还没有话术——添加第一个语言开始</p>
<div class="sc-new-lang">
<ElInput v-model="newLang" placeholder="语言代码如 pt / ru / ar" class="sc-new-lang-input" />
<ElButton size="small" @click="addLang">+ 添加语言</ElButton>
</div>
</div>
</template>
<script setup lang="ts">
import {
POOL_REQUIRED_LANGS,
SCENARIO_WHEN_LABELS,
poolLanguages,
type ScenarioDef,
type WallEditModel
} from '../model'
defineOptions({ name: 'WallScenarioCard' })
const props = defineProps<{
def: ScenarioDef
model: WallEditModel
}>()
const activeLang = ref('')
const newLang = ref('')
const langs = computed(() =>
poolLanguages(Object.fromEntries(props.def.pools.map((p) => [p.field, props.model.pools[p.field]])))
)
watch(
langs,
(list) => {
if (list.length && !list.includes(activeLang.value)) activeLang.value = list[0]
},
{ immediate: true }
)
const requiredMissing = computed(() =>
POOL_REQUIRED_LANGS.filter(
(l) => !langs.value.includes(l) || props.def.pools.some((p) => !(props.model.pools[p.field][l]?.length))
)
)
function isExtraLang(lang: string): boolean {
return !(POOL_REQUIRED_LANGS as readonly string[]).includes(lang)
}
function poolRows(field: string, lang: string): string[] {
return props.model.pools[field as keyof WallEditModel['pools']][lang] ?? []
}
function ensurePool(field: string, lang: string): string[] {
const pool = props.model.pools[field as keyof WallEditModel['pools']]
if (!pool[lang]) pool[lang] = []
return pool[lang]
}
function setPoolRow(field: string, lang: string, i: number, value: string): void {
ensurePool(field, lang)[i] = value
}
function addPoolRow(field: string, lang: string): void {
ensurePool(field, lang).push('')
}
function removePoolRow(field: string, lang: string, i: number): void {
ensurePool(field, lang).splice(i, 1)
}
function addLang(): void {
const code = newLang.value.trim().toLowerCase()
if (!/^[a-z]{2,3}$/.test(code)) {
ElMessage.warning('语言代码为 2-3 位字母(如 pt / ru / ar)')
return
}
for (const pool of props.def.pools) ensurePool(pool.field, code)
activeLang.value = code
newLang.value = ''
}
function removeLang(lang: string): void {
for (const pool of props.def.pools) delete props.model.pools[pool.field as keyof WallEditModel['pools']][lang]
}
</script>
<style scoped lang="scss">
.scenario-card {
padding: 14px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.sc-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
.sc-title {
margin: 0;
font-weight: 600;
font-size: 14px;
color: var(--el-text-color-primary);
}
.sc-note {
margin: 2px 0 0;
font-size: 12px;
color: var(--art-gray-500);
}
}
.sc-tabs {
margin-top: 6px;
:deep(.el-tabs__header) {
margin-bottom: 8px;
}
}
.sc-lang-label {
.sc-lang-del {
margin-left: 4px;
font-size: 12px;
color: var(--el-color-danger);
cursor: pointer;
&:hover {
opacity: 0.8;
}
}
}
.sc-pool {
margin-bottom: 10px;
.sc-pool-label {
margin: 0 0 6px;
padding-left: 8px;
font-size: 12px;
color: var(--art-gray-500);
border-left: 3px solid var(--el-color-primary-light-5);
}
}
.sc-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
.sc-input {
flex: 1;
min-width: 0;
}
}
.sc-empty {
margin: 10px 0;
font-size: 12px;
color: var(--art-gray-400);
}
.sc-new-lang {
display: flex;
align-items: center;
gap: 8px;
padding-top: 8px;
border-top: 1px dashed var(--el-border-color-lighter);
.sc-new-lang-input {
width: 180px;
}
}
}
</style>