feat(practice): streamline pilot content operations
This commit is contained in:
@@ -87,6 +87,8 @@ export type PracticeScenarioSaveRequest = {
|
||||
goal: string;
|
||||
enabled: boolean;
|
||||
rounds: PracticeScenarioRound[];
|
||||
expectedContentVersion?: string;
|
||||
expectedContentHash?: string;
|
||||
};
|
||||
|
||||
export type PracticeScenarioReviewStatusRequest = {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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), '')
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user