Files
prop-ai-hr/frontend/src/views/train/practice.vue
T

1557 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="practice-page">
<section class="page-head">
<div>
<h1>三角色对练</h1>
<p>AI 业主发起情境,学员应答,AI 教练给方向提示,AI 考官完成评分沉淀。</p>
</div>
<div class="head-actions">
<span class="status-pill" :class="apiPill.className">{{ apiPill.text }}</span>
<span class="status-pill" :class="flowPill.className">{{ flowPill.text }}</span>
<el-date-picker
v-model="pilotDateRange"
class="pilot-date-range"
type="daterange"
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="试点开始"
end-placeholder="试点结束"
unlink-panels
/>
<el-button :icon="Download" :loading="exportingPilot" :disabled="!pilotExportWindow" @click="exportPilotCsv">导出试点CSV</el-button>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置流程</el-button>
</div>
</section>
<el-alert
v-if="recordsFallback"
class="data-warning"
type="warning"
:closable="false"
show-icon
title="训练记录接口暂不可用,当前显示演示记录,不代表真实训练数据。"
/>
<section class="flow-strip">
<div v-for="step in flowSteps" :key="step.title" class="flow-step" :class="{ active: step.active, done: step.done }">
<span>{{ step.index }}</span>
<strong>{{ step.title }}</strong>
<small>{{ step.desc }}</small>
</div>
</section>
<section class="practice-grid">
<article class="panel config-panel">
<div class="panel-title">
<el-icon><UserFilled /></el-icon>
<h2>场景配置</h2>
</div>
<div class="select-card">
<span>训练场景</span>
<el-select v-model="scenarioId" class="scenario-select" @change="handleScenarioChange">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
</div>
<div class="field-list">
<div v-for="item in scenarioProfile" :key="item.label" class="field-item">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="rubric-card">
<div class="rubric-head">
<strong>Rubric 预览</strong>
<small>{{ selectedScenario.scores.length }} 个维度</small>
</div>
<p v-for="score in selectedScenario.scores" :key="score.label">
<span>{{ score.label }}</span>
<b>{{ score.value }}</b>
<small>{{ score.note }}</small>
</p>
</div>
<div class="trust-card">
<div>
<span>业主信任度</span>
<strong>{{ trustScore }}%</strong>
</div>
<div class="meter"><i :style="{ width: `${trustScore}%` }"></i></div>
</div>
<div class="trust-card emotion-card">
<div>
<span>情绪波动</span>
<strong>{{ emotionScore }}%</strong>
</div>
<div class="meter"><i :style="{ width: `${emotionScore}%` }"></i></div>
</div>
<div class="action-row config-actions">
<el-button type="danger" :disabled="started" :icon="Microphone" @click="startPractice">开始对练</el-button>
<el-button :icon="Tickets" @click="showGoal = !showGoal">{{ showGoal ? '收起目标' : '查看目标' }}</el-button>
</div>
<div v-if="showGoal" class="goal-card">
<strong>训练目标</strong>
<p>{{ selectedScenario.goal }}</p>
</div>
<div v-if="prepCard || prepLoading" class="prep-card">
<strong>训练前预习卡</strong>
<small>{{ prepLoading ? '加载中' : prepCard?.promptVersion }}</small>
<template v-if="prepCard">
<p>要点:{{ prepCard.keyPoints.join(' / ') }}</p>
<p>红线:{{ prepCard.redLines.join(' / ') }}</p>
<p>话术:{{ prepCard.scripts[0] }}</p>
</template>
</div>
</article>
<article class="panel chat-panel">
<div class="panel-title title-with-extra">
<div>
<el-icon><Microphone /></el-icon>
<h2>对练实时过程</h2>
</div>
<span>{{ completedRounds }}/{{ selectedScenario.rounds.length }} 轮</span>
</div>
<div v-if="!started" class="empty-state">
<strong>等待开始对练</strong>
<p>点击“开始对练”,AI 业主会抛出第一轮投诉情境。</p>
<el-button type="danger" :icon="Microphone" @click="startPractice">开始对练</el-button>
</div>
<template v-else>
<div class="chat-list">
<div v-for="turn in conversation" :key="turn.id" class="bubble" :class="turn.role">
<strong>{{ turn.name }}</strong>
<p>{{ turn.text }}</p>
</div>
</div>
<div class="reply-box">
<div class="reply-head">
<span>学员下一句</span>
</div>
<el-input v-model="draftReply" type="textarea" :rows="5" resize="none" :disabled="finished" placeholder="输入学员真实回复" />
</div>
<div class="action-row">
<el-button :disabled="finished" @click="continueRound">继续一轮</el-button>
<el-button :disabled="sopHelpLoading" @click="askSopHelp">{{ sopHelpLoading ? '查询中' : '求助 SOP' }}</el-button>
<el-button type="danger" :disabled="!canScore" @click="finishScore">结束并评分</el-button>
</div>
<div v-if="sopHelp || sopHelpMessage" class="help-card">
<strong>SOP 求助</strong>
<p v-if="sopHelp">{{ sopHelp.answer }}</p>
<p v-else>{{ sopHelpMessage }}</p>
<small v-if="sopHelp?.reference">{{ sopHelp.reference }}</small>
</div>
</template>
</article>
<article class="panel score-panel">
<div class="panel-title">
<el-icon><TrendCharts /></el-icon>
<h2>考官评分</h2>
</div>
<div v-if="!scored" class="empty-state score-empty">
<strong>等待评分</strong>
<p>完成两轮对练后点击“结束并评分”,生成考官评分、导师改写和训练记录。</p>
</div>
<template v-else>
<div class="score-grid">
<div v-for="score in displayScores" :key="score.label" class="score-item">
<span>{{ score.label }}</span>
<strong>{{ score.value }}</strong>
<small>{{ score.note }}</small>
</div>
</div>
<div class="coach-card">
<strong>导师改写</strong>
<p>{{ displayRewrite }}</p>
</div>
<div class="calibration-card">
<div class="calibration-head">
<strong>人工校准</strong>
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small>
</div>
<el-input-number v-model="calibrationScore" :min="0" :max="100" :disabled="!canCalibrate" />
<el-input v-model="calibrationReason" type="textarea" :rows="2" resize="none" :disabled="!canCalibrate" placeholder="校准原因" />
<div class="action-row">
<el-button type="danger" :loading="calibrationSaving" :disabled="!canCalibrate" @click="saveCalibration">记录校准样本</el-button>
</div>
</div>
</template>
</article>
</section>
<section class="panel record-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>最近对练记录</h2>
</div>
<el-table v-loading="recordsLoading" :data="records" size="small" class="record-table">
<el-table-column prop="time" label="时间" width="128" />
<el-table-column prop="trainee" label="学员" width="120" />
<el-table-column prop="scene" label="场景" min-width="150" />
<el-table-column prop="score" label="得分" width="80" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="summary" label="AI 摘要" min-width="260" />
</el-table>
</section>
<section class="panel prompt-panel">
<div class="panel-title title-with-extra">
<div>
<el-icon><Tickets /></el-icon>
<h2>提示词模板</h2>
</div>
<span>{{ promptTemplates.length }} 个模板</span>
</div>
<div class="prompt-layout">
<div class="prompt-list">
<el-table v-loading="promptLoading" :data="promptTemplates" size="small" class="record-table" row-key="id">
<el-table-column prop="code" label="Code" min-width="150" />
<el-table-column prop="name" label="名称" min-width="130" />
<el-table-column prop="category" label="分类" width="110" />
<el-table-column prop="outputFormat" label="输出" width="118" />
<el-table-column prop="version" label="版本" width="82" />
<el-table-column label="状态" width="92">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" effect="plain">{{ row.enabled ? '启用' : '停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="editPromptTemplate(row)">编辑</el-button>
<el-button link :type="row.enabled ? 'warning' : 'success'" @click="togglePromptTemplate(row)">
{{ row.enabled ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<el-form label-position="top" class="prompt-form">
<div class="prompt-form-head">
<strong>{{ promptFormMode }}</strong>
<el-button text type="primary" @click="resetPromptTemplateForm">新建模板</el-button>
</div>
<el-row :gutter="10">
<el-col :span="12">
<el-form-item label="Code">
<el-input v-model="promptForm.code" placeholder="mentor_rewrite" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="名称">
<el-input v-model="promptForm.name" placeholder="导师改写" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="10">
<el-col :span="8">
<el-form-item label="分类">
<el-select v-model="promptForm.category" class="full-control" filterable allow-create>
<el-option v-for="category in promptCategoryOptions" :key="category" :label="category" :value="category" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="输出格式">
<el-input v-model="promptForm.outputFormat" placeholder="json_card / text" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="版本">
<el-input v-model="promptForm.version" placeholder="v1" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="系统提示词">
<el-input v-model="promptForm.systemPrompt" type="textarea" :rows="3" resize="none" />
</el-form-item>
<el-form-item label="模板正文">
<el-input v-model="promptForm.template" type="textarea" :rows="5" resize="none" />
</el-form-item>
<el-row :gutter="10">
<el-col :span="16">
<el-form-item label="变量">
<el-input v-model="promptForm.variables" placeholder="scenarioName,goal,traineeText 或 JSON" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="启用状态">
<el-switch v-model="promptForm.enabled" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="说明">
<el-input v-model="promptForm.description" type="textarea" :rows="2" resize="none" />
</el-form-item>
<div class="action-row prompt-actions">
<el-button @click="loadPromptTemplates">刷新</el-button>
<el-button type="danger" :loading="promptSaving" @click="savePromptTemplate">保存模板</el-button>
</div>
</el-form>
</div>
</section>
</div>
</template>
<script setup name="TrainPractice" lang="ts">
import { computed, onMounted, ref } from 'vue';
import { Download, Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import FileSaver from 'file-saver';
import {
calibratePracticeSession,
createPromptTemplate,
exportPracticePilotCsv,
finishPracticeSession,
getPracticePrepCard,
listPromptTemplates,
listPracticeScenarios,
startPracticeSession,
submitPracticeTurn,
updatePromptTemplate,
type PromptTemplateRequest,
type PromptTemplateResponse,
type PracticePrepCardResponse,
type PracticeScenarioResponse
} from '@/api/aihr/practice';
import { listPracticeReviews } from '@/api/aihr/mobile-admin';
import { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
import { resolvePilotDateWindow } from './pilot-export';
type ChatRole = 'customer' | 'trainee' | 'coach';
type ChatTurn = {
id: string;
role: ChatRole;
name: string;
text: string;
};
type PracticeRound = {
customer: string;
sampleReply: string;
coachHint: string;
};
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;
strategy: string;
goal: string;
trustStart: number;
trustEnd: number;
rounds: PracticeRound[];
scores: Array<{
label: string;
value: number | string;
note: string;
}>;
total: number;
rewrite: string;
summary: string;
};
type PromptTemplateForm = PromptTemplateRequest & {
id: number | null;
};
const router = useRouter();
const fallbackScenarios: ScenarioSeed[] = [
{
id: 'complaint-water',
name: '客服应对投诉',
trainee: '张三',
customer: '高情绪、追问进度、质疑服务',
project: '住宅 / 车库积水',
strategy: '仅在漏关键步骤时提示',
goal: '训练学员先安抚、再确认诉求,并给出责任人和首次反馈时间。',
trustStart: 68,
trustEnd: 82,
rounds: [
{
customer: '你们说今天处理,到现在都没人联系我,这个车库还怎么停?',
sampleReply: '我理解您现在最关心的是谁负责和什么时候处理。我先确认工单和现场照片,10 分钟内给您明确责任人和到场时间。',
coachHint: '这轮命中“安抚 + 责任人 + 首次反馈时间”,可以继续追问处理承诺。'
},
{
customer: '我不要流程,我要知道如果今天还处理不好怎么办。',
sampleReply: '如果今天现场不能完成处理,我会同步主管升级,并在 18 点前给您替代方案和下一次确认时间,避免您继续空等。',
coachHint: '补上了升级路径和兜底承诺,结束后可进入评分。'
}
],
scores: [
{ label: '合规', value: 84, note: '有升级路径' },
{ label: '沟通', value: 88, note: '承诺清楚' },
{ label: '情绪', value: 82, note: '先承接诉求' },
{ label: '错题', value: '1条', note: '补费用争议' }
],
total: 86,
rewrite: '建议表达为:“我先确认责任人和到场时间,10 分钟内回复您;如果今天无法完成,我同步主管给您替代方案和下一次反馈时间。”',
summary: '能先安抚并明确责任人与反馈时限,升级兜底表达比上一轮更完整。'
},
{
id: 'fee-collection',
name: '催缴沟通技巧',
trainee: '李四',
customer: '拒绝缴费、质疑服务、要求先整改',
project: '住宅 / 物业费催缴',
strategy: '提示服务改进与缴费并行',
goal: '训练学员把费用沟通和服务整改拆开处理,避免激化争议。',
trustStart: 61,
trustEnd: 76,
rounds: [
{
customer: '服务做成这样还让我缴费?你先把问题解决了再说。',
sampleReply: '您提到的服务问题我会逐项记录并跟进,缴费和整改可以并行处理。我先确认您最不满意的三件事,并约定今天给您整改反馈。',
coachHint: '表达方向正确,但要补“争议项复核”和“责任人”。'
},
{
customer: '那我怎么知道你不是随便记一下?',
sampleReply: '我会把问题登记到回访记录里,由客服主管复核,今天 17 点前给您第一次处理反馈,后续每个节点都会同步。',
coachHint: '补上主管复核和节点同步,可以进入评分。'
}
],
scores: [
{ label: '合规', value: 80, note: '复核明确' },
{ label: '沟通', value: 83, note: '拆分得当' },
{ label: '情绪', value: 78, note: '安抚一般' },
{ label: '错题', value: '2条', note: '补开场安抚' }
],
total: 81,
rewrite: '建议先承接情绪:“您觉得服务不到位,我先把问题负责跟到底。”再说明缴费和整改并行,最后给出主管复核与反馈时间。',
summary: '费用争议拆分较清楚,但开场情绪承接不足。'
}
];
const baseRecords = [
{ time: '07-02 09:32', trainee: '张三', scene: '客服应对投诉', score: 86, status: '已完成', summary: '能先安抚并确认诉求,但反馈时限需要更明确。' },
{ time: '07-02 09:12', trainee: '李四', scene: '催缴沟通技巧', score: 78, status: '已完成', summary: '费用争议回应完整,服务改进承诺偏弱。' },
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
];
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
const scenarioId = ref(fallbackScenarios[0].id);
const started = ref(false);
const finished = ref(false);
const scored = ref(false);
const showGoal = ref(false);
const activeRound = ref(0);
const draftReply = ref('');
const sessionId = ref('');
const apiMode = ref<'local' | 'api' | 'fallback'>('local');
const apiScores = ref<ScenarioSeed['scores'] | null>(null);
const apiTotal = ref<number | null>(null);
const apiRewrite = ref('');
const apiSummary = ref('');
const apiTrust = ref<number | null>(null);
const apiEmotion = ref<number | null>(null);
const prepCard = ref<PracticePrepCardResponse | null>(null);
const prepLoading = ref(false);
const sopHelp = ref<SopSearchResponse | null>(null);
const sopHelpLoading = ref(false);
const sopHelpMessage = ref('');
const conversation = ref<ChatTurn[]>([]);
const records = ref<Array<(typeof baseRecords)[number]>>([]);
const recordsLoading = ref(true);
const recordsFallback = ref(false);
const promptTemplates = ref<PromptTemplateResponse[]>([]);
const promptLoading = ref(false);
const promptSaving = ref(false);
const calibrationScore = ref(0);
const calibrationReason = ref('管理端人工校准');
const calibrationSaving = ref(false);
const calibrationMessage = ref('');
const exportingPilot = ref(false);
const pilotDateRange = ref<string[] | null>(null);
const pilotExportWindow = computed(() => resolvePilotDateWindow(pilotDateRange.value));
const promptCategoryOptions = ['practice', 'knowledge', 'supervision'];
const defaultPromptForm = (): PromptTemplateForm => ({
id: null,
code: 'mentor_rewrite',
name: '导师改写',
category: 'practice',
outputFormat: 'text',
systemPrompt: '',
template: '',
variables: 'scenarioName,goal,traineeText',
version: 'v1',
enabled: true,
description: ''
});
const promptForm = ref<PromptTemplateForm>(defaultPromptForm());
const selectedScenario = computed(
() => scenarios.value.find((scenario) => scenario.id === scenarioId.value) ?? scenarios.value[0] ?? fallbackScenarios[0]
);
const promptFormMode = computed(() => (promptForm.value.id ? '编辑模板' : '新增模板'));
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
const canScore = computed(() => finished.value && !scored.value);
const canCalibrate = computed(() => scored.value && apiMode.value === 'api' && Boolean(sessionId.value));
const displayScores = computed(() => apiScores.value ?? selectedScenario.value.scores);
const displayTotal = computed(() => apiTotal.value ?? selectedScenario.value.total);
const displayRewrite = computed(() => apiRewrite.value || selectedScenario.value.rewrite);
const displaySummary = computed(() => apiSummary.value || selectedScenario.value.summary);
const trustScore = computed(() => {
if (apiTrust.value !== null) {
return apiTrust.value;
}
if (scored.value) {
return selectedScenario.value.trustEnd;
}
if (!started.value) {
return selectedScenario.value.trustStart;
}
return selectedScenario.value.trustStart + completedRounds.value * 7;
});
const emotionScore = computed(() => {
if (apiEmotion.value !== null) {
return apiEmotion.value;
}
if (!started.value) {
return 70;
}
return Math.max(45, 76 - completedRounds.value * 8);
});
const apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '真实 API', className: 'pill-info' };
}
if (apiMode.value === 'fallback') {
return { text: '本地剧本', className: 'pill-warning' };
}
return { text: '待调用 API', className: 'pill-info' };
});
const flowPill = computed(() => {
if (!started.value) {
return { text: '待开始对练', className: 'pill-warning' };
}
if (!scored.value) {
return { text: '对练进行中', className: 'pill-warning' };
}
return { text: '已完成闭环', className: 'pill-success' };
});
const flowSteps = computed(() => [
{ index: '1', title: '选场景', desc: selectedScenario.value.name, active: !started.value, done: started.value || scored.value },
{
index: '2',
title: '三角色对话',
desc: `${completedRounds.value}/${selectedScenario.value.rounds.length} 轮`,
active: started.value && !finished.value,
done: finished.value
},
{ index: '3', title: '评分沉淀', desc: scored.value ? `${displayTotal.value} 分` : '等待评分', active: scored.value, done: scored.value }
]);
const scenarioProfile = computed(() => [
{ label: '训练场景', value: selectedScenario.value.name },
{ label: 'AI 业主', value: selectedScenario.value.customer },
{ label: '项目类型', value: selectedScenario.value.project },
{ label: '教练策略', value: selectedScenario.value.strategy },
{ label: '学员', value: selectedScenario.value.trainee }
]);
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,
strategy: item.strategy,
goal: item.goal || item.successCriteria,
trustStart: Math.max(50, 70 - item.difficulty * 4),
trustEnd: 80 + Math.min(item.difficulty, 3),
rounds: [
{
customer: item.openingText || item.explicitNeed,
sampleReply: '我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。',
coachHint: item.successCriteria || '补上责任人、处理节点和回访方式。'
},
{
customer: '那接下来谁负责、什么时候给我反馈?',
sampleReply: '我会把责任人、处理节点和回访时间同步给您,过程有变化也会及时说明。',
coachHint: '说明闭环节点,不要做未经确认的承诺。'
}
],
scores:
item.rubric?.dimensions?.map((dimension) => ({
label: dimension.name,
value: Math.round(dimension.weight * 100),
note: dimension.description
})) || fallbackScenarios[0].scores,
total: 82,
rewrite: '建议先承接情绪,再说明责任人、处理节点和反馈方式。',
summary: item.successCriteria || '能按SOP给出明确闭环。'
});
const loadPrepCard = async () => {
prepLoading.value = true;
try {
const { data } = await getPracticePrepCard(scenarioId.value);
prepCard.value = data;
} catch {
prepCard.value = null;
} finally {
prepLoading.value = false;
}
};
const loadScenarioOptions = async () => {
try {
const { data } = await listPracticeScenarios({ position: '生活顾问' });
if (data?.length) {
scenarios.value = data.map(mapApiScenario);
if (!scenarios.value.some((scenario) => scenario.id === scenarioId.value)) {
scenarioId.value = scenarios.value[0].id;
}
}
} catch {
scenarios.value = [...fallbackScenarios];
}
await loadPrepCard();
};
const loadRecentRecords = async () => {
recordsLoading.value = true;
try {
const { data } = await listPracticeReviews({ limit: 10 });
records.value = (data || []).map((item) => ({
time: item.time,
trainee: item.trainee,
scene: item.scene,
score: item.score,
status: item.status,
summary: item.summary
}));
recordsFallback.value = false;
} catch {
records.value = [...baseRecords];
recordsFallback.value = true;
} finally {
recordsLoading.value = false;
}
};
const appendTurn = (role: ChatRole, text: string) => {
const nameMap = { customer: 'AI 业主', trainee: '学员', coach: 'AI 教练' };
conversation.value.push({ id: `${role}-${conversation.value.length}`, role, name: nameMap[role], text });
};
const resetPractice = () => {
started.value = false;
finished.value = false;
scored.value = false;
showGoal.value = false;
activeRound.value = 0;
draftReply.value = '';
sessionId.value = '';
apiMode.value = 'local';
apiScores.value = null;
apiTotal.value = null;
apiRewrite.value = '';
apiSummary.value = '';
apiTrust.value = null;
apiEmotion.value = null;
sopHelp.value = null;
sopHelpMessage.value = '';
calibrationScore.value = 0;
calibrationReason.value = '管理端人工校准';
calibrationMessage.value = '';
conversation.value = [];
};
const handleScenarioChange = () => {
resetPractice();
void loadPrepCard();
};
const startPractice = async () => {
resetPractice();
const fallbackCustomer = selectedScenario.value.rounds[0].customer;
try {
const { data } = await startPracticeSession({
extPartyId: selectedScenario.value.trainee,
scenarioId: selectedScenario.value.id,
mode: 'real'
});
sessionId.value = data.sessionId;
apiMode.value = 'api';
apiTrust.value = data.trust;
apiEmotion.value = data.emotion;
appendTurn('customer', data.customerText || fallbackCustomer);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
} catch {
sessionId.value = '';
apiMode.value = 'fallback';
appendTurn('customer', fallbackCustomer);
ElMessage.warning('后端对练 API 不可用,已回退本地剧本');
}
started.value = true;
};
const continueRound = async () => {
const round = selectedScenario.value.rounds[activeRound.value];
if (!draftReply.value.trim()) {
ElMessage.warning('请先输入学员回复');
return;
}
const traineeText = draftReply.value.trim();
appendTurn('trainee', traineeText);
draftReply.value = '';
if (apiMode.value === 'api' && sessionId.value) {
try {
const { data } = await submitPracticeTurn({
sessionId: sessionId.value,
roundIndex: activeRound.value,
traineeText
});
appendTurn('coach', data.redFlag ? data.coachHint || '红线纠偏:先承接情绪,再给责任人与反馈节点。' : data.coachHint || round.coachHint);
apiTrust.value = data.trust;
apiEmotion.value = data.emotion;
activeRound.value = Math.min(data.roundIndex ?? activeRound.value + 1, selectedScenario.value.rounds.length);
if (data.finished || activeRound.value >= selectedScenario.value.rounds.length) {
finished.value = true;
ElMessage.success('后端 API 已完成两轮对练,可以结束并评分');
return;
}
appendTurn('customer', data.customerText || selectedScenario.value.rounds[activeRound.value].customer);
return;
} catch {
apiMode.value = 'fallback';
ElMessage.warning('后端回合 API 不可用,已回退本地剧本推进');
}
}
appendTurn('coach', round.coachHint);
activeRound.value += 1;
if (activeRound.value >= selectedScenario.value.rounds.length) {
finished.value = true;
ElMessage.success('两轮对练已完成,可以结束并评分');
return;
}
appendTurn('customer', selectedScenario.value.rounds[activeRound.value].customer);
};
const askSopHelp = async () => {
sopHelpLoading.value = true;
sopHelpMessage.value = '';
sopHelp.value = null;
try {
const { data } = await searchKnowledgeSop({
queryText: `${selectedScenario.value.name} ${selectedScenario.value.goal}`,
category: 'sop',
position: selectedScenario.value.position || '生活顾问',
source: 'practice_help',
limit: 3
});
if (!data?.answer) {
throw new Error('SOP 未返回可用答案');
}
sopHelp.value = data;
} catch (error) {
sopHelpMessage.value = error instanceof Error ? error.message : 'SOP 求助失败,请稍后重试';
} finally {
sopHelpLoading.value = false;
}
};
const finishScore = async () => {
let nextRecord = {
time: '07-02 10:42',
trainee: selectedScenario.value.trainee,
scene: selectedScenario.value.name,
score: displayTotal.value,
status: '已完成',
summary: displaySummary.value
};
if (apiMode.value === 'api' && sessionId.value) {
try {
const { data } = await finishPracticeSession({ sessionId: sessionId.value });
apiScores.value = data.score;
apiTotal.value = data.totalScore;
apiRewrite.value = data.mentorRewrite;
apiSummary.value = data.aiComment;
apiTrust.value = data.trustCurve?.[data.trustCurve.length - 1] ?? selectedScenario.value.trustEnd;
nextRecord = data.records?.[0] ?? { ...nextRecord, score: data.totalScore, summary: data.aiComment };
ElMessage.success('三角色对练评分已由后端 API 返回');
} catch {
apiMode.value = 'fallback';
apiScores.value = null;
apiTotal.value = null;
apiRewrite.value = '';
apiSummary.value = '';
ElMessage.warning('后端评分 API 不可用,已回退本地评分');
}
} else {
apiScores.value = null;
apiTotal.value = null;
apiRewrite.value = '';
apiSummary.value = '';
}
scored.value = true;
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
records.value = [nextRecord, ...records.value];
if (apiMode.value !== 'api') {
ElMessage.success('考官评分已生成,并写入本地对练记录');
}
};
const saveCalibration = async () => {
if (!canCalibrate.value) {
ElMessage.warning('请先完成一次真实 API 对练评分');
return;
}
calibrationSaving.value = true;
try {
const { data } = await calibratePracticeSession(sessionId.value, {
reviewer: '管理端校准',
correctedTotal: calibrationScore.value,
reason: calibrationReason.value.trim() || '管理端人工校准'
});
calibrationMessage.value = `已记录:AI ${data.originalScore} / 人工 ${data.correctedScore}`;
ElMessage.success('校准样本已记录');
} catch {
ElMessage.error('校准样本记录失败,请检查权限或会话状态');
} finally {
calibrationSaving.value = false;
}
};
const exportPilotCsv = async () => {
const dateWindow = pilotExportWindow.value;
if (!dateWindow) {
ElMessage.warning('请选择完整的试点批次日期');
return;
}
const { startDate, endDate } = dateWindow;
exportingPilot.value = true;
try {
const data = await exportPracticePilotCsv({ startDate, endDate });
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), `aihr-practice-pilot-${startDate}-${endDate}.csv`);
ElMessage.success('试点数据 CSV 已导出');
} catch {
ElMessage.error('试点数据导出失败,请检查权限');
} finally {
exportingPilot.value = false;
}
};
const promptTemplateToForm = (template: PromptTemplateResponse): PromptTemplateForm => ({
id: template.id,
code: template.code || '',
name: template.name || '',
category: template.category || 'practice',
outputFormat: template.outputFormat || 'text',
systemPrompt: template.systemPrompt || '',
template: template.template || '',
variables: template.variables || '',
version: template.version || 'v1',
enabled: Boolean(template.enabled),
description: template.description || ''
});
const promptFormToRequest = (form: PromptTemplateForm): PromptTemplateRequest => ({
code: form.code.trim(),
name: form.name.trim(),
category: form.category.trim() || 'practice',
outputFormat: form.outputFormat.trim() || 'text',
systemPrompt: form.systemPrompt.trim(),
template: form.template.trim(),
variables: form.variables.trim(),
version: form.version.trim() || 'v1',
enabled: form.enabled,
description: form.description.trim()
});
const validatePromptVariables = (variables: string) => {
const value = variables.trim();
if (!value || !/^[{[]/.test(value)) {
return true;
}
try {
JSON.parse(value);
return true;
} catch {
return false;
}
};
const validatePromptForm = () => {
if (!promptForm.value.code.trim() || !promptForm.value.name.trim()) {
ElMessage.warning('请填写模板 Code 和名称');
return false;
}
if (!promptForm.value.template.trim()) {
ElMessage.warning('请填写模板正文');
return false;
}
if (!validatePromptVariables(promptForm.value.variables)) {
ElMessage.warning('变量使用 JSON 时必须是合法 JSON;逗号分隔变量可直接填写');
return false;
}
return true;
};
const loadPromptTemplates = async () => {
promptLoading.value = true;
try {
const { data } = await listPromptTemplates();
promptTemplates.value = data || [];
} catch {
promptTemplates.value = [];
ElMessage.warning('提示词模板加载失败');
} finally {
promptLoading.value = false;
}
};
const editPromptTemplate = (template: PromptTemplateResponse) => {
promptForm.value = promptTemplateToForm(template);
};
const resetPromptTemplateForm = () => {
promptForm.value = defaultPromptForm();
};
const savePromptTemplate = async () => {
if (!validatePromptForm()) {
return;
}
promptSaving.value = true;
try {
const requestData = promptFormToRequest(promptForm.value);
if (promptForm.value.id) {
const { data } = await updatePromptTemplate(promptForm.value.id, requestData);
promptForm.value = promptTemplateToForm(data);
ElMessage.success('提示词模板已更新');
} else {
const { data } = await createPromptTemplate(requestData);
promptForm.value = promptTemplateToForm(data);
ElMessage.success('提示词模板已创建');
}
await loadPromptTemplates();
} catch {
ElMessage.error('提示词模板保存失败,请检查权限或字段唯一性');
} finally {
promptSaving.value = false;
}
};
const togglePromptTemplate = async (template: PromptTemplateResponse) => {
promptSaving.value = true;
try {
const nextForm = promptTemplateToForm(template);
nextForm.enabled = !template.enabled;
await updatePromptTemplate(template.id, promptFormToRequest(nextForm));
await loadPromptTemplates();
ElMessage.success(template.enabled ? '提示词模板已停用' : '提示词模板已启用');
} catch {
ElMessage.error('提示词模板状态更新失败');
} finally {
promptSaving.value = false;
}
};
onMounted(() => {
void loadScenarioOptions();
void loadPromptTemplates();
void loadRecentRecords();
});
</script>
<style lang="scss" scoped>
.practice-page {
min-height: calc(100vh - 84px);
padding: 22px;
background: #f5f7fb;
color: #162033;
}
.page-head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 18px;
margin-bottom: 16px;
h1 {
margin: 0 0 8px;
font-size: 24px;
line-height: 1.2;
}
p {
margin: 0;
color: #657184;
font-size: 14px;
}
}
.head-actions,
.action-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.pilot-date-range {
width: 250px;
}
.flow-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.flow-step {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 4px 10px;
align-items: center;
min-height: 70px;
padding: 12px;
border: 1px solid #e4e9f2;
border-radius: 8px;
background: #fff;
text-align: left;
cursor: default;
span {
display: grid;
grid-row: span 2;
width: 32px;
height: 32px;
place-items: center;
border-radius: 50%;
background: #eef4ff;
color: #356bb4;
font-weight: 700;
}
strong {
color: #22314a;
font-size: 14px;
}
small {
color: #7a8597;
font-size: 12px;
}
}
.flow-step.active {
border-color: #f5a1a8;
box-shadow: 0 8px 20px rgba(215, 15, 32, 0.08);
}
.flow-step.done span {
color: #15844a;
background: #e5f8ee;
}
.practice-grid {
display: grid;
grid-template-columns: minmax(280px, 0.9fr) minmax(440px, 1.35fr) minmax(300px, 0.9fr);
gap: 12px;
margin-bottom: 12px;
}
.panel {
border: 1px solid #e4e9f2;
border-radius: 8px;
background: #fff;
box-shadow: 0 10px 24px rgba(21, 30, 45, 0.04);
padding: 18px;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
color: #4971b6;
h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
}
.title-with-extra {
justify-content: space-between;
div {
display: flex;
align-items: center;
gap: 8px;
}
span {
color: #657184;
font-size: 12px;
font-weight: 700;
}
}
.select-card,
.field-item,
.rubric-card,
.reply-box,
.coach-card,
.calibration-card,
.trust-card,
.goal-card,
.prep-card,
.help-card {
padding: 12px;
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #f9fbfe;
}
.select-card {
display: grid;
gap: 8px;
margin-bottom: 10px;
span {
color: #7a8597;
font-size: 12px;
}
}
.scenario-select {
width: 100%;
}
.field-list {
display: grid;
gap: 10px;
}
.field-item {
display: grid;
gap: 4px;
span {
color: #7a8597;
font-size: 12px;
}
strong {
font-size: 14px;
}
}
.rubric-card {
display: grid;
gap: 8px;
margin-top: 10px;
}
.rubric-head,
.rubric-card p {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 4px 10px;
align-items: center;
margin: 0;
}
.rubric-head small,
.rubric-card p small {
grid-column: 1 / -1;
color: #7a8597;
font-size: 12px;
}
.rubric-card p {
padding-top: 8px;
border-top: 1px solid #e5eaf3;
span {
color: #22314a;
font-size: 13px;
font-weight: 700;
}
b {
color: #d70f20;
}
}
.trust-card,
.goal-card,
.prep-card,
.help-card {
margin-top: 12px;
p {
margin: 6px 0 0;
color: #46566e;
line-height: 1.6;
}
small {
display: block;
margin-top: 4px;
color: #7a8597;
font-size: 12px;
}
}
.trust-card div:first-child {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.config-actions {
justify-content: flex-start;
}
.meter {
height: 8px;
border-radius: 999px;
background: #ecf1f7;
overflow: hidden;
i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #f65f61, #31b367);
}
}
.emotion-card .meter i {
background: linear-gradient(90deg, #31b367, #ffb020, #f65f61);
}
.chat-list {
display: grid;
gap: 10px;
}
.bubble {
max-width: 88%;
padding: 10px 12px;
border-radius: 8px;
strong {
display: block;
margin-bottom: 4px;
}
p {
margin: 0;
color: #344255;
line-height: 1.6;
}
&.customer {
background: #f1f5fb;
}
&.trainee {
justify-self: end;
background: #ffe9ea;
}
&.coach {
border: 1px dashed #ffbe73;
background: #fff8ed;
}
}
.reply-box {
margin-top: 12px;
:deep(.el-textarea__inner) {
line-height: 1.7;
}
}
.reply-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
span {
color: #7a8597;
font-size: 12px;
}
}
.empty-state {
display: grid;
place-items: center;
gap: 10px;
min-height: 320px;
padding: 24px;
border: 1px dashed #dbe3ef;
border-radius: 8px;
background: #fbfcff;
text-align: center;
strong {
color: #22314a;
font-size: 16px;
}
p {
max-width: 360px;
margin: 0;
color: #657184;
line-height: 1.6;
}
}
.score-empty {
min-height: 360px;
}
.action-row {
justify-content: flex-end;
margin-top: 12px;
}
.score-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.score-item {
padding: 12px;
border: 1px solid #e5eaf3;
border-radius: 8px;
span,
small {
display: block;
color: #647084;
font-size: 12px;
}
strong {
display: block;
margin: 4px 0;
color: #d70f20;
font-size: 24px;
}
}
.coach-card {
margin-top: 12px;
background: #fff8ed;
p {
margin: 6px 0 0;
line-height: 1.6;
}
}
.calibration-card {
display: grid;
gap: 10px;
margin-top: 12px;
}
.calibration-head {
display: flex;
justify-content: space-between;
gap: 10px;
small {
color: #647084;
font-size: 12px;
}
}
.status-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 24px;
padding: 0 10px;
border-radius: 6px;
font-size: 12px;
line-height: 24px;
font-weight: 700;
}
.pill-success {
color: #15844a;
background: #e5f8ee;
}
.pill-warning {
color: #c46b00;
background: #fff2dc;
}
.pill-info {
color: #4971b6;
background: #edf4ff;
}
.record-panel {
:deep(.el-table__inner-wrapper::before) {
display: none;
}
}
.record-table {
border: 1px solid #edf0f5;
border-radius: 8px;
overflow: hidden;
}
.prompt-panel {
margin-top: 12px;
}
.prompt-layout {
display: grid;
grid-template-columns: minmax(520px, 1.2fr) minmax(360px, 0.8fr);
gap: 14px;
align-items: start;
}
.prompt-list {
min-width: 0;
}
.prompt-form {
padding: 14px;
border: 1px solid #e5eaf3;
border-radius: 8px;
background: #f9fbfe;
:deep(.el-form-item) {
margin-bottom: 12px;
}
:deep(.el-textarea__inner) {
line-height: 1.65;
}
}
.prompt-form-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
strong {
color: #22314a;
font-size: 15px;
}
}
.full-control {
width: 100%;
}
.prompt-actions {
margin-top: 2px;
}
@media (max-width: 1360px) {
.practice-grid {
grid-template-columns: 1fr;
}
.prompt-layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 768px) {
.practice-page {
padding: 14px;
}
.page-head {
align-items: flex-start;
flex-direction: column;
}
.flow-strip {
grid-template-columns: 1fr;
}
.prompt-form {
padding: 12px;
}
.prompt-form :deep(.el-col) {
max-width: 100%;
flex: 0 0 100%;
}
.score-grid {
grid-template-columns: 1fr;
}
.action-row {
justify-content: flex-start;
}
}
</style>