chore: initialize wygj monorepo

This commit is contained in:
2026-07-03 01:25:10 +08:00
commit 62ffcb6e99
1248 changed files with 137340 additions and 0 deletions
+888
View File
@@ -0,0 +1,888 @@
<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-button @click="router.push('/index')">返回首页</el-button>
<el-button type="danger" :icon="Refresh" @click="resetPractice">重置演示</el-button>
</div>
</section>
<section class="flow-strip">
<button v-for="step in flowSteps" :key="step.title" type="button" class="flow-step" :class="{ active: step.active, done: step.done }">
<span>{{ step.index }}</span>
<strong>{{ step.title }}</strong>
<small>{{ step.desc }}</small>
</button>
</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="resetPractice">
<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="trust-card">
<div>
<span>业主信任度</span>
<strong>{{ trustScore }}%</strong>
</div>
<div class="meter"><i :style="{ width: `${trustScore}%` }"></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>
</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>
<el-button text type="danger" :disabled="finished" @click="fillSeedReply">填入 seed 回复</el-button>
</div>
<el-input
v-model="draftReply"
type="textarea"
:rows="5"
resize="none"
:disabled="finished"
placeholder="输入学员回复,或点击“填入 seed 回复”推进演示"
/>
</div>
<div class="action-row">
<el-button :disabled="finished" @click="continueRound">继续一轮</el-button>
<el-button type="danger" :disabled="!canScore" @click="finishScore">结束并评分</el-button>
</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>
</template>
</article>
</section>
<section class="panel record-panel">
<div class="panel-title">
<el-icon><Tickets /></el-icon>
<h2>最近对练记录</h2>
</div>
<el-table :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>
</div>
</template>
<script setup name="TrainPractice" lang="ts">
import { computed, 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';
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;
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;
};
const router = useRouter();
const scenarios: 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 scenarioId = ref(scenarios[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<'seed' | 'api' | 'fallback'>('seed');
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 conversation = ref<ChatTurn[]>([]);
const records = ref([...baseRecords]);
const selectedScenario = computed(() => scenarios.find((scenario) => scenario.id === scenarioId.value) ?? scenarios[0]);
const completedRounds = computed(() => conversation.value.filter((turn) => turn.role === 'trainee').length);
const canScore = computed(() => finished.value && !scored.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 apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '后端 API seed', className: 'pill-info' };
}
if (apiMode.value === 'fallback') {
return { text: 'API 失败回退', className: 'pill-warning' };
}
return { text: '本地 seed 演示', 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 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 = 'seed';
apiScores.value = null;
apiTotal.value = null;
apiRewrite.value = '';
apiSummary.value = '';
apiTrust.value = null;
conversation.value = [];
records.value = [...baseRecords];
};
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: 'seed'
});
sessionId.value = data.sessionId;
apiMode.value = 'api';
apiTrust.value = data.trust;
appendTurn('customer', data.customerText || fallbackCustomer);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} seed 对练`);
} catch {
sessionId.value = '';
apiMode.value = 'fallback';
appendTurn('customer', fallbackCustomer);
ElMessage.warning('后端对练 API 不可用,已回退本地 seed 对练');
}
started.value = true;
};
const fillSeedReply = () => {
draftReply.value = selectedScenario.value.rounds[activeRound.value].sampleReply;
};
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.coachHint || round.coachHint);
apiTrust.value = data.trust;
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 不可用,已回退本地 seed 推进');
}
}
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 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 不可用,已回退本地 seed 评分');
}
} else {
apiScores.value = null;
apiTotal.value = null;
apiRewrite.value = '';
apiSummary.value = '';
}
scored.value = true;
records.value = [nextRecord, ...baseRecords];
if (apiMode.value !== 'api') {
ElMessage.success('考官评分已生成,并写入本地对练记录');
}
};
</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;
}
.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,
.reply-box,
.coach-card,
.trust-card,
.goal-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;
}
}
.trust-card,
.goal-card {
margin-top: 12px;
p {
margin: 6px 0 0;
color: #46566e;
line-height: 1.6;
}
}
.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);
}
}
.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;
}
}
.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;
}
@media (max-width: 1360px) {
.practice-grid {
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;
}
.score-grid {
grid-template-columns: 1fr;
}
.action-row {
justify-content: flex-start;
}
}
</style>