1557 lines
44 KiB
Vue
1557 lines
44 KiB
Vue
<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>
|