Files
prop-ai-hr/frontend/src/views/train/reviews.vue
T

589 lines
17 KiB
Vue

<template>
<div class="review-page">
<section class="page-head">
<div>
<h1>复盘校准</h1>
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
</div>
<div class="head-actions">
<el-button type="danger" :loading="exporting" @click="exportPilot">导出试点 CSV</el-button>
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
</div>
</section>
<section class="filter-panel panel">
<el-date-picker v-model="startDate" type="date" value-format="YYYY-MM-DD" placeholder="开始日期" />
<el-date-picker v-model="endDate" type="date" value-format="YYYY-MM-DD" placeholder="结束日期" />
<el-select v-model="selectedPosition" placeholder="岗位">
<el-option label="生活顾问" value="生活顾问" />
</el-select>
<el-select v-model="selectedProjectCode" clearable filterable placeholder="组织范围">
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
</el-select>
</section>
<section class="metric-grid">
<div class="metric-card">
<span>校准样本</span><strong>{{ metrics.calibrationCount }} / 20</strong>
</div>
<div class="metric-card">
<span>分档一致率</span><strong>{{ metrics.consistency }}</strong>
</div>
<div class="metric-card">
<span>SOP 可用率</span><strong>{{ metrics.sopUsable }}</strong>
</div>
<div class="metric-card">
<span>严格门禁</span><strong :class="metrics.ready ? 'is-ready' : 'is-pending'">{{ metrics.ready ? '通过' : '待补样本' }}</strong>
</div>
</section>
<section class="review-layout">
<article class="panel list-panel">
<div class="panel-title">
<h2>待复盘记录</h2>
<el-tag type="danger">{{ visibleReviews.length }} 条</el-tag>
</div>
<div class="list-filters">
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName || person.extPartyId} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
</div>
<el-table v-loading="loading" :data="visibleReviews" size="small" highlight-current-row @row-click="selectReview">
<el-table-column prop="trainee" label="学员" width="96" />
<el-table-column prop="scene" label="场景" min-width="150" show-overflow-tooltip />
<el-table-column prop="score" label="得分" width="70" />
<el-table-column prop="status" label="状态" width="92">
<template #default="{ row }">
<el-tag :type="row.status === '待复盘' ? 'warning' : 'success'" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="time" label="时间" width="128" />
</el-table>
</article>
<article class="panel detail-panel">
<template v-if="detail">
<div class="panel-title">
<h2>{{ detail.trainee }} · {{ detail.scene }}</h2>
<el-tag :type="detail.score >= 80 ? 'success' : 'danger'">{{ detail.score }} 分</el-tag>
</div>
<div class="score-grid">
<div v-for="item in detail.scoreItems" :key="item.label" class="score-card">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
<small>{{ item.note }}</small>
</div>
</div>
<div class="dialogue-list">
<div v-for="(turn, index) in detail.dialogue" :key="`${turn.role}-${index}`" class="dialogue-item">
<div class="dialogue-head">
<strong>{{ turn.label }}</strong>
<el-tag v-if="hasAudio(turn)" size="small" type="info">有录音</el-tag>
</div>
<p>{{ turn.text }}</p>
<div v-if="hasAudio(turn)" class="dialogue-audio-row">
<audio v-if="audioSource(turn, index)" controls :src="audioSource(turn, index)" />
<el-button v-else size="small" :loading="audioLoading[audioKey(index)]" @click.stop="loadAudio(turn, index)">加载录音</el-button>
<span v-if="audioErrors[audioKey(index)]" class="audio-error">{{ audioErrors[audioKey(index)] }}</span>
</div>
</div>
</div>
<div class="annotation-list">
<h3>逐句标注</h3>
<el-table :data="detail.annotations" size="small" empty-text="暂无逐句标注">
<el-table-column prop="sourceText" label="原句" min-width="180" show-overflow-tooltip />
<el-table-column prop="issue" label="问题" min-width="160" />
<el-table-column prop="rewrite" label="推荐改写" min-width="220" show-overflow-tooltip />
<el-table-column prop="sopPoint" label="SOP点" min-width="160" />
</el-table>
</div>
<el-form class="review-form" label-position="top">
<el-form-item label="复盘建议">
<el-input v-model="reviewForm.advice" type="textarea" :rows="3" placeholder="写给员工的复盘建议" />
</el-form-item>
<div class="form-grid">
<el-form-item label="贡献学分">
<el-input-number v-model="reviewForm.incentivePoint" :min="0" :max="100" />
</el-form-item>
<el-form-item label="人工校准总分">
<el-input-number v-model="reviewForm.correctedTotal" :min="0" :max="100" />
</el-form-item>
</div>
<el-form-item label="校准原因">
<el-input v-model="reviewForm.calibrationReason" placeholder="例如:AI 对安抚情绪权重偏低" />
</el-form-item>
<div class="form-grid">
<el-form-item label="下次训练">
<el-select v-model="reviewForm.nextScenarioId" clearable placeholder="不派发">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
</el-form-item>
</div>
<el-form-item label="派发原因">
<el-input v-model="reviewForm.nextReason" placeholder="例如:低分维度定向再练" />
</el-form-item>
<div class="form-actions">
<el-button type="danger" :loading="saving" @click="submitReview">标记已复盘</el-button>
</div>
</el-form>
</template>
<el-empty v-else description="选择一条待复盘记录" />
</article>
</section>
</div>
</template>
<script setup name="TrainReviews" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import { calibratePracticeSession, exportPracticePilotCsv, listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import { resolvePilotDateWindow } from './pilot-export';
import {
getPracticeAudioBlobUrl,
getOrgSnapshot,
getPracticeReview,
listPracticeReviews,
markPracticeReviewed,
type OrgSnapshotResponse,
type ReviewDetail,
type ReviewRecord
} from '@/api/aihr/mobile-admin';
const loading = ref(false);
const saving = ref(false);
const reviews = ref<ReviewRecord[]>([]);
const detail = ref<ReviewDetail | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const selectedProjectCode = ref('');
const selectedExtPartyId = ref('');
const selectedPosition = ref('生活顾问');
const exporting = ref(false);
const startDate = ref('2026-07-01');
const endDate = ref('2026-07-31');
const metrics = reactive({ calibrationCount: 0, consistency: '--', sopUsable: '--', ready: false });
const audioSources = ref<Record<string, string>>({});
const audioLoading = ref<Record<string, boolean>>({});
const audioErrors = ref<Record<string, string>>({});
const pilotDateWindow = computed(() => resolvePilotDateWindow([startDate.value, endDate.value]));
const reviewForm = reactive({
reviewer: '主管',
advice: '',
incentivePoint: 10,
correctedTotal: 0,
calibrationReason: '',
nextScenarioId: '',
nextReason: ''
});
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const visibleReviews = computed(() => reviews.value);
type ReviewDialogueTurn = ReviewDetail['dialogue'][number];
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioUrl || turn.audioOssId);
const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || (!turn.audioOssId ? turn.audioUrl || '' : '');
const needsCalibration = () =>
Boolean(detail.value?.sessionId) && (reviewForm.correctedTotal !== detail.value.score || Boolean(reviewForm.calibrationReason.trim()));
const releaseAudioSources = () => {
Object.values(audioSources.value).forEach((url) => URL.revokeObjectURL(url));
audioSources.value = {};
audioLoading.value = {};
audioErrors.value = {};
};
const loadAudio = async (turn: ReviewDialogueTurn, index: number) => {
const key = audioKey(index);
if (audioSource(turn, index) || !turn.audioOssId || audioLoading.value[key]) return;
audioLoading.value = { ...audioLoading.value, [key]: true };
audioErrors.value = { ...audioErrors.value, [key]: '' };
try {
const url = await getPracticeAudioBlobUrl(turn.audioOssId);
audioSources.value = { ...audioSources.value, [key]: url };
} catch {
audioErrors.value = { ...audioErrors.value, [key]: '录音加载失败' };
} finally {
audioLoading.value = { ...audioLoading.value, [key]: false };
}
};
const loadScenarios = async () => {
try {
const result = await listPracticeScenarios();
scenarios.value = result.data || [];
} catch (error) {
scenarios.value = [];
}
};
const loadOrgSnapshot = async () => {
try {
const result = await getOrgSnapshot({
status: 'active',
projectCode: selectedProjectCode.value,
pageNum: 1,
pageSize: 500
});
orgSnapshot.value = result.data;
} catch (error) {
orgSnapshot.value = null;
}
};
const loadReviews = async () => {
loading.value = true;
try {
const result = await listPracticeReviews({
projectCode: selectedProjectCode.value,
extPartyId: selectedExtPartyId.value,
limit: 50
});
reviews.value = result.data || [];
await selectFirstVisibleReview();
} catch (error) {
ElMessage.error('待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const loadMetrics = async () => {
const dateWindow = pilotDateWindow.value;
if (!dateWindow) {
metrics.calibrationCount = 0;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
return;
}
try {
const blob = await exportPracticePilotCsv(dateWindow);
const csv = await blob.text();
const value = (key: string) => csv.match(new RegExp(`(?:^|\\n)"?${key}"?,"?([^"\\n]*)`))?.[1] || '';
metrics.calibrationCount = Number(value('calibration_sample_count')) || 0;
metrics.consistency = value('ai_human_band_consistency_rate') || '--';
metrics.sopUsable = value('sop_answer_usable_rate') || '--';
metrics.ready = value('pilot_metric_ready') === 'true';
} catch {
metrics.calibrationCount = reviews.value.length;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
}
};
const exportPilot = async () => {
const dateWindow = pilotDateWindow.value;
if (!dateWindow) {
ElMessage.warning('请选择有效的试点起止日期');
return;
}
exporting.value = true;
try {
const blob = await exportPracticePilotCsv(dateWindow);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `aihr-pilot-${dateWindow.startDate}-${dateWindow.endDate}.csv`;
link.click();
URL.revokeObjectURL(url);
ElMessage.success('试点 CSV 已导出');
} catch {
ElMessage.error('试点 CSV 导出失败');
} finally {
exporting.value = false;
}
};
const selectReview = async (row: ReviewRecord) => {
releaseAudioSources();
const result = await getPracticeReview(row.id);
detail.value = result.data;
reviewForm.advice = result.data.reviewAdvice || '';
reviewForm.incentivePoint = result.data.incentivePoint || 10;
reviewForm.correctedTotal = result.data.score;
reviewForm.calibrationReason = '';
reviewForm.nextScenarioId = '';
reviewForm.nextReason = '';
};
const selectFirstVisibleReview = async () => {
if (visibleReviews.value.some((item) => item.id === detail.value?.id)) return;
const first = visibleReviews.value[0];
if (!first) {
releaseAudioSources();
detail.value = null;
return;
}
await selectReview(first);
};
const submitReview = async () => {
if (!detail.value) return;
saving.value = true;
try {
if (needsCalibration()) {
await calibratePracticeSession(detail.value.sessionId, {
reviewer: reviewForm.reviewer,
correctedTotal: reviewForm.correctedTotal,
reason: reviewForm.calibrationReason
});
}
await markPracticeReviewed(detail.value.id, {
reviewer: reviewForm.reviewer,
advice: reviewForm.advice,
incentivePoint: reviewForm.incentivePoint,
nextScenarioId: reviewForm.nextScenarioId,
nextReason: reviewForm.nextReason
});
ElMessage.success('已完成复盘处理');
releaseAudioSources();
detail.value = null;
await loadReviews();
} catch (error) {
ElMessage.error('复盘提交失败');
} finally {
saving.value = false;
}
};
watch(selectedProjectCode, async () => {
selectedExtPartyId.value = '';
await loadOrgSnapshot();
await loadReviews();
});
watch(selectedExtPartyId, () => {
loadReviews();
});
watch([startDate, endDate], () => {
loadMetrics();
});
onMounted(() => {
loadScenarios();
loadOrgSnapshot();
loadReviews();
loadMetrics();
});
onBeforeUnmount(releaseAudioSources);
</script>
<style scoped lang="scss">
.review-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.score-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 20px 22px;
h1 {
margin: 0 0 8px;
font-size: 24px;
}
p {
margin: 0;
color: #667085;
}
}
.head-actions {
display: flex;
align-items: center;
gap: 10px;
}
.filter-panel {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
}
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
padding: 16px;
span {
display: block;
color: #667085;
font-size: 13px;
}
strong {
display: block;
margin-top: 8px;
color: #16864b;
font-size: 24px;
}
.is-pending {
color: #d97706;
font-size: 18px;
}
}
.review-layout {
display: grid;
grid-template-columns: minmax(420px, 0.9fr) minmax(0, 1.4fr);
gap: 16px;
margin-top: 16px;
}
.panel {
padding: 18px;
}
.panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
}
.list-filters {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.score-grid,
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.score-card {
padding: 12px;
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 6px 0;
font-size: 20px;
}
}
.dialogue-list {
display: grid;
gap: 10px;
margin-top: 16px;
}
.dialogue-item {
border-radius: 8px;
background: #f8fafc;
padding: 12px;
p {
margin: 6px 0 0;
color: #475467;
}
}
.dialogue-head,
.dialogue-audio-row {
display: flex;
align-items: center;
gap: 10px;
}
.dialogue-audio-row {
margin-top: 10px;
audio {
width: min(360px, 100%);
}
}
.audio-error {
color: #d92d20;
font-size: 12px;
}
.annotation-list {
margin-top: 18px;
h3 {
margin: 0 0 10px;
font-size: 15px;
}
}
.review-form {
margin-top: 18px;
}
.form-actions {
display: flex;
justify-content: flex-end;
}
@media (max-width: 1080px) {
.review-layout,
.list-filters,
.score-grid,
.form-grid,
.filter-panel,
.metric-grid {
grid-template-columns: 1fr;
}
.head-actions {
align-items: stretch;
flex-direction: column;
}
}
</style>