feat(aihr): finalize practice growth and access boundaries

This commit is contained in:
2026-07-25 00:42:19 +08:00
parent a90f40e4d8
commit c9d2012ec1
87 changed files with 6804 additions and 2538 deletions
+1 -240
View File
@@ -1,5 +1,4 @@
import axios from 'axios';
import request, { globalHeaders, reconcileTenantContextBlobResponse } from '@/utils/request';
import request from '@/utils/request';
type ApiResult<T> = {
code: number;
@@ -7,123 +6,6 @@ type ApiResult<T> = {
data: T;
};
export type ReviewRecord = {
id: number;
sessionId: string;
time: string;
trainee: string;
scene: string;
score: number;
status: string;
summary: string;
extPartyId: string;
};
export type ReviewDetail = ReviewRecord & {
mentorRewrite: string;
aiComment: string;
reviewAdvice: string;
incentivePoint: number | null;
scoreItems: Array<{ label: string; value: number | string; note: string }>;
dialogue: Array<{ role: string; label: string; text: string; audioUrl?: string; audioOssId?: number | string }>;
annotations: Array<{ turnIndex: number; sourceText: string; issue: string; rewrite: string; sopPoint: string }>;
};
export type ReviewAdviceRequest = {
reviewer?: string;
advice?: string;
incentivePoint?: number;
nextScenarioId?: string;
nextReason?: string;
};
export type CompetencyProfile = {
score: number;
completed: number;
pendingReview: number;
trainingMinutes: number;
contribution: number;
assessmentScore: number;
aiLevel: string;
dimensions: Array<{ label: string; value: number | string | null; note: string }>;
growthPath: Array<{ level: string; progress: number; status: string; evidence: string }>;
};
export type PracticeAlert = {
type: string;
title: string;
desc: string;
severity: string;
extPartyId: string;
};
export type PracticeAssignment = {
id: number;
extPartyId: string;
traineeName: string;
scenarioId: string;
scenarioName: string;
source: string;
reason: string;
status: string;
createTime: string;
question?: string;
referenceAnswer?: string;
userAnswer?: string;
feedback?: string;
correct?: boolean;
};
export type PracticeAssignmentRequest = {
extPartyId: string;
traineeName?: string;
scenarioId: string;
scenarioName?: string;
source: string;
reason: string;
};
export type PracticeDifficulty = {
scenarioId: string;
scenarioName: string;
cause: string;
count: number;
affectedPeople: number;
averageScore: number;
latestTime: string;
position?: string;
assignmentStatus?: string;
attempts?: number;
mistakeCount?: number;
difficultyScore?: number;
};
export type PracticeAssignmentBatchResponse = {
requestId: string;
total: number;
created: number;
duplicate: number;
failed: number;
results: Array<{ extPartyId: string; traineeName: string; status: 'CREATED' | 'DUPLICATE' | 'FAILED'; message: string }>;
};
export type PromotionEvidence = {
extPartyId: string;
currentLevel: string;
assessmentScore: number;
trainingMinutes: number;
contribution: number;
completed: number;
pendingReview: number;
dimensions: Array<{ label: string; value: number | string | null; note: string }>;
latestRecords: ReviewRecord[];
assignments: PracticeAssignment[];
evidenceItems: string[];
humanAssessmentScore?: number | null;
finalAdoptedScore?: number | null;
calibrationCount?: number;
};
export type OrgSyncRequest = {
dryRun?: boolean;
replaceExisting?: boolean;
@@ -188,127 +70,6 @@ export type OrgSnapshotResponse = {
rows: OrgPersonRow[];
};
export type ReviewListParams = {
projectCode?: string;
extPartyId?: string;
limit?: number;
};
export function listPracticeReviews(params?: ReviewListParams): Promise<ApiResult<ReviewRecord[]>> {
return request({
url: '/api/aihr/mobile/practice/reviews',
method: 'get',
params
});
}
export function getPracticeReview(id: number): Promise<ApiResult<ReviewDetail>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}`,
method: 'get'
});
}
export async function getPracticeAudioBlobUrl(ossId: number | string): Promise<string> {
try {
const response = await axios({
method: 'get',
url: `${import.meta.env.VITE_APP_BASE_API}/api/aihr/mobile/oss/${ossId}`,
responseType: 'blob',
headers: globalHeaders()
});
const contentType = String(response.headers['content-type'] || '');
if (contentType.includes('application/json') && (await reconcileTenantContextBlobResponse(response.data))) {
throw new Error('租户上下文已变更,请刷新后重试');
}
return URL.createObjectURL(response.data);
} catch (error) {
await reconcileTenantContextBlobResponse(axios.isAxiosError(error) ? error.response?.data : undefined);
throw error;
}
}
export function markPracticeReviewed(id: number, data: ReviewAdviceRequest): Promise<ApiResult<null>> {
return request({
url: `/api/aihr/mobile/practice/reviews/${id}/reviewed`,
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function getCompetencyProfile(extPartyId?: string): Promise<ApiResult<CompetencyProfile>> {
return request({
url: '/api/aihr/mobile/profile',
method: 'get',
params: { extPartyId }
});
}
export function getPromotionEvidence(extPartyId?: string): Promise<ApiResult<PromotionEvidence>> {
return request({
url: '/api/aihr/mobile/profile/promotion-evidence',
method: 'get',
params: { extPartyId }
});
}
export function listPracticeAlerts(): Promise<ApiResult<PracticeAlert[]>> {
return request({
url: '/api/aihr/mobile/practice/alerts',
method: 'get'
});
}
export function listPracticeAssignments(extPartyId?: string): Promise<ApiResult<PracticeAssignment[]>> {
return request({
url: '/api/aihr/mobile/practice/assignments',
method: 'get',
params: { extPartyId }
});
}
export function createPracticeAssignment(data: PracticeAssignmentRequest): Promise<ApiResult<PracticeAssignment>> {
return request({
url: '/api/aihr/mobile/practice/assignments',
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function listPracticeDifficulties(params?: { windowDays?: number; position?: string }): Promise<ApiResult<PracticeDifficulty[]>> {
type RawDifficulty = Omit<PracticeDifficulty, 'cause' | 'count'> & { attempts: number; mistakeCount: number };
return request<unknown, ApiResult<RawDifficulty[]>>({
url: '/api/aihr/mobile/practice/difficulties',
method: 'get',
params
}).then((result) => ({
...result,
data: (result.data || []).map((item) => ({
...item,
cause: item.mistakeCount >= Math.max(2, Math.ceil(item.attempts / 2)) ? '高频错题或低分' : '近期低分记录',
count: item.mistakeCount
}))
}));
}
export function assignPracticeDifficulty(data: {
scenarioId: string;
cause: string;
dueDate: string;
requestId: string;
windowDays: number;
position?: string;
}): Promise<ApiResult<PracticeAssignmentBatchResponse>> {
return request({
url: '/api/aihr/mobile/practice/difficulties/assign',
method: 'post',
data,
headers: { repeatSubmit: false }
});
}
export function syncOrgSnapshot(data: OrgSyncRequest): Promise<ApiResult<OrgSyncResponse>> {
return request({
url: '/api/aihr/org/sync',
+69
View File
@@ -18,6 +18,17 @@ export type PracticeScenarioResponse = {
position: string;
scenarioType: string;
projectType: string;
growthLevel: string;
competencyCode: string;
collaborationPositions: string;
reviewStatus: string;
riskLevel: '待评估' | '常规' | '高风险';
requiresDualReview: boolean;
curriculumVersion: string;
reviewer?: string;
reviewedTime?: string;
secondReviewer?: string;
secondReviewedTime?: string;
difficulty: number;
customerPersona: string;
openingText: string;
@@ -58,6 +69,11 @@ export type PracticeScenarioSaveRequest = {
position: string;
scenarioType: string;
projectType: string;
growthLevel: string;
competencyCode: string;
collaborationPositions: string;
riskLevel: '待评估' | '常规' | '高风险';
curriculumVersion: string;
difficulty: number;
customerPersona: string;
openingText: string;
@@ -73,6 +89,35 @@ export type PracticeScenarioSaveRequest = {
rounds: PracticeScenarioRound[];
};
export type PracticeScenarioReviewStatusRequest = {
reviewStatus: '草稿' | '待业务审核' | '已发布' | '已下线';
};
export type PracticeScenarioReviewStatusResponse = {
reviewStatus: '草稿' | '待业务审核' | '已发布' | '已下线';
awaitingSecondReview: boolean;
message: string;
};
export type PracticeCurriculumCapability = {
position: string;
growthLevel: string;
code: string;
name: string;
};
export type PracticeCurriculumMatrixRow = {
position: string;
growthLevel: string;
competencyCode: string;
competencyName: string;
publishedCount: number;
pendingReviewCount: number;
draftCount: number;
offlineCount: number;
coverageStatus: string;
};
export type PracticeRubricDimension = {
code: string;
name: string;
@@ -289,6 +334,30 @@ export function updatePracticeScenarioEnabled(id: string, data: PracticeScenario
});
}
export function updatePracticeScenarioReviewStatus(
id: string,
data: PracticeScenarioReviewStatusRequest
): Promise<ApiResult<PracticeScenarioReviewStatusResponse>> {
return request({
url: `/api/train/practice/scenarios/${encodeURIComponent(id)}/review-status`,
method: 'patch',
data
});
}
export function listPracticeCurriculumCapabilities(params?: {
position?: string;
growthLevel?: string;
}): Promise<ApiResult<PracticeCurriculumCapability[]>> {
return request({ url: '/api/train/practice/curriculum/capabilities', method: 'get', params });
}
export function listPracticeCurriculumMatrix(params?: {
position?: string;
}): Promise<ApiResult<PracticeCurriculumMatrixRow[]>> {
return request({ url: '/api/train/practice/curriculum/matrix', method: 'get', params });
}
export function savePracticeScenario(data: PracticeScenarioSaveRequest): Promise<ApiResult<PracticeScenarioResponse>> {
return request({
url: '/api/train/practice/scenarios',
+9 -16
View File
@@ -141,16 +141,17 @@ export const constantRoutes: RouteRecordRaw[] = [
},
{
path: 'reviews',
component: () => import('@/views/train/reviews.vue'),
name: 'TrainReviews',
meta: { title: '复盘校准', icon: 'finish' }
redirect: '/train/tasks',
hidden: true,
meta: { title: '复盘校准', icon: 'finish', activeMenu: '/train/tasks' }
},
{
path: 'difficulties',
component: () => import('@/views/train/difficulties.vue'),
name: 'TrainDifficulties',
redirect: '/train/tasks',
hidden: true,
meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/scenarios' }
meta: { title: '常见难题', icon: 'chart', activeMenu: '/train/tasks' }
}
]
},
@@ -285,19 +286,11 @@ export const constantRoutes: RouteRecordRaw[] = [
]
},
{
path: '/competency',
component: Layout,
path: '/competency/growth',
name: 'CompetencyGrowth',
redirect: '/train/tasks',
hidden: true,
redirect: '/competency/growth',
meta: { roles: operationsConsoleRoles },
children: [
{
path: 'growth',
component: () => import('@/views/competency/growth.vue'),
name: 'CompetencyGrowth',
meta: { title: '成长激励', icon: 'chart' }
}
]
meta: { title: '成长激励', icon: 'chart', roles: operationsConsoleRoles }
},
{
path: '/user',
-509
View File
@@ -1,509 +0,0 @@
<template>
<div class="growth-page">
<section class="page-head">
<div>
<h1>成长激励</h1>
<p>展示能力画像、初/中/高级证据包、预警、待训任务和待配置的学分口径。</p>
</div>
<div class="head-actions">
<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="extPartyId" clearable filterable allow-create default-first-option placeholder="组织人员或 extPartyId">
<el-option
v-for="person in orgPeople"
:key="person.extPartyId"
:label="`${person.personName || person.extPartyId} / ${person.positionName}`"
:value="person.extPartyId"
/>
</el-select>
<el-button :icon="Download" :disabled="!evidence" @click="exportEvidenceCsv">导出证据包</el-button>
<el-button type="danger" :icon="Refresh" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</section>
<section class="metric-grid">
<article class="metric-card">
<span>AI训练综合分</span>
<strong>{{ profile?.assessmentScore ?? '-' }}</strong>
<small>训练参考分,不等于 BRD 五维绩效总分</small>
</article>
<article class="metric-card">
<span>训练画像等级(参考)</span>
<strong>{{ evidence?.currentLevel || profile?.aiLevel || '-' }}</strong>
<small>需 HR/主管复核,不作为晋升结论</small>
</article>
<article class="metric-card">
<span>训练时长</span>
<strong>{{ profile?.trainingMinutes ?? 0 }} 分钟</strong>
<small>来自移动端训练记录</small>
</article>
<article class="metric-card">
<span>人工贡献分</span>
<strong>{{ profile?.contribution ?? 0 }}</strong>
<small>仅计入主管明确确认的有效贡献分,训练次数单独统计</small>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>能力维度</h2>
</div>
<div class="dimension-list">
<div v-for="item in profile?.dimensions || []" :key="item.label" class="dimension-item">
<div>
<strong>{{ item.label }}</strong>
<span>{{ item.note }}</span>
</div>
<el-progress
v-if="item.value !== null && item.value !== undefined"
:percentage="Number(item.value) || 0"
:stroke-width="10"
color="#e60012"
/>
<span v-else class="dimension-unavailable">待采集</span>
</div>
</div>
</article>
<article class="panel">
<div class="panel-title">
<h2>成长参考路径</h2>
</div>
<div class="stage-list">
<div v-for="stage in profile?.growthPath || []" :key="stage.level" class="stage-item">
<div class="stage-head">
<strong>{{ stage.level }}</strong>
<el-tag :type="stage.status === '已达成' ? 'success' : 'info'">{{ stage.status }}</el-tag>
</div>
<el-progress :percentage="stage.progress" :stroke-width="9" />
<small>{{ stage.evidence }}</small>
</div>
</div>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>晋升证据包(待复核)</h2>
</div>
<ul class="evidence-list">
<li v-for="item in evidence?.evidenceItems || []" :key="item">{{ item }}</li>
</ul>
<div class="evidence-score-row">
<span>人工校准分:{{ evidence?.humanAssessmentScore ?? '待采集' }}</span>
<span>最终采用分:{{ evidence?.finalAdoptedScore ?? evidence?.assessmentScore ?? '-' }}</span>
<span>校准样本:{{ evidence?.calibrationCount ?? 0 }}</span>
</div>
</article>
<article class="panel">
<div class="panel-title">
<h2>预警</h2>
</div>
<div class="alert-list">
<div v-for="item in alerts" :key="item.title" class="alert-item" :class="`severity-${item.severity}`">
<strong>{{ item.title }}</strong>
<span>{{ item.desc }}</span>
</div>
</div>
</article>
</section>
<section class="main-grid">
<article class="panel">
<div class="panel-title">
<h2>待训任务</h2>
<el-button type="danger" plain @click="createManualAssignment">派发专项</el-button>
</div>
<div class="assignment-form">
<el-select v-model="assignmentScenarioId" filterable placeholder="选择专项场景">
<el-option v-for="scenario in scenarios" :key="scenario.id" :label="scenario.name" :value="scenario.id" />
</el-select>
<el-input v-model="assignmentReason" placeholder="派发原因" />
</div>
<el-table :data="assignments" size="small">
<el-table-column prop="traineeName" label="学员" width="110" />
<el-table-column prop="scenarioName" label="训练" min-width="160" />
<el-table-column prop="source" label="来源" width="92" />
<el-table-column prop="status" label="状态" width="92" />
<el-table-column prop="reason" label="原因" min-width="180" show-overflow-tooltip />
</el-table>
</article>
<article class="panel">
<div class="panel-title">
<h2>学分规则(待 HR 配置)</h2>
<el-tag type="info" effect="plain">规划参考,当前不生效</el-tag>
</div>
<el-table :data="rules" size="small">
<el-table-column prop="source" label="来源" width="130" />
<el-table-column prop="points" label="分值" width="90" />
<el-table-column prop="guard" label="防刷分口径" min-width="220" />
</el-table>
</article>
</section>
</div>
</template>
<script setup name="CompetencyGrowth" lang="ts">
import { Download, Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import FileSaver from 'file-saver';
import { computed, onMounted, ref, watch } from 'vue';
import { listPracticeScenarios, type PracticeScenarioResponse } from '@/api/aihr/practice';
import {
createPracticeAssignment,
getCompetencyProfile,
getOrgSnapshot,
getPromotionEvidence,
listPracticeAlerts,
listPracticeAssignments,
type CompetencyProfile,
type OrgSnapshotResponse,
type PracticeAlert,
type PracticeAssignment,
type PromotionEvidence
} from '@/api/aihr/mobile-admin';
const extPartyId = ref('');
const selectedProjectCode = ref('');
const loading = ref(false);
const profile = ref<CompetencyProfile | null>(null);
const evidence = ref<PromotionEvidence | null>(null);
const alerts = ref<PracticeAlert[]>([]);
const assignments = ref<PracticeAssignment[]>([]);
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
const scenarios = ref<PracticeScenarioResponse[]>([]);
const assignmentScenarioId = ref('');
const assignmentReason = ref('HR在成长激励页派发专项训练');
const rules = [
{ source: '训练完成', points: '待配置', guard: '只认完成记录,不按点击或打开计分' },
{ source: '主管复盘采纳', points: '待配置', guard: '由主管复盘时人工确认' },
{ source: '案例被采纳', points: '待配置', guard: '必须审核入库并署名' },
{ source: '课程考试通过', points: '待配置', guard: '阶段二课程考试上线后启用' }
];
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const selectedPerson = computed(() => orgPeople.value.find((item) => item.extPartyId === extPartyId.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 loadAll = async () => {
loading.value = true;
const party = extPartyId.value.trim();
try {
const [profileResult, evidenceResult, alertsResult, assignmentsResult] = await Promise.all([
getCompetencyProfile(party),
getPromotionEvidence(party),
listPracticeAlerts(),
listPracticeAssignments(party)
]);
profile.value = profileResult.data;
evidence.value = evidenceResult.data;
alerts.value = alertsResult.data || [];
assignments.value = assignmentsResult.data || [];
} catch (error) {
ElMessage.error('成长数据加载失败');
} finally {
loading.value = false;
}
};
const loadScenarios = async () => {
try {
scenarios.value = ((await listPracticeScenarios()).data || []).filter((item) => item.enabled);
assignmentScenarioId.value = assignmentScenarioId.value || scenarios.value[0]?.id || '';
} catch (error) {
scenarios.value = [];
}
};
const createManualAssignment = async () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId || '';
if (!party) {
ElMessage.warning('请先输入员工手机号或 extPartyId');
return;
}
const scenario = scenarios.value.find((item) => item.id === assignmentScenarioId.value);
if (!scenario) {
ElMessage.warning('请先选择专项场景');
return;
}
try {
await createPracticeAssignment({
extPartyId: party,
traineeName: selectedPerson.value?.personName || party,
scenarioId: scenario.id,
scenarioName: scenario.name,
source: 'manual',
reason: assignmentReason.value.trim() || 'HR在成长激励页派发专项训练'
});
ElMessage.success('专项训练已派发');
await loadAll();
} catch (error) {
ElMessage.error('专项训练派发失败');
}
};
const csvCell = (value: unknown) => `"${String(value ?? '').replaceAll('"', '""')}"`;
const appendCsvRow = (rows: string[], ...values: unknown[]) => {
rows.push(values.map(csvCell).join(','));
};
const exportEvidenceCsv = () => {
const party = extPartyId.value.trim() || evidence.value?.extPartyId?.trim() || 'all';
if (!evidence.value) {
ElMessage.warning('证据包数据加载后才能导出');
return;
}
const rows: string[] = [];
const personName = selectedPerson.value?.personName || (party === 'all' ? '全部员工' : party);
appendCsvRow(rows, 'section', 'field', 'value');
appendCsvRow(rows, 'summary', 'employee', personName);
appendCsvRow(rows, 'summary', 'ext_party_id', party);
appendCsvRow(rows, 'summary', 'current_level', evidence.value.currentLevel);
appendCsvRow(rows, 'summary', 'assessment_score', evidence.value.assessmentScore);
appendCsvRow(rows, 'summary', 'human_assessment_score', evidence.value.humanAssessmentScore ?? '待采集');
appendCsvRow(rows, 'summary', 'final_adopted_score', evidence.value.finalAdoptedScore ?? evidence.value.assessmentScore);
appendCsvRow(rows, 'summary', 'calibration_count', evidence.value.calibrationCount ?? 0);
appendCsvRow(rows, 'summary', 'training_minutes', evidence.value.trainingMinutes);
appendCsvRow(rows, 'summary', 'contribution', evidence.value.contribution);
appendCsvRow(rows, 'summary', 'completed', evidence.value.completed);
appendCsvRow(rows, 'summary', 'pending_review', evidence.value.pendingReview);
profile.value?.growthPath.forEach((stage) =>
appendCsvRow(rows, 'growth_path', `${stage.level} ${stage.status}`, `${stage.progress}% ${stage.evidence}`)
);
evidence.value.dimensions.forEach((item) => {
const value = item.value === null || item.value === undefined ? '待采集' : item.value;
appendCsvRow(rows, 'dimension', item.label, `${value} ${item.note}`);
});
evidence.value.evidenceItems.forEach((item, index) => appendCsvRow(rows, 'evidence_item', index + 1, item));
evidence.value.latestRecords.forEach((item) =>
appendCsvRow(rows, 'latest_record', item.scene, `${item.score} ${item.status} ${item.time} ${item.summary}`)
);
evidence.value.assignments.forEach((item) =>
appendCsvRow(rows, 'assignment', item.scenarioName, `${item.status} ${item.createTime} ${item.reason}`)
);
FileSaver.saveAs(new Blob([`\uFEFF${rows.join('\n')}`], { type: 'text/csv;charset=utf-8' }), `aihr-promotion-evidence-${party}.csv`);
ElMessage.success('成长证据包 CSV 已导出');
};
watch(selectedProjectCode, async () => {
extPartyId.value = '';
await loadOrgSnapshot();
});
watch(extPartyId, () => {
loadAll();
});
onMounted(() => {
loadOrgSnapshot();
loadScenarios();
loadAll();
});
</script>
<style scoped lang="scss">
.growth-page {
min-height: 100%;
padding: 24px;
background: #f5f7fb;
color: #1f2937;
}
.page-head,
.panel,
.metric-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.page-head {
display: flex;
align-items: center;
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: grid;
grid-template-columns: 220px 280px auto auto;
gap: 10px;
}
.metric-grid,
.main-grid {
display: grid;
gap: 16px;
margin-top: 16px;
}
.metric-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.main-grid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.metric-card,
.panel {
padding: 18px;
}
.metric-card {
span,
small {
display: block;
color: #667085;
}
strong {
display: block;
margin: 8px 0;
font-size: 24px;
}
}
.panel-title,
.stage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title {
margin-bottom: 14px;
h2 {
margin: 0;
font-size: 17px;
}
}
.assignment-form {
display: grid;
grid-template-columns: minmax(180px, 0.75fr) minmax(220px, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.dimension-list,
.stage-list,
.alert-list {
display: grid;
gap: 14px;
}
.dimension-item strong,
.stage-item strong {
display: block;
margin-bottom: 4px;
}
.dimension-item span,
.stage-item small {
display: block;
margin-bottom: 8px;
color: #667085;
}
.dimension-item .dimension-unavailable {
margin-bottom: 8px;
color: #98a2b3;
font-weight: 600;
}
.evidence-list {
margin: 0;
padding-left: 18px;
li {
margin-bottom: 10px;
color: #475467;
}
}
.evidence-score-row {
display: flex;
flex-wrap: wrap;
gap: 8px 18px;
margin-top: 14px;
color: #667085;
font-size: 13px;
}
.alert-item {
border-radius: 8px;
padding: 12px;
background: #f8fafc;
strong,
span {
display: block;
}
span {
margin-top: 4px;
color: #667085;
}
}
.severity-high {
background: #fff1f3;
}
.severity-medium {
background: #fff7ed;
}
@media (max-width: 1080px) {
.page-head,
.main-grid,
.assignment-form,
.metric-grid {
grid-template-columns: 1fr;
}
.page-head {
display: block;
}
.head-actions {
display: grid;
grid-template-columns: 1fr;
margin-top: 14px;
}
}
</style>
+2 -2
View File
@@ -122,8 +122,8 @@
<article class="panel training-panel">
<div class="panel-title table-title">
<h2>最近训练与复盘</h2>
<el-button link type="primary" @click="go('/train/reviews')"
>查看全部 <el-icon><ArrowRight /></el-icon
<el-button link type="primary" @click="go('/train/tasks')"
>任务运营 <el-icon><ArrowRight /></el-icon
></el-button>
</div>
<div class="record-table-wrap" tabindex="0" role="region" aria-label="最近训练与复盘表,可横向滚动">
-173
View File
@@ -1,173 +0,0 @@
<template>
<div class="difficulty-page">
<section class="page-head">
<div>
<h1>常见难题</h1>
<p>按岗位和项目权限聚合低分、红线与错题原因,并一键生成定向专项。</p>
</div>
<el-button :icon="Refresh" :loading="loading" @click="load">刷新</el-button>
</section>
<section class="filter-panel panel">
<el-select v-model="position" clearable placeholder="全部岗位" @change="load">
<el-option v-for="item in positions" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="windowDays" placeholder="统计窗口" @change="load">
<el-option label="近7天" :value="7" />
<el-option label="近30天" :value="30" />
<el-option label="近90天" :value="90" />
</el-select>
<el-select v-model="sortKey" placeholder="排序方式">
<el-option label="出现次数" value="count" />
<el-option label="涉及人数" value="people" />
<el-option label="低分优先" value="score" />
<el-option label="最近发生" value="latest" />
</el-select>
</section>
<section class="metric-grid">
<div class="metric-card"><span>难题组合</span><strong>{{ rows.length }}</strong></div>
<div class="metric-card"><span>累计出现</span><strong>{{ totalCount }}</strong></div>
<div class="metric-card"><span>单项最多涉及</span><strong>{{ totalPeople }}</strong></div>
<div class="metric-card"><span>平均得分</span><strong>{{ averageScore }}</strong></div>
</section>
<section class="panel table-panel">
<el-table v-loading="loading" :data="sortedRows" empty-text="当前项目范围内暂无常见难题">
<el-table-column prop="scenarioName" label="场景" min-width="180" show-overflow-tooltip />
<el-table-column prop="position" label="岗位" width="110" />
<el-table-column prop="cause" label="归因" min-width="150" show-overflow-tooltip />
<el-table-column prop="count" label="出现次数" width="96" sortable />
<el-table-column prop="affectedPeople" label="涉及人数" width="96" sortable />
<el-table-column prop="averageScore" label="平均分" width="90" sortable>
<template #default="{ row }"><span :class="row.averageScore < 80 ? 'low-score' : ''">{{ row.averageScore }}</span></template>
</el-table-column>
<el-table-column prop="latestTime" label="最近发生" width="170" />
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ row }"><el-button type="danger" link @click="openAssign(row)">一键派专项</el-button></template>
</el-table-column>
</el-table>
</section>
<el-dialog v-model="assignVisible" title="一键生成定向专项" width="520px">
<div v-if="activeRow" class="assign-summary">
<strong>{{ activeRow.scenarioName }}</strong>
<span>{{ activeRow.cause }} · {{ activeRow.affectedPeople }} 人涉及</span>
<el-alert type="info" :closable="false" title="人员范围由服务端按当前主管项目和该难题低分记录确定,前端不能越权指定。" />
<el-date-picker v-model="dueDate" value-format="YYYY-MM-DD" type="date" :disabled-date="disablePast" placeholder="截止日期" />
<div class="quick-dates">
<el-button v-for="item in quickDates" :key="item.days" size="small" @click="dueDate = dateAfter(item.days)">{{ item.label }}</el-button>
</div>
</div>
<template #footer>
<el-button @click="assignVisible = false">取消</el-button>
<el-button type="danger" :loading="assigning" :disabled="!dueDate" @click="submitAssign">确认派发</el-button>
</template>
</el-dialog>
<el-dialog v-model="resultVisible" title="派发结果" width="620px">
<el-result v-if="batchResult" :icon="batchResult.failed ? 'warning' : 'success'" :title="`成功 ${batchResult.created + batchResult.duplicate} 人,失败 ${batchResult.failed} 人`" />
<el-table v-if="batchResult?.results?.length" :data="batchResult.results" max-height="320">
<el-table-column prop="traineeName" label="员工" min-width="120" />
<el-table-column prop="status" label="状态" width="100" />
<el-table-column prop="message" label="说明" min-width="180" />
</el-table>
</el-dialog>
</div>
</template>
<script setup name="TrainDifficulties" lang="ts">
import { Refresh } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import { computed, onMounted, ref } from 'vue';
import {
assignPracticeDifficulty,
listPracticeDifficulties,
type PracticeAssignmentBatchResponse,
type PracticeDifficulty
} from '@/api/aihr/mobile-admin';
const positions = ['生活顾问', '客服', '保安', '保洁', '保修'];
const quickDates = [{ label: '当天', days: 0 }, { label: '明天', days: 1 }, { label: '3天内', days: 3 }, { label: '7天内', days: 7 }];
const rows = ref<PracticeDifficulty[]>([]);
const loading = ref(false);
const assigning = ref(false);
const position = ref('');
const windowDays = ref(30);
const sortKey = ref<'count' | 'people' | 'score' | 'latest'>('count');
const assignVisible = ref(false);
const resultVisible = ref(false);
const activeRow = ref<PracticeDifficulty | null>(null);
const dueDate = ref('');
const batchResult = ref<PracticeAssignmentBatchResponse | null>(null);
const sortedRows = computed(() => [...rows.value].sort((a, b) => {
if (sortKey.value === 'people') return b.affectedPeople - a.affectedPeople;
if (sortKey.value === 'score') return a.averageScore - b.averageScore;
if (sortKey.value === 'latest') return String(b.latestTime).localeCompare(String(a.latestTime));
return b.count - a.count;
}));
const totalCount = computed(() => rows.value.reduce((sum, item) => sum + item.count, 0));
const totalPeople = computed(() => rows.value.length ? Math.max(0, ...rows.value.map((item) => item.affectedPeople)) : 0);
const averageScore = computed(() => rows.value.length
? Math.round(rows.value.reduce((sum, item) => sum + item.averageScore * item.count, 0) / Math.max(1, totalCount.value))
: '--');
const formatDate = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
const dateAfter = (days: number) => { const date = new Date(); date.setHours(12, 0, 0, 0); date.setDate(date.getDate() + days); return formatDate(date); };
const disablePast = (date: Date) => date.getTime() < new Date(new Date().setHours(0, 0, 0, 0)).getTime();
const load = async () => {
loading.value = true;
try {
const response = await listPracticeDifficulties({ windowDays: windowDays.value, position: position.value || undefined });
rows.value = response.data || [];
} catch {
rows.value = [];
ElMessage.error('常见难题读取失败');
} finally { loading.value = false; }
};
const openAssign = (row: PracticeDifficulty) => { activeRow.value = row; dueDate.value = dateAfter(1); batchResult.value = null; assignVisible.value = true; };
const submitAssign = async () => {
if (!activeRow.value || !dueDate.value || assigning.value) return;
assigning.value = true;
try {
const response = await assignPracticeDifficulty({
scenarioId: activeRow.value.scenarioId,
cause: activeRow.value.cause,
dueDate: dueDate.value,
requestId: `difficulty:${activeRow.value.scenarioId}:${Date.now()}`,
windowDays: windowDays.value,
position: activeRow.value.position
});
batchResult.value = response.data;
assignVisible.value = false;
resultVisible.value = true;
await load();
} catch { ElMessage.error('专项派发失败'); }
finally { assigning.value = false; }
};
onMounted(load);
</script>
<style scoped>
.difficulty-page { padding: 24px; color: #172033; }
.page-head, .filter-panel, .metric-grid { display: flex; gap: 16px; align-items: center; }
.page-head { justify-content: space-between; margin-bottom: 18px; }
.page-head h1 { margin: 0; font-size: 28px; }
.page-head p { margin: 8px 0 0; color: #667085; }
.panel { border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; box-shadow: 0 4px 18px rgba(16,24,40,.05); }
.filter-panel { padding: 16px; }
.filter-panel :deep(.el-select) { width: 200px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 16px 0; }
.metric-card { display: grid; gap: 8px; padding: 18px; border: 1px solid #e4e7ec; border-radius: 12px; background: #fff; }
.metric-card span { color: #667085; }
.metric-card strong { font-size: 28px; }
.table-panel { overflow: hidden; padding: 8px; }
.low-score { color: #d92d20; font-weight: 800; }
.assign-summary { display: grid; gap: 14px; }
.assign-summary > span { color: #667085; }
.quick-dates { display: flex; gap: 8px; }
@media (max-width: 1100px) {
.difficulty-page { padding: 16px; }
.filter-panel { flex-wrap: wrap; }
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
+17 -122
View File
@@ -8,29 +8,17 @@
<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"
type="info"
:closable="false"
show-icon
title="训练记录接口暂不可用,当前显示演示记录,不代表真实训练数据。"
title="此页仅用于不绑定员工身份的运营预览;真实训练历史、主管复盘、团队难题和试点数据均不在此页展示。"
/>
<section class="flow-strip">
@@ -182,7 +170,7 @@
<div class="calibration-card">
<div class="calibration-head">
<strong>人工校准</strong>
<small>{{ calibrationMessage || (canCalibrate ? '用于 M5 分档一致率样本' : '真实 API 评分后可记录') }}</small>
<small>{{ calibrationMessage || (canCalibrate ? '仅作运营预览校准,不计入员工成长或正式试点' : '完成后端运营预览评分即可记录') }}</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="校准原因" />
@@ -194,21 +182,6 @@
</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>
@@ -311,14 +284,12 @@
<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 { 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,
@@ -331,9 +302,7 @@ import {
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';
@@ -459,12 +428,6 @@ const fallbackScenarios: ScenarioSeed[] = [
}
];
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);
@@ -474,7 +437,7 @@ const showGoal = ref(false);
const activeRound = ref(0);
const draftReply = ref('');
const sessionId = ref('');
const apiMode = ref<'local' | 'api' | 'fallback'>('local');
const apiMode = ref<'local' | 'preview' | 'fallback'>('local');
const apiScores = ref<ScenarioSeed['scores'] | null>(null);
const apiTotal = ref<number | null>(null);
const apiRewrite = ref('');
@@ -487,9 +450,6 @@ 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);
@@ -497,9 +457,6 @@ 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,
@@ -521,7 +478,7 @@ const selectedScenario = computed(
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 canCalibrate = computed(() => scored.value && apiMode.value === 'preview' && 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);
@@ -548,8 +505,8 @@ const emotionScore = computed(() => {
return Math.max(45, 76 - completedRounds.value * 8);
});
const apiPill = computed(() => {
if (apiMode.value === 'api') {
return { text: '真实 API', className: 'pill-info' };
if (apiMode.value === 'preview') {
return { text: '运营预览 API', className: 'pill-info' };
}
if (apiMode.value === 'fallback') {
return { text: '本地剧本', className: 'pill-warning' };
@@ -563,7 +520,7 @@ const flowPill = computed(() => {
if (!scored.value) {
return { text: '对练进行中', className: 'pill-warning' };
}
return { text: '已完成闭环', className: 'pill-success' };
return { text: '已完成运营预览', className: 'pill-success' };
});
const flowSteps = computed(() => [
{ index: '1', title: '选场景', desc: selectedScenario.value.name, active: !started.value, done: started.value || scored.value },
@@ -657,27 +614,6 @@ const loadScenarioOptions = async () => {
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 });
@@ -721,11 +657,11 @@ const startPractice = async () => {
mode: 'real'
});
sessionId.value = data.sessionId;
apiMode.value = 'api';
apiMode.value = 'preview';
apiTrust.value = data.trust;
apiEmotion.value = data.emotion;
appendTurn('customer', data.customerText || fallbackCustomer);
ElMessage.success(`已通过后端 API 进入 ${selectedScenario.value.name} 对练`);
ElMessage.success(`已进入 ${selectedScenario.value.name} 的后端运营预览;不会使用或生成员工身份`);
} catch {
sessionId.value = '';
apiMode.value = 'fallback';
@@ -745,7 +681,7 @@ const continueRound = async () => {
appendTurn('trainee', traineeText);
draftReply.value = '';
if (apiMode.value === 'api' && sessionId.value) {
if (apiMode.value === 'preview' && sessionId.value) {
try {
const { data } = await submitPracticeTurn({
sessionId: sessionId.value,
@@ -803,16 +739,7 @@ const askSopHelp = async () => {
};
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) {
if (apiMode.value === 'preview' && sessionId.value) {
try {
const { data } = await finishPracticeSession({ sessionId: sessionId.value });
apiScores.value = data.score;
@@ -820,8 +747,7 @@ const finishScore = async () => {
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 返回');
ElMessage.success('运营预览评分已由后端 API 返回,不计入员工训练记录');
} catch {
apiMode.value = 'fallback';
apiScores.value = null;
@@ -839,15 +765,14 @@ const finishScore = async () => {
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('考官评分已生成,并写入本地对练记录');
if (apiMode.value !== 'preview') {
ElMessage.success('考官评分已生成,仅保留在当前页面的本地演示记录');
}
};
const saveCalibration = async () => {
if (!canCalibrate.value) {
ElMessage.warning('请先完成一次真实 API 对练评分');
ElMessage.warning('请先完成一次后端运营预览评分');
return;
}
calibrationSaving.value = true;
@@ -866,25 +791,6 @@ const saveCalibration = async () => {
}
};
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 || '',
@@ -1004,7 +910,6 @@ const togglePromptTemplate = async (template: PromptTemplateResponse) => {
onMounted(() => {
void loadScenarioOptions();
void loadPromptTemplates();
void loadRecentRecords();
});
</script>
@@ -1044,10 +949,6 @@ onMounted(() => {
flex-wrap: wrap;
}
.pilot-date-range {
width: 250px;
}
.flow-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1451,12 +1352,6 @@ onMounted(() => {
background: #edf4ff;
}
.record-panel {
:deep(.el-table__inner-wrapper::before) {
display: none;
}
}
.record-table {
border: 1px solid #edf0f5;
border-radius: 8px;
-610
View File
@@ -1,610 +0,0 @@
<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>
<el-alert
v-if="reviewsFallback || metricsFallback"
class="data-warning"
type="warning"
:closable="false"
show-icon
title="部分真实数据接口暂不可用;当前页面不展示演示复盘记录或猜测性试点指标。"
/>
<section class="metric-grid">
<div class="metric-card">
<span>校准样本</span><strong>{{ calibrationMetric }}</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" placeholder="可选,不填不计分" />
</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 reviewsFallback = ref(false);
const metricsFallback = ref(false);
const startDate = ref('2026-07-01');
const endDate = ref('2026-07-31');
const metrics = reactive({ calibrationCount: null as number | null, 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: null as number | null,
correctedTotal: 0,
calibrationReason: '',
nextScenarioId: '',
nextReason: ''
});
const orgPeople = computed(() => orgSnapshot.value?.rows || []);
const visibleReviews = computed(() => reviews.value);
const calibrationMetric = computed(() => (metrics.calibrationCount === null ? '--' : `${metrics.calibrationCount} / 20`));
type ReviewDialogueTurn = ReviewDetail['dialogue'][number];
const audioKey = (index: number) => `${detail.value?.id || 'review'}-${index}`;
const inlineAudioSource = (turn: ReviewDialogueTurn) =>
turn.audioUrl?.startsWith('data:audio/') ? turn.audioUrl : '';
const hasAudio = (turn: ReviewDialogueTurn) => Boolean(turn.audioOssId || inlineAudioSource(turn));
const audioSource = (turn: ReviewDialogueTurn, index: number) => audioSources.value[audioKey(index)] || inlineAudioSource(turn);
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 || [];
reviewsFallback.value = false;
await selectFirstVisibleReview();
} catch (error) {
reviews.value = [];
detail.value = null;
releaseAudioSources();
reviewsFallback.value = true;
ElMessage.error('待复盘列表加载失败');
} finally {
loading.value = false;
}
};
const loadMetrics = async () => {
const dateWindow = pilotDateWindow.value;
if (!dateWindow) {
metrics.calibrationCount = null;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
metricsFallback.value = 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';
metricsFallback.value = false;
} catch {
metrics.calibrationCount = null;
metrics.consistency = '--';
metrics.sopUsable = '--';
metrics.ready = false;
metricsFallback.value = true;
}
};
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 ?? null;
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 ?? undefined,
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>
+277 -10
View File
@@ -3,7 +3,7 @@
<section class="page-head">
<div>
<h1>场景库管理</h1>
<p>五岗位训练内容 · 场景回合、版本、Rubric 与启停维护</p>
<p>住宅物业五岗位成长目录 · 场景回合、能力项、审核、版本与 Rubric 统一维护</p>
</div>
<el-button type="danger" @click="openCreate">新增场景</el-button>
</section>
@@ -15,6 +15,12 @@
<el-select v-model="filters.scenarioType" placeholder="场景分类" clearable>
<el-option v-for="item in scenarioTypes" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.growthLevel" placeholder="成长层级" clearable>
<el-option v-for="item in growthLevels" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.reviewStatus" placeholder="审核状态" clearable>
<el-option v-for="item in reviewStatuses" :key="item" :label="item" :value="item" />
</el-select>
<el-select v-model="filters.enabled" placeholder="状态" clearable>
<el-option label="启用" :value="true" />
<el-option label="停用" :value="false" />
@@ -23,18 +29,55 @@
<el-button type="danger" @click="queryScenes">查询</el-button>
</section>
<section class="matrix-card">
<div class="matrix-head">
<div>
<h2>矩阵缺口</h2>
<p>按住宅物业 × 岗位 × 层级 × 能力项核对。只有“已发布且启用”才计为可训练内容。</p>
</div>
<el-button text type="danger" @click="loadMatrix">刷新矩阵</el-button>
</div>
<el-table :data="visibleMatrixRows" size="small" max-height="240">
<el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="growthLevel" label="层级" width="90" />
<el-table-column prop="competencyName" label="能力项" min-width="180" />
<el-table-column prop="publishedCount" label="已发布" width="82" />
<el-table-column prop="pendingReviewCount" label="待审核" width="82" />
<el-table-column prop="draftCount" label="草稿" width="70" />
<el-table-column label="覆盖状态" width="100">
<template #default="{ row }">
<el-tag :type="matrixTagType(row.coverageStatus)" size="small">{{ row.coverageStatus }}</el-tag>
</template>
</el-table-column>
</el-table>
</section>
<section class="table-card">
<el-table v-loading="loading" :data="pagedScenarios" row-key="id">
<el-table-column prop="position" label="岗位" width="100" />
<el-table-column prop="name" label="场景名称" min-width="180" />
<el-table-column prop="scenarioType" label="分类" min-width="120" />
<el-table-column prop="growthLevel" label="层级" width="84" />
<el-table-column label="能力项" min-width="160">
<template #default="{ row }">{{ capabilityName(row) }}</template>
</el-table-column>
<el-table-column prop="difficulty" label="难度" width="92">
<template #default="{ row }">{{ difficultyLabel(row.difficulty) }}</template>
</el-table-column>
<el-table-column label="Rubric" min-width="180">
<template #default="{ row }"> {{ row.rubric?.name || '未配置' }} {{ row.rubric?.version || '' }} </template>
</el-table-column>
<el-table-column label="状态" width="88">
<el-table-column label="审核" width="98">
<template #default="{ row }">
<el-tag :type="reviewTagType(row.reviewStatus)" size="small">{{ row.reviewStatus || '草稿' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务审核" min-width="210" show-overflow-tooltip>
<template #default="{ row }">
{{ reviewTrail(row) }}
</template>
</el-table-column>
<el-table-column label="启停" width="72">
<template #default="{ row }">
<span :class="['status-text', row.enabled === false ? 'disabled' : 'enabled']">
{{ row.enabled === false ? '停用' : '启用' }}
@@ -42,9 +85,11 @@
</template>
</el-table-column>
<el-table-column prop="updateTime" label="更新时间" width="150" />
<el-table-column label="操作" width="140" fixed="right">
<el-table-column label="操作" width="286" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="advanceReviewStatus(row)">{{ reviewActionLabel(row) }}</el-button>
<el-button v-if="row.reviewStatus === '待业务审核'" link type="info" @click="returnToDraft(row)">退回草稿</el-button>
<el-button link type="danger" @click="toggleEnabled(row)">
{{ row.enabled === false ? '启用' : '停用' }}
</el-button>
@@ -89,12 +134,60 @@
<el-form-item label="场景分类"><el-input v-model="form.scenarioType" /></el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
<el-form-item label="成长层级">
<el-select v-model="form.growthLevel" class="full-control">
<el-option v-for="item in growthLevels" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="项目类型"><el-input v-model="form.projectType" /></el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="能力项">
<el-select v-model="form.competencyCode" class="full-control" placeholder="选择本岗位、层级的能力项">
<el-option
v-if="form.competencyCode && !currentCapabilityOptions.some((item) => item.code === form.competencyCode)"
:label="`${form.competencyCode}(待归类)`"
:value="form.competencyCode"
/>
<el-option v-for="item in currentCapabilityOptions" :key="item.code" :label="item.name" :value="item.code" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="难度"><el-input-number v-model="form.difficulty" :min="1" :max="5" class="full-control" /></el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="目录版本"><el-input v-model="form.curriculumVersion" /></el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="8"><el-form-item label="项目类型"><el-input v-model="form.projectType" disabled /></el-form-item></el-col>
<el-col :span="8">
<el-form-item label="内容风险" required>
<el-select v-model="form.riskLevel" class="full-control">
<el-option label="待评估" value="待评估" />
<el-option label="常规" value="常规" />
<el-option label="高风险(需两位不同审核人)" value="高风险" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8"><el-form-item label="协同岗位(可选)"><el-input v-model="form.collaborationPositions" placeholder="例如:保安、客服" /></el-form-item></el-col>
</el-row>
<el-alert
:closable="false"
type="info"
show-icon
title="保存后为草稿;已发布内容再次编辑会退回“待业务审核”。发布时服务端会校验住宅、五岗位、能力项、风险级别、业务来源/SOP、服务红线、回合与五维 Rubric;收费、安全、投诉等受控内容会自动升级为高风险,需两位不同审核人。"
/>
<el-form-item label="业主人设"><el-input v-model="form.customerPersona" /></el-form-item>
<el-form-item label="开场话术" required><el-input v-model="form.openingText" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="业务来源 / SOP 引用" required>
<el-input v-model="form.sopRefs" type="textarea" :rows="2" placeholder="例如:SOP-LG-001 / 对应制度条款" />
</el-form-item>
<el-form-item label="服务红线(不应这样做)" required>
<el-input v-model="form.irritants" type="textarea" :rows="2" placeholder="例如:不得推诿、不得承诺无法兑现的时限" />
</el-form-item>
<el-form-item label="成功标准"><el-input v-model="form.successCriteria" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="训练目标" required><el-input v-model="form.goal" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="教练策略"><el-input v-model="form.strategy" /></el-form-item>
@@ -126,8 +219,13 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
listPracticeScenarios,
listPracticeCurriculumCapabilities,
listPracticeCurriculumMatrix,
savePracticeScenario,
updatePracticeScenarioEnabled,
updatePracticeScenarioReviewStatus,
type PracticeCurriculumCapability,
type PracticeCurriculumMatrixRow,
type PracticeScenarioResponse,
type PracticeScenarioRound,
type PracticeScenarioSaveRequest
@@ -138,11 +236,25 @@ const saving = ref(false);
const dialogVisible = ref(false);
const editingId = ref('');
const scenarios = ref<PracticeScenarioResponse[]>([]);
const curriculumCapabilities = ref<PracticeCurriculumCapability[]>([]);
const matrixRows = ref<PracticeCurriculumMatrixRow[]>([]);
const positions = ['生活顾问', '保安', '保洁', '保修', '客服'];
const growthLevels = ['初级', '中级', '高级'];
const reviewStatuses = ['草稿', '待业务审核', '已发布', '已下线'];
const riskLevels = ['待评估', '常规', '高风险'];
const scenarioTypes = ref<string[]>([]);
const filters = reactive<{ position: string; scenarioType: string; enabled: boolean | undefined; keyword: string }>({
const filters = reactive<{
position: string;
scenarioType: string;
growthLevel: string;
reviewStatus: string;
enabled: boolean | undefined;
keyword: string;
}>({
position: '',
scenarioType: '',
growthLevel: '',
reviewStatus: '',
enabled: undefined,
keyword: ''
});
@@ -153,6 +265,11 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
position: '生活顾问',
scenarioType: '投诉处理',
projectType: '住宅',
growthLevel: '初级',
competencyCode: '',
collaborationPositions: '',
riskLevel: '待评估',
curriculumVersion: '住宅五岗-v1',
difficulty: 1,
customerPersona: '',
openingText: '',
@@ -160,7 +277,7 @@ const emptyForm = (): PracticeScenarioSaveRequest & { id: string } => ({
hiddenNeed: '',
irritants: '',
delights: '',
sopRefs: '生活顾问SOP',
sopRefs: '',
successCriteria: '',
strategy: '仅在漏关键步骤时提示',
goal: '',
@@ -178,6 +295,8 @@ const filteredScenarios = computed(() =>
return (
(!filters.scenarioType || item.scenarioType === filters.scenarioType) &&
(!filters.position || item.position === filters.position) &&
(!filters.growthLevel || item.growthLevel === filters.growthLevel) &&
(!filters.reviewStatus || item.reviewStatus === filters.reviewStatus) &&
(filters.enabled === undefined || item.enabled === filters.enabled) &&
(!keyword || `${item.id}${item.name}`.toLowerCase().includes(keyword))
);
@@ -188,6 +307,12 @@ const pagedScenarios = computed(() => {
return filteredScenarios.value.slice(start, start + pagination.size);
});
const enabledCount = computed(() => scenarios.value.filter((item) => item.enabled !== false).length);
const visibleMatrixRows = computed(() =>
matrixRows.value.filter((item) => !filters.position || item.position === filters.position)
);
const currentCapabilityOptions = computed(() =>
curriculumCapabilities.value.filter((item) => item.position === form.position && item.growthLevel === form.growthLevel)
);
const load = async () => {
loading.value = true;
@@ -195,11 +320,22 @@ const load = async () => {
const { data } = await listPracticeScenarios({ includeDisabled: true });
scenarios.value = data || [];
scenarioTypes.value = [...new Set(scenarios.value.map((item) => item.scenarioType).filter(Boolean))];
await Promise.all([loadCapabilities(), loadMatrix()]);
} finally {
loading.value = false;
}
};
const loadCapabilities = async () => {
const { data } = await listPracticeCurriculumCapabilities();
curriculumCapabilities.value = data || [];
};
const loadMatrix = async () => {
const { data } = await listPracticeCurriculumMatrix({ position: filters.position || undefined });
matrixRows.value = data || [];
};
const queryScenes = () => {
pagination.page = 1;
};
@@ -217,6 +353,11 @@ const fillForm = (item?: PracticeScenarioResponse) => {
position: item.position,
scenarioType: item.scenarioType,
projectType: item.projectType,
growthLevel: item.growthLevel || '初级',
competencyCode: item.competencyCode || '',
collaborationPositions: item.collaborationPositions || '',
riskLevel: riskLevels.includes(item.riskLevel) ? item.riskLevel : '待评估',
curriculumVersion: item.curriculumVersion || '住宅五岗-v1',
difficulty: item.difficulty,
customerPersona: item.customerPersona,
openingText: item.openingText,
@@ -258,6 +399,84 @@ const toggleEnabled = async (item: PracticeScenarioResponse) => {
await updatePracticeScenarioEnabled(item.id, { enabled });
item.enabled = enabled;
ElMessage.success(enabled ? '场景已启用' : '场景已停用');
await loadMatrix();
};
const reviewActionLabel = (item: PracticeScenarioResponse) => {
const status = item.reviewStatus || '草稿';
if (status === '待业务审核' && item.requiresDualReview && item.reviewer && !item.secondReviewer) {
return '复核并发布';
}
return ({
草稿: '提交审核',
待业务审核: '审核并发布',
已发布: '下线',
已下线: '重开草稿'
}[status] || '提交审核');
};
const nextReviewStatus = (status?: string): '草稿' | '待业务审核' | '已发布' | '已下线' => ({
草稿: '待业务审核',
待业务审核: '已发布',
已发布: '已下线',
已下线: '草稿'
}[status || '草稿'] || '待业务审核') as '草稿' | '待业务审核' | '已发布' | '已下线';
const advanceReviewStatus = async (item: PracticeScenarioResponse) => {
const reviewStatus = nextReviewStatus(item.reviewStatus);
const result = await updatePracticeScenarioReviewStatus(item.id, { reviewStatus });
await load();
ElMessage.success(result.data?.message || `场景已更新为“${reviewStatus}”`);
};
const returnToDraft = async (item: PracticeScenarioResponse) => {
const result = await updatePracticeScenarioReviewStatus(item.id, { reviewStatus: '草稿' });
await load();
ElMessage.success(result.data?.message || '场景已退回草稿');
};
const reviewerWithTime = (reviewer?: string, reviewedTime?: string) =>
reviewer ? `${reviewer}${reviewedTime ? `(${reviewedTime})` : ''}` : '未记录';
const reviewTrail = (item: PracticeScenarioResponse) => {
const status = item.reviewStatus || '草稿';
if (item.requiresDualReview) {
if (status === '草稿') {
return item.reviewer ? `高风险 · 已退回草稿(上次审核:${reviewerWithTime(item.reviewer, item.reviewedTime)})` : '高风险 · 草稿未提交';
}
if (status === '待业务审核') {
return item.reviewer
? `高风险 · 首审:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 待第二人复核`
: '高风险 · 待首审';
}
if (status === '已发布') {
return `高风险 · 首审:${reviewerWithTime(item.reviewer, item.reviewedTime)};复审:${reviewerWithTime(item.secondReviewer, item.secondReviewedTime)} · 已双审`;
}
return '高风险 · 已下线';
}
if (status === '草稿') return item.reviewer ? `已退回草稿(上次审核:${reviewerWithTime(item.reviewer, item.reviewedTime)})` : '草稿未提交';
if (status === '待业务审核') return item.reviewer ? `审核:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 待发布` : '待业务审核';
if (status === '已发布') return item.reviewer ? `审核:${reviewerWithTime(item.reviewer, item.reviewedTime)} · 已发布` : '已发布';
return '已下线';
};
const reviewTagType = (status?: string) => ({
草稿: 'info',
待业务审核: 'warning',
已发布: 'success',
已下线: 'danger'
}[status || '草稿'] || 'info') as 'success' | 'warning' | 'info' | 'danger';
const matrixTagType = (status?: string) => {
if (status === '已覆盖') return 'success';
if (status === '待审核' || status === '草稿待补全') return 'warning';
return 'danger';
};
const capabilityName = (scenario: PracticeScenarioResponse) => {
const item = curriculumCapabilities.value.find((capability) => capability.code === scenario.competencyCode
&& capability.position === scenario.position && capability.growthLevel === scenario.growthLevel);
return item?.name || scenario.competencyCode || '待归类';
};
const submit = async () => {
@@ -265,6 +484,14 @@ const submit = async () => {
ElMessage.warning('请填写场景名称、开场话术和训练目标');
return;
}
if (!form.competencyCode.trim()) {
ElMessage.warning('请先选择场景所属的能力项;未归类内容不能发布');
return;
}
if (!form.sopRefs.trim() || !form.irritants.trim()) {
ElMessage.warning('请填写业务来源/SOP和服务红线;它们是业务审核与发布的必要依据');
return;
}
saving.value = true;
try {
await savePracticeScenario({ ...form, id: form.id.trim() || undefined });
@@ -307,7 +534,8 @@ onMounted(load);
}
.filter-card,
.table-card {
.table-card,
.matrix-card {
padding: 18px;
border-radius: 8px;
background: #fff;
@@ -315,11 +543,46 @@ onMounted(load);
.filter-card {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1.5fr 78px;
grid-template-columns: repeat(5, minmax(120px, 1fr)) minmax(180px, 1.4fr) 78px;
gap: 12px;
margin-bottom: 12px;
}
.matrix-card {
margin-bottom: 12px;
:deep(.el-table) {
color: #111;
}
:deep(.el-table th.el-table__cell) {
color: #858991;
font-weight: 500;
background: #fafbfc;
}
}
.matrix-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
margin-bottom: 12px;
h2 {
margin: 0;
color: #25282d;
font-size: 16px;
}
p {
margin: 5px 0 0;
color: #858991;
font-size: 12px;
line-height: 1.5;
}
}
.table-card {
:deep(.el-table) {
color: #111;
@@ -361,6 +624,10 @@ onMounted(load);
:deep(.el-textarea__inner) {
line-height: 1.6;
}
:deep(.el-alert) {
margin-top: 4px;
}
}
.full-control {
@@ -390,7 +657,7 @@ onMounted(load);
@media (max-width: 1100px) {
.filter-card {
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.filter-card .el-button {
width: 100%;
+1 -1
View File
@@ -5,7 +5,7 @@ import path from 'path';
export default defineConfig(({ mode, command }) => {
const env = loadEnv(mode, process.cwd());
const proxyTarget = env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
const proxyTarget = process.env.VITE_APP_PROXY_TARGET || env.VITE_APP_PROXY_TARGET || 'https://wygj-api.localhost';
return {
// 部署生产环境和开发环境下的URL。
// 默认情况下,vite 会假设你的应用是被部署在一个域名的根路径上