feat(admin): WallScenarioCard 双状态场景卡组件
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||||
Reference in New Issue
Block a user