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