feat(practice): streamline pilot content operations

This commit is contained in:
2026-07-25 12:51:39 +08:00
parent 35b37b07f7
commit fa9d6da975
9 changed files with 380 additions and 24 deletions
+2
View File
@@ -87,6 +87,8 @@ export type PracticeScenarioSaveRequest = {
goal: string;
enabled: boolean;
rounds: PracticeScenarioRound[];
expectedContentVersion?: string;
expectedContentHash?: string;
};
export type PracticeScenarioReviewStatusRequest = {
+310 -9
View File
@@ -2,13 +2,85 @@
<div class="scenario-page">
<section class="page-head">
<div>
<h1>场景库管理</h1>
<p>住宅物业五岗位成长目录 · 场景回合、能力项、审核、版本与 Rubric 统一维护</p>
<h1>试点场景运营</h1>
<p>默认每岗位、每层级选 1 个核心场景;目标 15 个,现有内容不足时按实际可用数量展示。</p>
</div>
<div class="page-actions">
<el-button @click="showAdvanced = !showAdvanced">{{ showAdvanced ? '收起完整场景库' : '展开完整场景库' }}</el-button>
<el-button v-if="showAdvanced" type="danger" @click="openCreate">新增场景</el-button>
</div>
<el-button type="danger" @click="openCreate">新增场景</el-button>
</section>
<section class="filter-card">
<section class="pilot-card">
<div class="pilot-head">
<div>
<h2>{{ pilotRows.length }} 个试点场景</h2>
<p>自动初审只建议能力项与风险级别,不代替业务审核,也不会自动发布。</p>
</div>
<div class="pilot-actions">
<el-button :disabled="!pilotSuggestionCount" :loading="batchSaving" @click="applyPilotSuggestions">
批量确认初审建议({{ pilotSuggestionCount }})
</el-button>
<el-button
v-if="pilotReadyDrafts.length"
:loading="batchReviewing"
@click="advancePilotRows(pilotReadyDrafts, '待业务审核')"
>
批量提交审核({{ pilotReadyDrafts.length }})
</el-button>
<el-button
v-if="pilotReadyReviews.length"
type="danger"
:loading="batchReviewing"
@click="advancePilotRows(pilotReadyReviews, '已发布')"
>
批量审核并发布({{ pilotReadyReviews.length }})
</el-button>
</div>
</div>
<el-alert
:closable="false"
type="info"
show-icon
:title="`已选 ${pilotRows.length} 个场景;${pilotReadyCount} 个已通过发布预检,${pilotSuggestionCount} 个可批量补齐归类或风险。高风险仍执行双人复核。`"
/>
<el-table v-loading="loading" :data="pilotRows" row-key="id" size="small">
<el-table-column prop="position" label="岗位" width="96" />
<el-table-column prop="growthLevel" label="层级" width="72" />
<el-table-column prop="name" label="场景" min-width="170" />
<el-table-column label="能力项" min-width="190">
<template #default="{ row }">
<span>{{ capabilityName(row) }}</span>
<span v-if="row.suggestedCompetency && row.competencyCode !== row.suggestedCompetency.code" class="suggestion">
→ {{ row.suggestedCompetency.name }}
</span>
</template>
</el-table-column>
<el-table-column label="风险" width="128">
<template #default="{ row }">
<span>{{ row.riskLevel }}</span>
<span v-if="row.riskLevel !== row.suggestedRiskLevel" class="suggestion">→ {{ row.suggestedRiskLevel }}</span>
</template>
</el-table-column>
<el-table-column label="预检" min-width="180">
<template #default="{ row }">
<el-tag :type="row.issues.length ? 'warning' : 'success'" size="small">
{{ row.issues.length ? `待补:${row.issues.join('、')}` : '可审核' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button link type="danger" @click="openEdit(row)">查看</el-button>
<el-button v-if="row.suggestionNeeded" link type="danger" :disabled="batchSaving" @click="applyPilotSuggestion(row)">
确认建议
</el-button>
</template>
</el-table-column>
</el-table>
</section>
<section v-if="showAdvanced" class="filter-card">
<el-select v-model="filters.position" placeholder="岗位" clearable>
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
@@ -29,7 +101,7 @@
<el-button type="danger" @click="queryScenes">查询</el-button>
</section>
<section class="matrix-card">
<section v-if="showAdvanced" class="matrix-card">
<div class="matrix-head">
<div>
<h2>矩阵缺口</h2>
@@ -52,7 +124,7 @@
</el-table>
</section>
<section class="table-card">
<section v-if="showAdvanced" class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="name" label="场景名称" min-width="180" />
@@ -178,7 +250,7 @@
:closable="false"
type="info"
show-icon
title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;收费、安全、投诉等受控内容会自动升级为高风险,需两位不同审核人。"
title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;人身、消防、燃气、带电、高空、舆情或退款减免等实质风险会自动升级为高风险,需两位不同审核人。"
/>
<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>
@@ -216,7 +288,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
listPracticeScenarios,
listPracticeCurriculumCapabilities,
@@ -233,6 +305,9 @@ import {
const loading = ref(false);
const saving = ref(false);
const batchSaving = ref(false);
const batchReviewing = ref(false);
const showAdvanced = ref(false);
const dialogVisible = ref(false);
const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]);
@@ -282,6 +357,8 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
strategy: '仅在漏关键步骤时提示',
goal: '',
enabled: true,
expectedContentVersion: '',
expectedContentHash: '',
rounds: [
{ customer: '', coachHint: '先承接情绪,再确认具体诉求。', emotion: 72, trust: 70 },
{ customer: '我想知道接下来谁负责、什么时候反馈。', coachHint: '补上责任人、处理节点和回访时间。', emotion: 62, trust: 82 }
@@ -314,6 +391,90 @@ const currentCapabilityOptions = computed(() =>
curriculumCapabilities.value.filter((item) => item.position === form.position && item.growthLevel === form.growthLevel)
);
type PilotScenarioRow = PracticeScenarioResponse & {
suggestedCompetency?: PracticeCurriculumCapability;
suggestedRiskLevel: '常规' | '高风险';
suggestionNeeded: boolean;
issues: string[];
};
const knownCapability = (scenario: PracticeScenarioResponse) =>
curriculumCapabilities.value.find((item) =>
item.code === scenario.competencyCode
&& item.position === scenario.position
&& item.growthLevel === scenario.growthLevel
);
const suggestedCapability = (scenario: PracticeScenarioResponse) => {
const existing = knownCapability(scenario);
if (existing) return existing;
const options = curriculumCapabilities.value.filter((item) =>
item.position === scenario.position && item.growthLevel === scenario.growthLevel
);
const text = [
scenario.name,
scenario.scenarioType,
scenario.openingText,
scenario.goal,
scenario.sopRefs
].join('');
return options
.map((item, index) => ({
item,
index,
score: item.name
.split(/[\/、与及()()]/)
.filter((term) => term.length >= 2)
.reduce((score, term) => score + (text.includes(term) ? 1 : 0), 0)
}))
.sort((left, right) => right.score - left.score || left.index - right.index)[0]?.item;
};
const scenarioIssues = (
scenario: PracticeScenarioResponse,
competencyCode = scenario.competencyCode,
riskLevel = scenario.riskLevel
) => {
const issues: string[] = [];
const capability = curriculumCapabilities.value.some((item) =>
item.code === competencyCode && item.position === scenario.position && item.growthLevel === scenario.growthLevel
);
if (!capability) issues.push('能力项');
if (riskLevel === '待评估') issues.push('风险');
if (!scenario.sopRefs?.trim()) issues.push('SOP');
if (!scenario.irritants?.trim()) issues.push('服务红线');
if (!scenario.goal?.trim()) issues.push('训练目标');
if (!scenario.successCriteria?.trim()) issues.push('成功标准');
if (!scenario.rounds || scenario.rounds.length < 2) issues.push('训练回合');
if (!scenario.rubric || scenario.rubric.dimensions?.length !== 5) issues.push('五维评分');
return issues;
};
const pilotRows = computed<PilotScenarioRow[]>(() =>
positions.flatMap((position) =>
growthLevels.map((growthLevel) => {
const candidates = scenarios.value.filter((item) => item.position === position && item.growthLevel === growthLevel);
const scenario = candidates.find((item) => item.enabled !== false && !item.requiresDualReview)
|| candidates.find((item) => item.enabled !== false)
|| candidates[0];
if (!scenario) return undefined;
const competency = suggestedCapability(scenario);
const riskLevel = scenario.requiresDualReview ? '高风险' : '常规';
return {
...scenario,
suggestedCompetency: competency,
suggestedRiskLevel: riskLevel,
suggestionNeeded: (!knownCapability(scenario) && Boolean(competency)) || scenario.riskLevel === '待评估',
issues: scenarioIssues(scenario)
};
})
).filter((item): item is PilotScenarioRow => Boolean(item))
);
const pilotSuggestionCount = computed(() => pilotRows.value.filter((item) => item.suggestionNeeded).length);
const pilotReadyCount = computed(() => pilotRows.value.filter((item) => !item.issues.length).length);
const pilotReadyDrafts = computed(() => pilotRows.value.filter((item) => !item.issues.length && item.reviewStatus === '草稿'));
const pilotReadyReviews = computed(() => pilotRows.value.filter((item) => !item.issues.length && item.reviewStatus === '待业务审核'));
const load = async () => {
loading.value = true;
try {
@@ -342,6 +503,101 @@ const queryScenes = () => {
const difficultyLabel = (value: number) => ({ 1: '初级', 2: '中级', 3: '高级', 4: '困难', 5: '挑战' })[value] || '中级';
const scenarioSavePayload = (
item: PracticeScenarioResponse,
competencyCode: string,
riskLevel: '待评估' | '常规' | '高风险'
): PracticeScenarioSaveRequest => ({
id: item.id,
name: item.name,
position: item.position,
scenarioType: item.scenarioType,
projectType: item.projectType,
growthLevel: item.growthLevel,
competencyCode,
collaborationPositions: item.collaborationPositions || '',
riskLevel,
curriculumVersion: item.curriculumVersion,
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,
expectedContentVersion: item.contentVersion,
expectedContentHash: item.contentHash,
rounds: item.rounds.map((round) => ({ ...round }))
});
const savePilotSuggestions = async (rows: PilotScenarioRow[]) => {
const targets = rows.filter((item) => item.suggestionNeeded && item.suggestedCompetency);
const results = await Promise.allSettled(targets.map((item) =>
savePracticeScenario(scenarioSavePayload(item, item.suggestedCompetency!.code, item.suggestedRiskLevel))
));
await load();
const success = results.filter((item) => item.status === 'fulfilled').length;
const failed = results.length - success;
if (failed) ElMessage.warning(`已更新 ${success} 个,${failed} 个失败,请按行重试`);
else ElMessage.success(`已确认 ${success} 个场景的初审建议`);
};
const applyPilotSuggestion = async (row: PilotScenarioRow) => {
batchSaving.value = true;
try {
await savePilotSuggestions([row]);
} finally {
batchSaving.value = false;
}
};
const applyPilotSuggestions = async () => {
const count = pilotSuggestionCount.value;
if (!count) return;
await ElMessageBox.confirm(
`将批量回写 ${count} 个试点场景的能力项和风险建议;不会自动发布。`,
'确认初审建议',
{ type: 'warning' }
);
batchSaving.value = true;
try {
await savePilotSuggestions(pilotRows.value);
} finally {
batchSaving.value = false;
}
};
const advancePilotRows = async (
rows: PilotScenarioRow[],
reviewStatus: '待业务审核' | '已发布'
) => {
if (!rows.length) return;
const action = reviewStatus === '已发布' ? '审核并发布' : '提交审核';
await ElMessageBox.confirm(
`将对 ${rows.length} 个已通过预检的试点场景执行“${action}”。高风险场景仍需另一位审核人复核。`,
`批量${action}`,
{ type: 'warning' }
);
batchReviewing.value = true;
try {
const results = await Promise.allSettled(rows.map((item) =>
updatePracticeScenarioReviewStatus(item.id, { reviewStatus })
));
await load();
const success = results.filter((item) => item.status === 'fulfilled').length;
const failed = results.length - success;
if (failed) ElMessage.warning(`已处理 ${success} 个,${failed} 个未通过服务端校验`);
else ElMessage.success(`已处理 ${success} 个场景,页面已刷新显示最终审核状态`);
} finally {
batchReviewing.value = false;
}
};
const fillForm = (item?: PracticeScenarioResponse) => {
Object.assign(
form,
@@ -370,6 +626,8 @@ const fillForm = (item?: PracticeScenarioResponse) => {
strategy: item.strategy,
goal: item.goal,
enabled: item.enabled,
expectedContentVersion: item.contentVersion,
expectedContentHash: item.contentHash,
rounds: item.rounds?.map((round) => ({ ...round })) || emptyForm().rounds
}
: {}
@@ -535,12 +793,52 @@ onMounted(load);
.filter-card,
.table-card,
.matrix-card {
.matrix-card,
.pilot-card {
padding: 18px;
border-radius: 8px;
background: #fff;
}
.page-actions,
.pilot-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.pilot-card {
margin-bottom: 12px;
:deep(.el-alert) {
margin-bottom: 12px;
}
}
.pilot-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
h2 {
margin: 0;
font-size: 18px;
}
p {
margin: 5px 0 0;
color: #858991;
font-size: 12px;
}
}
.suggestion {
margin-left: 6px;
color: #e60012;
}
.filter-card {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr)) minmax(180px, 1.4fr) 78px;
@@ -656,6 +954,9 @@ onMounted(load);
}
@media (max-width: 1100px) {
.pilot-head {
flex-direction: column;
}
.filter-card {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+2 -1
View File
@@ -6,6 +6,7 @@ import path from 'path';
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd());
const proxyTarget = process.env.VITE_APP_PROXY_TARGET || env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
const keepProxyPrefix = process.env.VITE_PROXY_KEEP_PREFIX === '1';
return {
// 部署生产环境和开发环境下的URL。
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上
@@ -29,7 +30,7 @@ export default defineConfig(({ mode, command }) => {
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path) => path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
rewrite: (path) => keepProxyPrefix ? path : path.replace(new RegExp('^' + env.VITE_APP_BASE_API), '')
}
}
},