feat(aihr): align figma pages for release

This commit is contained in:
2026-07-14 02:04:36 +08:00
parent 922fe7ff29
commit 87360a25d3
51 changed files with 3575 additions and 629 deletions
+24 -1
View File
@@ -344,6 +344,18 @@ type ScenarioSeed = {
id: string;
name: string;
position?: string;
scenarioType?: string;
projectType?: string;
difficulty?: number;
customerPersona?: string;
openingText?: string;
explicitNeed?: string;
hiddenNeed?: string;
irritants?: string;
delights?: string;
sopRefs?: string;
successCriteria?: string;
enabled?: boolean;
trainee: string;
customer: string;
project: string;
@@ -489,7 +501,6 @@ const defaultPromptForm = (): PromptTemplateForm => ({
description: ''
});
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
const selectedScenario = computed(
() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]
);
@@ -563,6 +574,18 @@ const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
id: item.id,
name: item.name,
position: item.position,
scenarioType: item.scenarioType,
projectType: item.projectType,
difficulty: item.difficulty,
customerPersona: item.customerPersona,
openingText: item.openingText,
explicitNeed: item.explicitNeed,
hiddenNeed: item.hiddenNeed,
irritants: item.irritants,
delights: item.delights,
sopRefs: item.sopRefs,
successCriteria: item.successCriteria,
enabled: item.enabled,
trainee: '张三',
customer: item.customerPersona,
project: item.projectType || item.scenarioType,
+126 -5
View File
@@ -6,10 +6,37 @@
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
</div>
<div class="head-actions">
<el-button type="danger" :loading="exporting" @click="exportPilot">导出试点 CSV</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
</div>
</section>
<section class="filter-panel panel">
<el-date-picker v-model="startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<el-date-picker v-model="endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<el-select v-model="selectedPosition" placeholder="岗位">
<el-option label="生活顾问" value="生活顾问" />
</el-select>
<el-select v-model="selectedProjectCode" clearable filterable placeholder="组织范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
</section>
<section class="metric-grid">
<div class="metric-card">
<span>校准样本</span><strong>{{ metrics.calibrationCount }} / 20</strong>
</div>
<div class="metric-card">
<span>分档一致率</span><strong>{{ metrics.consistency }}</strong>
</div>
<div class="metric-card">
<span>SOP 可用率</span><strong>{{ metrics.sopUsable }}</strong>
</div>
<div class="metric-card">
<span>严格门禁</span><strong :class="metrics.ready ? 'is-ready' : 'is-pending'">{{ metrics.ready ? '通过' : '待补样本' }}</strong>
</div>
</section>
<section class="review-layout">
<article class="panel list-panel">
<div class="panel-title">
@@ -17,9 +44,6 @@
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
</div>
<div class="list-filters">
<el-select v-model="selectedProjectCode" clearable filterable placeholder="项目范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
<el-option
v-for="person in orgPeople"
@@ -122,7 +146,7 @@
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { calibratePracticeSession, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import {
getPracticeAudioBlobUrl,
getOrgSnapshot,
@@ -142,6 +166,11 @@ const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref('');
const selectedExtPartyId = ref('');
const selectedPosition = ref('生活顾问');
const exporting = ref(false);
const startDate = ref('2026-07-01');
const endDate = ref('2026-07-31');
const metrics = reactive({ calibrationCount: 0, consistency: '--', sopUsable: '--', ready: false });
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
@@ -228,6 +257,41 @@ const loadReviews = async () => {
}
};
const loadMetrics = async () => {
try {
const blob = await exportPracticePilotCsv({ startDate: startDate.value, endDate: endDate.value });
const csv = await blob.text();
const value = (key: string) => csv.match(new RegExp(`(?:^|\\n)"?${key}"?,"?([^"\\n]*)`))?.[1] || '';
metrics.calibrationCount = Number(value('calibration_sample_count')) || 0;
metrics.consistency = value('ai_human_band_consistency_rate') || '--';
metrics.sopUsable = value('sop_answer_usable_rate') || '--';
metrics.ready = value('pilot_metric_ready') === 'true';
} catch {
metrics.calibrationCount = reviews.value.length;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
}
};
const exportPilot = async () => {
exporting.value = true;
try {
const blob = await exportPracticePilotCsv({ startDate: startDate.value, endDate: endDate.value });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `aihr-pilot-${startDate.value}-${endDate.value}.csv`;
link.click();
URL.revokeObjectURL(url);
ElMessage.success('试点 CSV 已导出');
} catch {
ElMessage.error('试点 CSV 导出失败');
} finally {
exporting.value = false;
}
};
const selectReview = async (row: ReviewRecord) => {
releaseAudioSources();
const result = await getPracticeReview(row.id);
@@ -290,10 +354,15 @@ watch(selectedExtPartyId, () => {
loadReviews();
});
watch([startDate, endDate], () => {
loadMetrics();
});
onMounted(() => {
loadScenarios();
loadOrgSnapshot();
loadReviews();
loadMetrics();
});
onBeforeUnmount(releaseAudioSources);
@@ -332,6 +401,51 @@ onBeforeUnmount(releaseAudioSources);
}
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.filter-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
padding: 16px;
span {
display: block;
color: #667085;
font-size: 13px;
}
strong {
display: block;
margin-top: 8px;
color: #16864b;
font-size: 24px;
}
.is-pending {
color: #d97706;
font-size: 18px;
}
}
.review-layout {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
@@ -445,8 +559,15 @@ onBeforeUnmount(releaseAudioSources);
.review-layout,
.list-filters,
.score-grid,
.form-grid {
.form-grid,
.filter-panel,
.metric-grid {
grid-template-columns: 1fr;
}
.head-actions {
align-items: stretch;
flex-direction: column;
}
}
</style>
+315
View File
@@ -0,0 +1,315 @@
<template>
<div class="rubric-page">
<div class="page-heading">
<div>
<h1>Rubric 评分规则</h1>
<p>维护评分维度、权重、版本和生效状态</p>
</div>
<el-button type="danger" :icon="Plus" @click="createVersion">新建版本</el-button>
</div>
<div class="rubric-layout">
<section class="version-card">
<h2>版本列表</h2>
<button
v-for="item in rubrics"
:key="item.code"
class="version-item"
:class="{ active: item.code === selectedCode }"
@click="selectRubric(item)"
>
<strong>{{ item.name }} {{ item.version }}</strong>
<span>{{ item.enabled ? '当前生效' : '历史版本' }} · {{ item.dimensions.length }} 维度</span>
</button>
<el-empty v-if="!loading && rubrics.length === 0" description="暂无评分规则" />
</section>
<section v-if="form" class="editor-card">
<div class="editor-heading">
<div>
<h2>{{ form.name }} {{ form.version }}</h2>
<p>{{ selectedRubric?.scenarioName || form.scenarioCode }} · {{ form.description || '维护评分维度与权重' }}</p>
</div>
<el-tag :type="form.enabled ? 'success' : 'info'">{{ form.enabled ? '已启用' : '已停用' }}</el-tag>
</div>
<h3>评分维度与权重</h3>
<div v-for="dimension in form.dimensions" :key="dimension.code" class="dimension-row">
<div class="dimension-label">
<strong>{{ dimension.name }}</strong>
<span>{{ dimension.description || '请补充该维度的评分说明' }}</span>
</div>
<div class="dimension-control">
<span>{{ Math.round(dimension.weight * 100) }}% 权重</span>
<el-slider v-model="dimension.weight" :min="0" :max="1" :step="0.05" @change="normalizeWeights" />
</div>
</div>
<div class="band-card">
<strong>评分档位</strong>
<span>90–100 优秀&nbsp; · &nbsp;75–89 良好&nbsp; · &nbsp;60–74 待提升&nbsp; · &nbsp;&lt;60 不合格</span>
</div>
<div class="editor-actions">
<el-button @click="copyVersion">复制版本</el-button>
<el-button @click="selectedRubric && toggleEnabled(selectedRubric)">{{ form.enabled ? '停用版本' : '启用版本' }}</el-button>
<el-button type="danger" :loading="saving" @click="saveVersion">保存并发布</el-button>
</div>
</section>
<el-empty v-else-if="!loading" description="请选择一个评分规则" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Plus } from '@element-plus/icons-vue';
import {
listPracticeRubrics,
savePracticeRubric,
updatePracticeRubricEnabled,
type PracticeRubricAdmin,
type PracticeRubricSaveRequest
} from '@/api/aihr/practice';
const loading = ref(false);
const saving = ref(false);
const rubrics = ref<PracticeRubricAdmin[]>([]);
const selectedCode = ref('');
const form = ref<PracticeRubricSaveRequest>();
const selectedRubric = computed(() => rubrics.value.find((item) => item.code === selectedCode.value));
const cloneForm = (item: PracticeRubricAdmin): PracticeRubricSaveRequest => ({
code: item.code,
scenarioCode: item.scenarioCode,
name: item.name,
version: item.version,
enabled: item.enabled,
description: item.description,
dimensions: item.dimensions.map((dimension) => ({ ...dimension }))
});
const loadRubrics = async () => {
loading.value = true;
try {
const result = await listPracticeRubrics();
rubrics.value = result.data ?? [];
if (!selectedCode.value && rubrics.value.length > 0) {
selectRubric(rubrics.value[0]);
} else if (selectedCode.value) {
const current = rubrics.value.find((item) => item.code === selectedCode.value);
if (current) selectRubric(current);
}
} catch {
rubrics.value = [];
} finally {
loading.value = false;
}
};
const selectRubric = (item: PracticeRubricAdmin) => {
selectedCode.value = item.code;
form.value = reactive(cloneForm(item));
};
const normalizeWeights = () => {
if (!form.value) return;
const total = form.value.dimensions.reduce((sum, item) => sum + Number(item.weight || 0), 0);
if (total > 0 && Math.abs(total - 1) > 0.001) {
form.value.dimensions.forEach((item) => {
item.weight = Number((item.weight / total).toFixed(2));
});
}
};
const createVersion = () => {
const source = rubrics.value[0];
if (!source) return;
const suffix = Date.now().toString().slice(-4);
form.value = reactive({
...cloneForm(source),
code: `${source.code}-${suffix}`,
version: `v${Number.parseInt(source.version.replace(/\D/g, ''), 10) + 1}`,
enabled: false
});
selectedCode.value = '';
};
const copyVersion = () => createVersion();
const saveVersion = async () => {
if (!form.value) return;
normalizeWeights();
const total = form.value.dimensions.reduce((sum, item) => sum + Number(item.weight || 0), 0);
if (Math.abs(total - 1) > 0.011) {
ElMessage.warning('各维度权重合计必须为 100%');
return;
}
saving.value = true;
try {
const result = await savePracticeRubric(form.value);
ElMessage.success('评分规则已保存并发布');
await loadRubrics();
if (result.data) selectRubric(result.data);
} finally {
saving.value = false;
}
};
const toggleEnabled = async (item: PracticeRubricAdmin) => {
await ElMessageBox.confirm(`确定${item.enabled ? '停用' : '启用'}“${item.name} ${item.version}”吗?`, '状态确认');
await updatePracticeRubricEnabled(item.code, !item.enabled);
item.enabled = !item.enabled;
if (item.code === selectedCode.value && form.value) form.value.enabled = item.enabled;
ElMessage.success(item.enabled ? '评分规则已启用' : '评分规则已停用');
};
onMounted(loadRubrics);
</script>
<style scoped>
.rubric-page {
min-height: calc(100vh - 84px);
padding: 28px 32px 40px;
background: #f4f6fa;
color: #1d2635;
}
.page-heading,
.editor-heading,
.editor-actions {
display: flex;
align-items: center;
justify-content: space-between;
}
.page-heading {
margin-bottom: 22px;
}
h1,
h2,
h3,
p {
margin: 0;
}
h1 {
font-size: 28px;
font-weight: 600;
}
h2 {
font-size: 20px;
font-weight: 600;
}
h3 {
margin: 24px 0 18px;
font-size: 18px;
}
.page-heading p,
.editor-heading p {
margin-top: 8px;
color: #858e9e;
font-size: 13px;
}
.rubric-layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.version-card,
.editor-card {
min-height: 520px;
padding: 18px 16px;
border-radius: 8px;
background: #fff;
box-shadow: 0 8px 24px rgb(33 43 54 / 4%);
}
.version-card h2 {
margin-bottom: 16px;
}
.version-item {
display: block;
width: 100%;
margin-bottom: 9px;
padding: 16px 14px;
border: 0;
border-radius: 8px;
background: #fafbfc;
color: #253044;
text-align: left;
cursor: pointer;
}
.version-item.active {
background: #fff0f1;
color: #e60012;
}
.version-item strong,
.version-item span {
display: block;
}
.version-item span {
margin-top: 7px;
color: #858e9e;
font-size: 12px;
}
.editor-card {
padding: 20px;
}
.editor-heading {
align-items: flex-start;
}
.dimension-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: center;
margin: 18px 0;
}
.dimension-label strong,
.dimension-label span,
.dimension-control > span {
display: block;
}
.dimension-label span {
margin-top: 7px;
color: #858e9e;
font-size: 12px;
}
.dimension-control > span {
margin-bottom: 8px;
color: #e60012;
font-size: 12px;
text-align: right;
}
.band-card {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 24px;
padding: 16px;
border-radius: 8px;
background: #f7f9fb;
color: #758196;
font-size: 13px;
}
.band-card strong {
color: #253044;
font-size: 15px;
}
.editor-actions {
margin-top: 16px;
gap: 10px;
}
.editor-actions .el-button:last-child {
flex: 1;
}
@media (max-width: 900px) {
.rubric-layout {
grid-template-columns: 1fr;
}
.dimension-row {
grid-template-columns: 1fr;
gap: 8px;
}
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<template>
<div class="scenario-page">
<section class="page-head">
<div>
<h1>场景库管理</h1>
<p>生活顾问岗位 · 场景保存、Rubric 版本与启停维护</p>
</div>
<el-button type="danger" @click="openCreate">新增场景</el-button>
</section>
<section class="filter-card">
<el-select v-model="filters.position" placeholder="岗位" clearable>
<el-option label="生活顾问" value="生活顾问" />
</el-select>
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.enabled" placeholder="状态" clearable>
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
</el-select>
<el-input v-model="filters.keyword" placeholder="场景名称 / 编码" clearable @keyup.enter="queryScenes" />
<el-button type="danger" @click="queryScenes">查询</el-button>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="name" label="场景名称" min-width="180" />
<el-table-column prop="scenarioType" label="分类" min-width="120" />
<el-table-column prop="difficulty" label="难度" width="92">
<template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>
</el-table-column>
<el-table-column label="Rubric" min-width="180">
<template #default="{ row }"> {{ row.rubric?.name || '未配置' }} {{ row.rubric?.version || '' }} </template>
</el-table-column>
<el-table-column label="状态" width="88">
<template #default="{ row }">
<span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']">
{{ row.enabled === false ? '停用' : '启用' }}
</span>
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="150" />
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="toggleEnabled(row)">
{{ row.enabled === false ? '启用' : '停用' }}
</el-button>
</template>
</el-table-column>
</el-table>
<div class="table-footer">
<span>共 {{ filteredScenarios.length }} 个生活顾问场景 · 当前启用 {{ enabledCount }} 个</span>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
layout="prev, pager, next"
:total="filteredScenarios.length"
background
/>
</div>
</section>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="720px" destroy-on-close>
<el-form label-position="top" class="scenario-form">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="场景名称" required>
<el-input v-model="form.name" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="场景编码">
<el-input v-model="form.id" :disabled="Boolean(editingId)" placeholder="自动生成或填写唯一编码" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="岗位"><el-input v-model="form.position" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
</el-col>
</el-row>
<el-form-item label="项目类型"><el-input v-model="form.projectType" /></el-form-item>
<el-form-item label="业主人设"><el-input v-model="form.customerPersona" /></el-form-item>
<el-form-item label="开场话术" required><el-input v-model="form.openingText" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item>
<el-switch v-model="form.enabled" active-text="启用场景" inactive-text="停用场景" />
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="danger" :loading="saving" @click="submit">保存场景</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
listPracticeScenarios,
savePracticeScenario,
updatePracticeScenarioEnabled,
type PracticeScenarioResponse,
type PracticeScenarioSaveRequest
} from '@/api/aihr/practice';
const loading = ref(false);
const saving = ref(false);
const dialogVisible = ref(false);
const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]);
const scenarioTypes = ref<string[]>([]);
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
position: '生活顾问',
scenarioType: '',
enabled: undefined,
keyword: ''
});
const pagination = reactive({ page: 1, size: 10 });
const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
id: '',
name: '',
position: '生活顾问',
scenarioType: '投诉处理',
projectType: '住宅',
difficulty: 1,
customerPersona: '',
openingText: '',
explicitNeed: '',
hiddenNeed: '',
irritants: '',
delights: '',
sopRefs: '生活顾问SOP',
successCriteria: '',
strategy: '仅在漏关键步骤时提示',
goal: '',
enabled: true
});
const form = reactive(emptyForm());
const dialogTitle = computed(() => (editingId.value ? '编辑场景' : '新增场景'));
const filteredScenarios = computed(() =>
scenarios.value.filter((item) => {
const keyword = filters.keyword.trim().toLowerCase();
return (
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
(filters.enabled === undefined || item.enabled === filters.enabled) &&
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
);
})
);
const pagedScenarios = computed(() => {
const start = (pagination.page - 1) * pagination.size;
return filteredScenarios.value.slice(start, start + pagination.size);
});
const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length);
const load = async () => {
loading.value = true;
try {
const { data } = await listPracticeScenarios();
scenarios.value = data || [];
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
} finally {
loading.value = false;
}
};
const queryScenes = () => {
pagination.page = 1;
};
const difficultyLabel = (value: number) => ({ 1: '初级', 2: '中级', 3: '高级', 4: '困难', 5: '挑战' })[value] || '中级';
const fillForm = (item?: PracticeScenarioResponse) => {
Object.assign(
form,
emptyForm(),
item
? {
id: item.id,
name: item.name,
position: item.position,
scenarioType: item.scenarioType,
projectType: item.projectType,
difficulty: item.difficulty,
customerPersona: item.customerPersona,
openingText: item.openingText,
explicitNeed: item.explicitNeed,
hiddenNeed: item.hiddenNeed,
irritants: item.irritants,
delights: item.delights,
sopRefs: item.sopRefs,
successCriteria: item.successCriteria,
strategy: item.strategy,
goal: item.goal,
enabled: item.enabled
}
: {}
);
};
const openCreate = () => {
editingId.value = '';
fillForm();
dialogVisible.value = true;
};
const openEdit = (item: PracticeScenarioResponse) => {
editingId.value = item.id;
fillForm(item);
dialogVisible.value = true;
};
const toggleEnabled = async (item: PracticeScenarioResponse) => {
const enabled = item.enabled === false;
await updatePracticeScenarioEnabled(item.id, { enabled });
item.enabled = enabled;
ElMessage.success(enabled ? '场景已启用' : '场景已停用');
};
const submit = async () => {
if (!form.name.trim() || !form.openingText.trim() || !form.goal.trim()) {
ElMessage.warning('请填写场景名称、开场话术和训练目标');
return;
}
saving.value = true;
try {
await savePracticeScenario({ ...form, id: form.id.trim() || undefined });
dialogVisible.value = false;
await load();
ElMessage.success('场景已保存');
} finally {
saving.value = false;
}
};
onMounted(load);
</script>
<style scoped lang="scss">
.scenario-page {
min-height: calc(100vh - 84px);
padding: 24px 28px 40px;
background: #f4f6fa;
color: #111;
}
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
h1 {
margin: 0;
font-size: 24px;
line-height: 1.3;
}
p {
margin: 6px 0 0;
color: #858991;
font-size: 13px;
}
}
.filter-card,
.table-card {
padding: 18px;
border-radius: 8px;
background: #fff;
}
.filter-card {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1.5fr 78px;
gap: 12px;
margin-bottom: 12px;
}
.table-card {
:deep(.el-table) {
color: #111;
}
:deep(.el-table th.el-table__cell) {
color: #858991;
font-weight: 500;
background: #fafbfc;
}
}
.status-text {
font-size: 13px;
&.enabled {
color: #2a9b5f;
}
&.disabled {
color: #858991;
}
}
.table-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 18px;
color: #858991;
font-size: 12px;
}
.scenario-form {
padding: 4px 8px;
:deep(.el-form-item) {
margin-bottom: 14px;
}
:deep(.el-textarea__inner) {
line-height: 1.6;
}
}
.full-control {
width: 100%;
}
@media (max-width: 1100px) {
.filter-card {
grid-template-columns: 1fr 1fr;
}
.filter-card .el-button {
width: 100%;
}
}
</style>