chore: initialize wygj monorepo
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user