Files
prop-ai-hr/mobile-uni/src/pages/supervisor/review/index.vue
T

1162 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page review-page">
<view class="subpage-header">
<button class="subpage-back-button" aria-label="返回待复盘" title="返回待复盘" @click="goBack"><uni-icons type="back" size="20" color="#25282d" /></button>
<view class="subpage-heading">
<text class="page-title">复盘详情</text>
<text class="page-subtitle">{{ detail ? `${displayName} · ${detail.scene}` : '训练记录' }}</text>
</view>
<button v-if="loggedIn && reviewId && !accessDenied" class="refresh-icon" :disabled="loading" @click="loadDetail">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-2.64-6.36"/><path d="M21 3v6h-6"/></svg>
</button>
</view>
<view v-if="message && !accessDenied && detail" class="status-message">{{ message }}</view>
<StateBlock
v-if="accessDenied"
type="access"
action-text="切换账号"
secondary-action-text="返回员工端"
@action="switchAccount"
@secondary-action="goToEmployee"
/>
<view v-else-if="!loggedIn" class="panel login-panel">
<view>
<text class="panel-title">需要登录</text>
<text class="muted block">登录后查看并完成复盘。</text>
</view>
<button class="primary-button login-button" @click="goLogin">去登录</button>
</view>
<StateBlock v-else-if="loadFailed" type="error" action-text="重新加载" @action="loadDetail" />
<StateBlock v-else-if="firstLoading" type="loading" title="复盘详情加载中…" />
<template v-else-if="detail">
<view class="panel head-panel">
<view class="head-main">
<view class="head-title-row">
<text class="priority-badge" :class="priority">{{ priorityBadge }}</text>
<text class="head-title">{{ displayName }} · {{ detail.scene }}</text>
</view>
<text class="head-meta">{{ detail.time }} · {{ detail.status }}</text>
<view class="ai-summary">
<view class="ai-summary-label">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9z"/></svg>
<text>AI 摘要</text>
</view>
<text class="ai-summary-text">{{ detail.aiComment || detail.summary }}</text>
</view>
</view>
<view class="head-score">
<text class="head-score-value">{{ detail.score }}</text>
<text class="head-score-unit">分</text>
</view>
</view>
<view v-if="numericScoreItems.length" class="panel">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><rect x="7" y="12" width="3" height="6" rx="1"/><rect x="12" y="8" width="3" height="10" rx="1"/><rect x="17" y="5" width="3" height="13" rx="1"/></svg>
</view>
<text class="panel-title">评分明细</text>
</view>
<view class="score-bars">
<view v-for="item in numericScoreItems" :key="item.label" class="score-bar-row">
<text class="score-bar-label">{{ item.label }}</text>
<view class="score-bar-track">
<view class="score-bar-fill" :style="{ width: `${item.percent}%` }" />
</view>
<text class="score-bar-value">{{ item.text }}</text>
</view>
</view>
</view>
<view v-if="detail.dialogue?.length" class="panel">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</view>
<text class="panel-title">对话与录音</text>
</view>
<view class="dialogue-list">
<view
v-for="(turn, index) in detail.dialogue"
:key="`${turn.role}-${index}`"
class="dialogue-row"
:class="bubbleSide(turn)"
>
<view class="bubble-avatar" :class="turn.role">
<svg v-if="turn.role === 'customer'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a8 8 0 0 1 16 0v1"/></svg>
<svg v-else-if="turn.role === 'trainee'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>
<svg v-else viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9z"/></svg>
</view>
<view class="bubble-body">
<text class="bubble-label">{{ turn.label }}</text>
<view class="bubble" :class="turn.role">
<text class="bubble-text">{{ turn.text }}</text>
</view>
<view v-if="hasAudio(turn)" class="audio-bar">
<audio v-if="audioSrc(turn, index)" class="audio-native" controls :src="audioSrc(turn, index)"></audio>
<button
v-else
class="audio-load"
:disabled="audioLoading[audioKey(index)]"
@click="loadAudio(turn, index)"
>
<view class="audio-play-icon">
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M8 5.5v13l11-6.5z"/></svg>
</view>
<text>{{ audioLoading[audioKey(index)] ? '录音加载中…' : '播放录音' }}</text>
</button>
<text v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-if="detail.annotations?.length || detail.mentorRewrite" class="panel">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><path d="M4 22v-7"/></svg>
</view>
<text class="panel-title">标注与建议</text>
</view>
<view class="annotation-list">
<view v-for="item in detail.annotations" :key="`${item.turnIndex}-${item.issue}`" class="annotation-row">
<view class="annotation-line">
<text class="annotation-tag issue">问题</text>
<text class="annotation-text">{{ item.issue }}</text>
</view>
<view class="annotation-line">
<text class="annotation-tag advice">建议</text>
<text class="annotation-text">{{ item.rewrite }}</text>
</view>
</view>
<view v-if="detail.mentorRewrite" class="annotation-line mentor-line">
<text class="annotation-tag advice">话术</text>
<text class="annotation-text">{{ detail.mentorRewrite }}</text>
</view>
</view>
</view>
<view v-if="editable" class="panel review-form">
<view class="panel-head">
<view class="panel-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>
</view>
<text class="panel-title">主管复盘</text>
</view>
<view class="textarea-wrap">
<textarea
v-model="advice"
class="textarea review-textarea"
maxlength="200"
placeholder="填写复盘建议,可留空直接标记"
/>
<text class="textarea-count">{{ advice.length }}/200</text>
</view>
<view class="point-row">
<text class="point-label">人工贡献分</text>
<input
v-model="incentivePoint"
class="input point-input"
type="number"
maxlength="3"
placeholder="可留空"
/>
</view>
<view class="next-training-section">
<text class="field-label">复盘后专项</text>
<view class="scenario-row">
<button class="scenario-chip" :class="{ active: !nextScenarioId }" @click="clearNextScenario">
暂不派发
</button>
<button
v-for="item in scenarioOptions"
:key="item.id"
class="scenario-chip"
:class="{ active: nextScenarioId === item.id }"
@click="selectNextScenario(item.id)"
>
{{ item.name }}
</button>
</view>
<textarea
v-if="nextScenarioId"
v-model="nextReason"
class="textarea next-reason-input"
maxlength="180"
placeholder="填写下一次训练原因"
/>
</view>
<button class="primary-button submit-button tabbar-safe-action" :disabled="marking" @click="markReviewed">
{{ marking ? '提交中…' : '标记已复盘' }}
</button>
</view>
<view v-else class="panel review-complete">
<text class="complete-title">复盘已完成</text>
<text class="complete-desc">{{ detail.reviewAdvice || '该训练记录已完成主管复盘。' }}</text>
<text v-if="detail.incentivePoint != null" class="complete-point">
人工贡献分 {{ detail.incentivePoint }}
</text>
</view>
</template>
<StateBlock
v-else-if="loggedIn && !loading"
type="empty"
:title="reviewId ? '复盘详情暂不可用' : '缺少有效的复盘ID'"
action-text="返回待复盘"
@action="goToReviews"
/>
<SupervisorTabBar v-if="loggedIn && !accessDenied" active="review" />
</view>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import type { ReviewDetail, ReviewDialogue } from '@/types/api';
import { authenticatedFileUrl, releaseAuthenticatedFileUrl } from '@/services/api';
import { clearAuth, isLoggedIn, rememberLoginRedirect } from '@/services/auth';
import { resetPageScroll } from '@/services/navigation';
import { getPracticeReviewDetail, getPracticeScenarios, getPracticeTeamSnapshot, markPracticeReviewed } from '@/services/practice';
import SupervisorTabBar from '@/components/SupervisorTabBar.vue';
import StateBlock from '@/components/StateBlock.vue';
import {
createAsyncGenerationGuard,
isReviewEditable,
isSupervisorAccessDenied,
supervisorScenarioOptions,
type SupervisorScenarioOption,
type SupervisorScenarioId
} from '@/services/supervisor-view';
const basePath = '/pages/supervisor/review/index';
const reviewId = ref(0);
const detail = ref<ReviewDetail | null>(null);
const advice = ref('');
const incentivePoint = ref('');
const nextScenarioId = ref<SupervisorScenarioId | ''>('');
const scenarioOptions = ref<SupervisorScenarioOption[]>([...supervisorScenarioOptions]);
const nextReason = ref('');
const loggedIn = ref(false);
const accessDenied = ref(false);
const loading = ref(false);
const marking = ref(false);
const message = ref('');
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const audioRequests = createAsyncGenerationGuard();
const memberNames = ref<Record<string, string>>({});
const editable = computed(() => isReviewEditable(detail.value?.status));
const loadFailed = computed(() => !loading.value && !!message.value && !detail.value);
const firstLoading = computed(() => loading.value && !detail.value && !message.value);
const displayName = computed(() => {
const identity = (detail.value?.extPartyId || '').trim();
const mapped = identity ? memberNames.value[identity] : '';
if (mapped) return mapped;
const name = detail.value?.trainee?.trim();
if (name && !/^[??]{3,}/.test(name) && !/^手机用户\d+$/.test(name)) return name;
if (/^1[3-9]\d{9}$/.test(identity)) return `员工·尾号${identity.slice(-4)}`;
return identity ? `员工·${identity.length > 8 ? `末尾${identity.slice(-6)}` : identity}` : '员工';
});
const priority = computed(() => {
const score = detail.value?.score ?? 100;
return score < 60 ? 'high' : score < 80 ? 'medium' : 'low';
});
const priorityBadge = computed(() =>
priority.value === 'high' ? '高' : priority.value === 'medium' ? '中' : '低');
const numericScoreItems = computed(() =>
(detail.value?.scoreItems || []).map((item) => {
const value = typeof item.value === 'number' ? item.value : Number(item.value);
const numeric = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : null;
return {
label: item.label,
percent: numeric ?? 0,
text: numeric == null ? String(item.value ?? '待采集') : `${numeric}分`
};
}));
const bubbleSide = (turn: ReviewDialogue) =>
turn.role === 'trainee' ? 'side-right' : 'side-left';
const audioKey = (index: number) => `${reviewId.value || 'review'}-${index}`;
const inlineAudioSrc = (turn: ReviewDialogue) =>
turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : '';
const hasAudio = (turn: ReviewDialogue) => Boolean(turn.audioOssId || inlineAudioSrc(turn));
const audioSrc = (turn: ReviewDialogue, index: number) =>
audioSources.value[audioKey(index)] || inlineAudioSrc(turn);
const releaseAudioSources = () => {
audioRequests.invalidate();
Object.values(audioSources.value).forEach(releaseAuthenticatedFileUrl);
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const clearDetail = () => {
releaseAudioSources();
detail.value = null;
advice.value = '';
incentivePoint.value = '';
nextScenarioId.value = '';
nextReason.value = '';
};
const refreshAuthState = () => {
loggedIn.value = isLoggedIn();
};
const handleRequestError = (error: unknown, fallback: string) => {
if (isSupervisorAccessDenied(error)) {
clearDetail();
accessDenied.value = true;
message.value = '';
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
message.value = '登录已过期,请重新登录';
return;
}
message.value = error instanceof Error ? error.message : fallback;
};
const loadAudio = async (turn: ReviewDialogue, index: number) => {
const key = audioKey(index);
if (audioSrc(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
const generation = audioRequests.capture();
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const source = await authenticatedFileUrl(`/api/aihr/mobile/oss/${turn.audioOssId}`);
if (!audioRequests.isCurrent(generation)) {
releaseAuthenticatedFileUrl(source);
return;
}
audioSources.value = {
...audioSources.value,
[key]: source
};
} catch (error) {
if (!audioRequests.isCurrent(generation)) return;
if (isSupervisorAccessDenied(error)) {
handleRequestError(error, '录音加载失败');
return;
}
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
message.value = '登录已过期,请重新登录';
return;
}
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
if (error instanceof Error && error.message === '网络请求失败') {
message.value = '录音加载失败,请检查网络后重试';
}
} finally {
if (audioRequests.isCurrent(generation)) {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
}
};
const loadDetail = async () => {
refreshAuthState();
message.value = '';
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
return;
}
if (!reviewId.value) {
clearDetail();
message.value = '缺少有效的复盘ID';
return;
}
if (loading.value) return;
loading.value = true;
releaseAudioSources();
try {
const [nextDetail, snapshot] = await Promise.all([
getPracticeReviewDetail(reviewId.value),
getPracticeTeamSnapshot().catch(() => ({ members: [], records: [], assignments: [], mistakes: [] }))
]);
accessDenied.value = false;
detail.value = nextDetail;
memberNames.value = Object.fromEntries(
snapshot.members
.map((member) => [member.extPartyId, member.name] as const)
.filter(([id, name]) => Boolean(id && name) && !/^[??]{3,}/.test(name))
);
advice.value = detail.value.reviewAdvice || '';
incentivePoint.value = detail.value.incentivePoint == null ? '' : String(detail.value.incentivePoint);
nextScenarioId.value = '';
nextReason.value = '';
} catch (error) {
clearDetail();
handleRequestError(error, '复盘详情加载失败');
} finally {
loading.value = false;
}
};
const loadScenarioOptions = async () => {
try {
const rows = await getPracticeScenarios('生活顾问');
const nextOptions = rows
.filter((item) => item.enabled !== false && item.id && item.name)
.map((item) => ({ id: item.id, name: item.name }));
if (nextOptions.length) {
scenarioOptions.value = nextOptions;
} else {
scenarioOptions.value = [];
}
} catch {
scenarioOptions.value = [...supervisorScenarioOptions];
}
};
const selectNextScenario = (scenarioId: SupervisorScenarioId) => {
nextScenarioId.value = scenarioId;
const scenario = scenarioOptions.value.find((item) => item.id === scenarioId);
nextReason.value = `复盘后建议补练${scenario?.name || '专项训练'}`;
};
const clearNextScenario = () => {
nextScenarioId.value = '';
nextReason.value = '';
};
const markReviewed = async () => {
if (marking.value || !detail.value?.id) return;
if (!editable.value) {
message.value = '该记录已完成复盘,不能重复提交';
return;
}
const pointText = incentivePoint.value.trim();
const point = pointText ? Number(pointText) : undefined;
if (point !== undefined && !Number.isFinite(point)) {
message.value = '贡献分必须是数字';
return;
}
marking.value = true;
message.value = '';
const assignedScenario = nextScenarioId.value
? scenarioOptions.value.find((item) => item.id === nextScenarioId.value)?.name || ''
: '';
try {
await markPracticeReviewed(detail.value.id, {
reviewer: '主管',
advice: advice.value.trim(),
incentivePoint: point,
nextScenarioId: nextScenarioId.value || undefined,
nextReason: nextScenarioId.value ? nextReason.value.trim() : undefined
});
const params = new URLSearchParams({ id: String(detail.value.id) });
if (assignedScenario) params.set('assigned', assignedScenario);
uni.redirectTo({ url: `/pages/supervisor/review/done?${params.toString()}` });
} catch (error) {
handleRequestError(error, '标记失败');
} finally {
marking.value = false;
}
};
const currentPath = () => reviewId.value ? `${basePath}?id=${reviewId.value}` : basePath;
const goLogin = () => {
rememberLoginRedirect(currentPath());
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToEmployee = () => {
uni.switchTab({ url: '/pages/user/today/index' });
};
const switchAccount = () => {
clearAuth();
rememberLoginRedirect(currentPath());
uni.redirectTo({ url: '/pages/auth/login/index' });
};
const goToReviews = () => {
uni.redirectTo({ url: '/pages/supervisor/reviews/index' });
};
const goBack = () => {
if (getCurrentPages().length <= 1) {
goToReviews();
return;
}
uni.navigateBack({ delta: 1, fail: goToReviews });
};
onLoad((query) => {
const value = Number(query?.id);
reviewId.value = Number.isInteger(value) && value > 0 ? value : 0;
});
onShow(() => {
resetPageScroll();
refreshAuthState();
if (!loggedIn.value) {
accessDenied.value = false;
clearDetail();
return;
}
void loadScenarioOptions();
if (!detail.value && !loading.value) void loadDetail();
});
onBeforeUnmount(() => {
audioRequests.dispose();
releaseAudioSources();
});
</script>
<style scoped>
.review-page {
padding: 22px 20px 112px;
background: #f7f7f7;
}
.subpage-header {
align-items: center;
}
.refresh-icon {
display: flex;
width: 34px;
height: 34px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
padding: 0;
border: 0;
border-radius: 50%;
background: #ffffff;
color: #666a72;
box-shadow: 0 2px 8px rgba(24, 28, 36, 0.06);
line-height: 1;
}
.refresh-icon::after {
border: 0;
}
.refresh-icon svg {
width: 17px;
height: 17px;
}
.refresh-icon[disabled] {
opacity: 0.5;
}
.review-page .panel {
border: 0;
border-radius: 20px;
background: #ffffff;
box-shadow: none;
padding: 18px;
}
.login-panel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.login-button {
min-width: 104px;
}
.block {
display: block;
margin-top: 8px;
font-size: 14px;
line-height: 1.5;
}
.panel-head {
display: flex;
align-items: center;
gap: 8px;
}
.panel-icon {
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
color: #e60012;
}
.panel-icon svg {
width: 20px;
height: 20px;
}
.panel-title {
color: #17181c;
font-size: 16px;
font-weight: 900;
line-height: 1.3;
}
/* 头部大卡 */
.head-panel {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
}
.head-main {
min-width: 0;
flex: 1;
}
.head-title-row {
display: flex;
align-items: center;
gap: 8px;
}
.priority-badge {
display: flex;
width: 24px;
height: 24px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 7px;
font-size: 12px;
font-weight: 900;
line-height: 1;
}
.priority-badge.high {
background: #e60012;
color: #ffffff;
}
.priority-badge.medium {
background: #fef0c7;
color: #b45309;
}
.priority-badge.low {
background: #f1f5f9;
color: #64748b;
}
.head-title {
min-width: 0;
overflow: hidden;
color: #25282d;
font-size: 16px;
font-weight: 900;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.head-meta {
display: block;
margin-top: 7px;
color: #8a8d93;
font-size: 12px;
line-height: 1.4;
}
.head-score {
display: flex;
flex: 0 0 auto;
align-items: baseline;
color: #e60012;
line-height: 1;
}
.head-score-value {
font-size: 38px;
font-weight: 900;
}
.head-score-unit {
margin-left: 2px;
font-size: 14px;
font-weight: 800;
}
.ai-summary {
margin-top: 14px;
padding: 12px;
border-radius: 12px;
background: #f8f8f9;
}
.ai-summary-label {
display: flex;
align-items: center;
gap: 5px;
color: #e60012;
font-size: 12px;
font-weight: 800;
line-height: 1;
}
.ai-summary-label svg {
width: 14px;
height: 14px;
}
.ai-summary-text {
display: block;
margin-top: 7px;
color: #3a3d44;
font-size: 13px;
line-height: 1.55;
}
/* 评分明细 */
.score-bars {
margin-top: 14px;
}
.score-bar-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
}
.score-bar-label {
width: 62px;
flex: 0 0 auto;
color: #3a3d44;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.score-bar-track {
overflow: hidden;
height: 8px;
flex: 1;
border-radius: 4px;
background: #f0f1f3;
}
.score-bar-fill {
height: 100%;
border-radius: 4px;
background: #e60012;
}
.score-bar-value {
width: 44px;
flex: 0 0 auto;
color: #e60012;
font-size: 14px;
font-weight: 900;
line-height: 1;
text-align: right;
}
/* 对话气泡 */
.dialogue-list {
margin-top: 14px;
}
.dialogue-row {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 12px;
}
.dialogue-row:first-child {
margin-top: 0;
}
.dialogue-row.side-right {
flex-direction: row-reverse;
}
.bubble-avatar {
display: flex;
width: 30px;
height: 30px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 50%;
}
.bubble-avatar svg {
width: 16px;
height: 16px;
}
.bubble-avatar.customer {
background: #f0f1f3;
color: #666a72;
}
.bubble-avatar.trainee {
background: #fff0f1;
color: #e60012;
}
.bubble-avatar.coach {
background: #fef0c7;
color: #b45309;
}
.bubble-body {
display: flex;
max-width: 78%;
flex-direction: column;
}
.side-right .bubble-body {
align-items: flex-end;
}
.bubble-label {
margin-bottom: 4px;
color: #a0a3aa;
font-size: 11px;
line-height: 1;
}
.bubble {
padding: 10px 12px;
border-radius: 4px 14px 14px;
}
.bubble.customer {
background: #f4f5f7;
}
.bubble.trainee {
border-radius: 14px 4px 14px 14px;
background: #fff0f1;
}
.bubble.coach {
border: 1px solid #fde68a;
border-radius: 14px;
background: #fffbeb;
}
.bubble-text {
color: #25282d;
font-size: 13px;
line-height: 1.55;
}
.audio-bar {
margin-top: 8px;
}
.audio-native {
display: block;
width: 100%;
height: 36px;
}
.audio-load {
display: flex;
min-height: 40px;
align-items: center;
gap: 8px;
margin: 0;
padding: 0 14px 0 6px;
border: 0;
border-radius: 20px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(24, 28, 36, 0.08);
color: #3a3d44;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.audio-load::after {
border: 0;
}
.audio-play-icon {
display: flex;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #e60012;
color: #ffffff;
}
.audio-play-icon svg {
width: 14px;
height: 14px;
}
.audio-error {
display: block;
margin-top: 6px;
color: #e60012;
font-size: 12px;
}
/* 标注与建议 */
.annotation-list {
margin-top: 12px;
}
.annotation-row {
padding: 10px 0;
border-top: 1px solid #f4f5f7;
}
.annotation-row:first-child {
padding-top: 0;
border-top: 0;
}
.annotation-line {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 8px;
}
.annotation-line:first-child {
margin-top: 0;
}
.annotation-tag {
display: flex;
min-width: 34px;
height: 20px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 11px;
font-weight: 900;
line-height: 1;
}
.annotation-tag.issue {
background: #fff1f2;
color: #e60012;
}
.annotation-tag.advice {
background: #f0fdf4;
color: #15803d;
}
.annotation-text {
min-width: 0;
flex: 1;
color: #3a3d44;
font-size: 13px;
line-height: 1.55;
}
.mentor-line {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #eceef1;
}
/* 主管复盘表单 */
.textarea-wrap {
position: relative;
margin-top: 12px;
}
.review-textarea {
width: 100%;
min-height: 92px;
box-sizing: border-box;
padding: 12px 12px 26px;
border: 1px solid #eceef1;
border-radius: 12px;
background: #f8f8f9;
color: #25282d;
font-size: 13px;
line-height: 1.55;
}
.textarea-count {
position: absolute;
right: 10px;
bottom: 8px;
color: #c0c3c9;
font-size: 11px;
line-height: 1;
}
.point-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
}
.point-label {
color: #3a3d44;
font-size: 13px;
font-weight: 700;
}
.point-input {
width: 96px;
min-height: 36px;
padding: 0 12px;
border: 1px solid #eceef1;
border-radius: 10px;
background: #f8f8f9;
color: #25282d;
font-size: 13px;
text-align: center;
}
.next-training-section {
margin-top: 14px;
}
.field-label {
display: block;
color: #3a3d44;
font-size: 13px;
font-weight: 700;
line-height: 1.3;
}
.scenario-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
}
.scenario-chip {
display: flex;
min-height: 34px;
align-items: center;
justify-content: center;
margin: 0;
padding: 0 14px;
border: 1px solid #e3e5e9;
border-radius: 17px;
background: #ffffff;
color: #3a3d44;
font-size: 13px;
font-weight: 700;
line-height: 1;
}
.scenario-chip::after {
border: 0;
}
.scenario-chip.active {
border-color: #e60012;
background: #fff0f1;
color: #e60012;
}
.next-reason-input {
width: 100%;
min-height: 64px;
box-sizing: border-box;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid #eceef1;
border-radius: 12px;
background: #f8f8f9;
color: #25282d;
font-size: 13px;
line-height: 1.5;
}
.submit-button {
display: flex;
width: 100%;
min-height: 48px;
align-items: center;
justify-content: center;
margin: 18px 0 0;
padding: 0;
border: 0;
border-radius: 24px;
background: #e60012;
color: #ffffff;
font-size: 16px;
font-weight: 900;
line-height: 1;
letter-spacing: 1px;
}
.submit-button::after {
border: 0;
}
.submit-button[disabled] {
opacity: 0.6;
}
/* 已完成态 */
.review-complete {
border-left: 4px solid #16a34a;
}
.complete-title,
.complete-desc,
.complete-point {
display: block;
}
.complete-title {
color: #166534;
font-size: 16px;
font-weight: 900;
}
.complete-desc,
.complete-point {
margin-top: 8px;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
</style>