368 lines
9.7 KiB
Vue
368 lines
9.7 KiB
Vue
<template>
|
|
<div class="review-page">
|
|
<section class="page-head">
|
|
<div>
|
|
<h1>复盘校准</h1>
|
|
<p>集中处理主管待复盘、逐句标注、复盘建议、学分奖励和下一次训练派发。</p>
|
|
</div>
|
|
<div class="head-actions">
|
|
<el-button :icon="Refresh" :loading="loading" @click="loadReviews">刷新</el-button>
|
|
</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="selectedProjectCode" clearable filterable placeholder="项目范围">
|
|
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
|
|
</el-select>
|
|
<el-select v-model="selectedExtPartyId" clearable filterable placeholder="组织人员">
|
|
<el-option
|
|
v-for="person in orgPeople"
|
|
:key="person.extPartyId"
|
|
:label="`${person.personName} / ${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">
|
|
<strong>{{ turn.label }}</strong>
|
|
<p>{{ turn.text }}</p>
|
|
</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-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, onMounted, reactive, ref, watch } from 'vue';
|
|
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
|
|
import {
|
|
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 reviewForm = reactive({
|
|
reviewer: '主管',
|
|
advice: '',
|
|
incentivePoint: 10,
|
|
nextScenarioId: '',
|
|
nextReason: ''
|
|
});
|
|
|
|
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
|
|
const visibleReviews = computed(() => reviews.value);
|
|
|
|
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 selectReview = async (row: ReviewRecord) => {
|
|
const result = await getPracticeReview(row.id);
|
|
detail.value = result.data;
|
|
reviewForm.advice = result.data.reviewAdvice || '';
|
|
reviewForm.incentivePoint = result.data.incentivePoint || 10;
|
|
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) {
|
|
detail.value = null;
|
|
return;
|
|
}
|
|
await selectReview(first);
|
|
};
|
|
|
|
const submitReview = async () => {
|
|
if (!detail.value) return;
|
|
saving.value = true;
|
|
try {
|
|
await markPracticeReviewed(detail.value.id, {
|
|
reviewer: reviewForm.reviewer,
|
|
advice: reviewForm.advice,
|
|
incentivePoint: reviewForm.incentivePoint,
|
|
nextScenarioId: reviewForm.nextScenarioId,
|
|
nextReason: reviewForm.nextReason
|
|
});
|
|
ElMessage.success('已完成复盘处理');
|
|
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();
|
|
});
|
|
|
|
onMounted(() => {
|
|
loadScenarios();
|
|
loadOrgSnapshot();
|
|
loadReviews();
|
|
});
|
|
</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;
|
|
}
|
|
}
|
|
|
|
.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;
|
|
}
|
|
}
|
|
|
|
.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 {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|