feat(aihr): close phase two practice pilot gates

This commit is contained in:
2026-07-08 00:51:32 +08:00
parent 1d14497bc3
commit 4b10b08eeb
27 changed files with 4807 additions and 206 deletions
+152
View File
@@ -12,6 +12,43 @@ export type PracticeStartRequest = {
mode: string;
};
export type PracticeScenarioResponse = {
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;
strategy: string;
goal: string;
enabled: boolean;
rubric?: {
code: string;
name: string;
version: string;
dimensions: Array<{ code: string; name: string; weight: number; description: string }>;
};
};
export type PracticeScenarioEnabledRequest = {
enabled: boolean;
};
export type PracticePrepCardResponse = {
keyPoints: string[];
redLines: string[];
scripts: string[];
promptVersion: string;
};
export type PracticeStartResponse = {
sessionId: string;
scenarioId: string;
@@ -34,6 +71,7 @@ export type PracticeTurnRequest = {
roundIndex: number;
traineeText: string;
traineeAudioUrl?: string;
traineeAudioOssId?: number;
};
export type PracticeTurnResponse = {
@@ -42,6 +80,7 @@ export type PracticeTurnResponse = {
emotion: number;
trust: number;
coachHint: string;
redFlag: boolean;
roundIndex: number;
finished: boolean;
};
@@ -70,6 +109,78 @@ export type PracticeFinishResponse = {
}>;
};
export type PracticeCalibrationRequest = {
reviewer?: string;
correctedTotal?: number;
correctedCompliance?: number;
correctedEmotion?: number;
correctedCommunication?: number;
correctedMarketing?: number;
reason?: string;
};
export type PracticeCalibrationResponse = {
id: number;
sessionId: string;
originalScore: number;
correctedScore: number;
reviewer: string;
reason: string;
status: string;
createTime: string;
};
export type PromptTemplateResponse = {
id: number;
code: string;
name: string;
category: string;
outputFormat: string;
systemPrompt: string;
template: string;
variables: string;
version: string;
enabled: boolean;
description: string;
updateTime: string;
};
export type PromptTemplateRequest = {
code: string;
name: string;
category: string;
outputFormat: string;
systemPrompt: string;
template: string;
variables: string;
version: string;
enabled: boolean;
description: string;
};
export function listPracticeScenarios(params?: { position?: string; scenarioType?: string }): Promise<ApiResult<PracticeScenarioResponse[]>> {
return request({
url: '/api/train/practice/scenarios',
method: 'get',
params
});
}
export function updatePracticeScenarioEnabled(id: string, data: PracticeScenarioEnabledRequest): Promise<ApiResult<null>> {
return request({
url: `/api/train/practice/scenarios/${id}/enabled`,
method: 'patch',
data
});
}
export function getPracticePrepCard(id: string): Promise<ApiResult<PracticePrepCardResponse>> {
return request({
url: `/api/train/practice/scenarios/${id}/prep-card`,
method: 'get'
});
}
export function startPracticeSession(data: PracticeStartRequest): Promise<ApiResult<PracticeStartResponse>> {
return request({
url: '/api/train/practice/start',
@@ -96,3 +207,44 @@ export function finishPracticeSession(data: PracticeFinishRequest): Promise<ApiR
headers: { repeatSubmit: false }
});
}
export function calibratePracticeSession(sessionId: string, data: PracticeCalibrationRequest): Promise<ApiResult<PracticeCalibrationResponse>> {
return request({
url: `/api/train/practice/sessions/${sessionId}/calibration`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function exportPracticePilotCsv(): Promise<Blob> {
return request({
url: '/api/train/practice/export',
method: 'get',
responseType: 'blob'
}) as unknown as Promise<Blob>;
}
export function listPromptTemplates(params?: { category?: string; code?: string; enabled?: boolean }): Promise<ApiResult<PromptTemplateResponse[]>> {
return request({
url: '/api/aihr/prompt-templates',
method: 'get',
params
});
}
export function createPromptTemplate(data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
return request({
url: '/api/aihr/prompt-templates',
method: 'post',
data
});
}
export function updatePromptTemplate(id: number, data: PromptTemplateRequest): Promise<ApiResult<PromptTemplateResponse>> {
return request({
url: `/api/aihr/prompt-templates/${id}`,
method: 'put',
data
});
}
+20 -1
View File
@@ -36,6 +36,16 @@ export type SopSearchResponse = {
snippets: SopSnippet[];
training: string[];
records: SopRecord[];
keyPoints?: string[];
cautions?: string[];
reviewId?: number;
};
export type SopReviewResponse = {
id: number;
usable: boolean | null;
status: string;
reviewedAt: string;
};
export type SopUploadResponse = {
@@ -49,7 +59,7 @@ export type SopUploadResponse = {
snippets: SopSnippet[];
};
export function searchKnowledgeSop(data: { queryText: string; category: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
export function searchKnowledgeSop(data: { queryText: string; category: string; position?: string; source?: string; limit: number }): Promise<ApiResult<SopSearchResponse>> {
return request({
url: '/api/knowledge/search',
method: 'post',
@@ -68,6 +78,15 @@ export function uploadKnowledgeDoc(data: FormData): Promise<ApiResult<SopUploadR
});
}
export function reviewKnowledgeSop(id: number, data: { usable: boolean; reviewer?: string; note?: string }): Promise<ApiResult<SopReviewResponse>> {
return request({
url: `/api/knowledge/reviews/${id}`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export type UploadEnqueueResponse = {
itemId: number;
batchId: string;
+53 -1
View File
@@ -50,6 +50,13 @@
<strong>AI 回答</strong>
<p>{{ answerText }}</p>
<span>引用:{{ referenceText }}</span>
<div class="review-row">
<small>{{ reviewMessage }}</small>
<div>
<el-button size="small" type="success" :loading="reviewSaving" :disabled="!canReview" @click="markReview(true)">可用</el-button>
<el-button size="small" :loading="reviewSaving" :disabled="!canReview" @click="markReview(false)">不可用</el-button>
</div>
</div>
</div>
<div v-else class="empty-state">点击检索后展示命中文档、原文片段和带引用的 AI 回答。</div>
<div v-if="searched" class="doc-list">
@@ -105,7 +112,7 @@ import { ElMessage } from 'element-plus';
import type { UploadFile } from 'element-plus';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
import { reviewKnowledgeSop, searchKnowledgeSop, uploadKnowledgeDoc, type SopRecord, type SopSearchResponse } from '@/api/aihr/sop';
const router = useRouter();
@@ -203,6 +210,8 @@ const apiMode = ref<'seed' | 'api' | 'fallback'>('seed');
const apiResult = ref<SopSearchResponse | null>(null);
const uploading = ref(false);
const uploadedRecords = ref<SopRecord[]>([]);
const reviewSaving = ref(false);
const reviewStatus = ref('');
const activeSeed = computed(() => sopSeeds[selectedCategory.value]);
const answerText = computed(() => apiResult.value?.answer ?? activeSeed.value.answer);
@@ -220,6 +229,12 @@ const flowStatus = computed(() => {
if (searched.value) return { label: `已命中 ${snippets.value.length} 段`, type: 'pill-primary' };
return { label: '可引用 SOP 内容', type: 'pill-success' };
});
const canReview = computed(() => apiMode.value === 'api' && Boolean(apiResult.value?.reviewId));
const reviewMessage = computed(() => {
if (reviewStatus.value) return reviewStatus.value;
if (canReview.value) return `待评审样本 #${apiResult.value?.reviewId}`;
return '后端检索后可评审';
});
const seedRecords = [
{
@@ -257,10 +272,12 @@ function selectCategory(item: Category) {
trainingGenerated.value = false;
apiMode.value = 'seed';
apiResult.value = null;
reviewStatus.value = '';
}
async function runSearch() {
trainingGenerated.value = false;
reviewStatus.value = '';
try {
const { data } = await searchKnowledgeSop({
queryText: queryText.value,
@@ -279,6 +296,28 @@ async function runSearch() {
}
}
async function markReview(usable: boolean) {
const reviewId = apiResult.value?.reviewId;
if (!reviewId) {
ElMessage.warning('请先完成一次后端 SOP 检索');
return;
}
reviewSaving.value = true;
try {
const { data } = await reviewKnowledgeSop(reviewId, {
usable,
reviewer: '管理端评审',
note: usable ? '回答可用于试点问答' : '回答需补充或修正'
});
reviewStatus.value = `${data.status}:${data.usable ? '可用' : '不可用'} ${data.reviewedAt}`;
ElMessage.success('SOP 问答评审已记录');
} catch {
ElMessage.error('SOP 问答评审失败');
} finally {
reviewSaving.value = false;
}
}
async function handleDocSelected(uploadFile: UploadFile) {
const raw = uploadFile.raw;
if (!raw) return;
@@ -442,6 +481,19 @@ async function generateTraining() {
}
}
.review-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 12px;
small {
color: #657184;
font-size: 12px;
}
}
.empty-state,
.training-card {
padding: 14px;
+615 -9
View File
@@ -8,6 +8,7 @@
<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-button :loading="exportingPilot" @click="exportPilotCsv">导出试点CSV</el-button>
<el-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置演示</el-button>
</div>
@@ -30,7 +31,7 @@
<div class="select-card">
<span>训练场景</span>
<el-select v-model="scenarioId" class="scenario-select" @change="resetPractice">
<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>
@@ -42,6 +43,18 @@
</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>
@@ -50,6 +63,14 @@
<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>
@@ -59,6 +80,16 @@
<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">
@@ -101,8 +132,16 @@
<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>
@@ -129,6 +168,24 @@
<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>
@@ -147,15 +204,130 @@
<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, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Microphone, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router';
import { finishPracticeSession, startPracticeSession, submitPracticeTurn } from '@/api/aihr/practice';
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 { searchKnowledgeSop, type SopSearchResponse } from '@/api/aihr/sop';
type ChatRole = 'customer' | 'trainee' | 'coach';
@@ -175,6 +347,7 @@ type PracticeRound = {
type ScenarioSeed = {
id: string;
name: string;
position?: string;
trainee: string;
customer: string;
project: string;
@@ -193,9 +366,13 @@ type ScenarioSeed = {
summary: string;
};
type PromptTemplateForm = PromptTemplateRequest & {
id: number | null;
};
const router = useRouter();
const scenarios: ScenarioSeed[] = [
const fallbackScenarios: ScenarioSeed[] = [
{
id: 'complaint-water',
name: '客服应对投诉',
@@ -268,7 +445,8 @@ const baseRecords = [
{ time: '07-02 08:47', trainee: '王五', scene: '突发事件处理', score: 72, status: '进行中', summary: '多方协调表达不够清晰,建议继续一轮。' }
];
const scenarioId = ref(scenarios[0].id);
const scenarios = ref<ScenarioSeed[]>([...fallbackScenarios]);
const scenarioId = ref(fallbackScenarios[0].id);
const started = ref(false);
const finished = ref(false);
const scored = ref(false);
@@ -282,12 +460,43 @@ 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([...baseRecords]);
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 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.find((scenario) => scenario.id === scenarioId.value) ?? scenarios[0]);
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);
@@ -304,6 +513,15 @@ const trustScore = computed(() => {
}
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' };
@@ -341,6 +559,67 @@ const scenarioProfile = computed(() => [
{ label: '学员', value: selectedScenario.value.trainee }
]);
const mapApiScenario = (item: PracticeScenarioResponse): ScenarioSeed => ({
id: item.id,
name: item.name,
position: item.position,
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 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 });
@@ -360,10 +639,21 @@ const resetPractice = () => {
apiRewrite.value = '';
apiSummary.value = '';
apiTrust.value = null;
apiEmotion.value = null;
sopHelp.value = null;
sopHelpMessage.value = '';
calibrationScore.value = 0;
calibrationReason.value = '管理端人工校准';
calibrationMessage.value = '';
conversation.value = [];
records.value = [...baseRecords];
};
const handleScenarioChange = () => {
resetPractice();
void loadPrepCard();
};
const startPractice = async () => {
resetPractice();
const fallbackCustomer = selectedScenario.value.rounds[0].customer;
@@ -376,6 +666,7 @@ const startPractice = async () => {
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 {
@@ -408,8 +699,9 @@ const continueRound = async () => {
roundIndex: activeRound.value,
traineeText
});
appendTurn('coach', data.coachHint || round.coachHint);
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;
@@ -434,6 +726,29 @@ const continueRound = async () => {
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',
@@ -470,11 +785,167 @@ const finishScore = async () => {
}
scored.value = true;
calibrationScore.value = Math.max(0, Math.min(100, Math.round(Number(displayTotal.value) || 0)));
records.value = [nextRecord, ...baseRecords];
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 () => {
exportingPilot.value = true;
try {
const data = await exportPracticePilotCsv();
FileSaver.saveAs(new Blob([data], { type: 'text/csv;charset=utf-8' }), 'aihr-practice-pilot.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();
});
</script>
<style lang="scss" scoped>
@@ -613,10 +1084,14 @@ const finishScore = async () => {
.select-card,
.field-item,
.rubric-card,
.reply-box,
.coach-card,
.calibration-card,
.trust-card,
.goal-card {
.goal-card,
.prep-card,
.help-card {
padding: 12px;
border: 1px solid #e5eaf3;
border-radius: 8px;
@@ -657,8 +1132,47 @@ const finishScore = async () => {
}
}
.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 {
.goal-card,
.prep-card,
.help-card {
margin-top: 12px;
p {
@@ -666,6 +1180,13 @@ const finishScore = async () => {
color: #46566e;
line-height: 1.6;
}
small {
display: block;
margin-top: 4px;
color: #7a8597;
font-size: 12px;
}
}
.trust-card div:first-child {
@@ -692,6 +1213,10 @@ const finishScore = async () => {
}
}
.emotion-card .meter i {
background: linear-gradient(90deg, #31b367, #ffb020, #f65f61);
}
.chat-list {
display: grid;
gap: 10px;
@@ -818,6 +1343,23 @@ const finishScore = async () => {
}
}
.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;
@@ -857,10 +1399,65 @@ const finishScore = async () => {
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) {
@@ -877,6 +1474,15 @@ const finishScore = async () => {
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;
}