feat(aihr): complete phase two pilot readiness

This commit is contained in:
2026-07-10 19:13:31 +08:00
parent 0219de6fa8
commit 13230e6b9a
48 changed files with 7040 additions and 1647 deletions
+130
View File
@@ -0,0 +1,130 @@
import type { CandidateMaterial, InterviewRecord } from '@/types/api';
export const candidateRoutes = {
index: '/pages/candidate/index/index',
interview: '/pages/candidate/interview/index',
materials: '/pages/candidate/materials/index',
progress: '/pages/candidate/progress/index',
study: '/pages/candidate/study/index'
} as const;
export type CandidateStage = 'interview' | 'materials' | 'review' | 'complete';
export interface CandidateFlow {
stage: CandidateStage;
badge: string;
title: string;
desc: string;
progress: number;
progressText: string;
notice: string;
cta: string;
route: string;
}
export interface CandidateStep {
index: number;
title: string;
desc: string;
status: 'complete' | 'current' | 'pending';
}
export const buildCandidateFlow = (
records: InterviewRecord[],
materials: CandidateMaterial[]
): CandidateFlow => {
if (!records.length) {
return {
stage: 'interview',
badge: '待面试',
title: '完成结构化面试',
desc: '按岗位题目完成回答,提交后生成正式面试记录。',
progress: 0,
progressText: '0/2',
notice: '待完成结构化面试',
cta: '开始面试',
route: candidateRoutes.interview
};
}
const materialStatus = materials[0]?.status || '';
if (!materials.length || materialStatus === '已驳回') {
const rejected = materialStatus === '已驳回';
return {
stage: 'materials',
badge: rejected ? '资料被驳回' : '待补资料',
title: rejected ? '重新提交资料' : '上传补充资料',
desc: rejected ? '请按审核结果补充或替换资料。' : '上传简历、证书或相关图片,供 HR 审核。',
progress: 50,
progressText: '1/2',
notice: rejected ? '资料被驳回,请重新上传' : '面试已完成,请上传补充资料',
cta: rejected ? '重新上传' : '上传资料',
route: candidateRoutes.materials
};
}
if (materialStatus === '已通过') {
return {
stage: 'complete',
badge: '资料已通过',
title: '等待后续通知',
desc: '面试和补充资料已完成,请留意 HR 后续通知。',
progress: 100,
progressText: '2/2',
notice: '资料已通过,等待后续通知',
cta: '查看进度',
route: candidateRoutes.progress
};
}
return {
stage: 'review',
badge: '审核中',
title: '资料审核中',
desc: '资料已提交,审核结果会同步到进度页。',
progress: 75,
progressText: '1/2',
notice: '资料已提交,等待 HR 审核',
cta: '查看进度',
route: candidateRoutes.progress
};
};
export const buildCandidateSteps = (flow: CandidateFlow): CandidateStep[] => {
const interviewComplete = flow.stage !== 'interview';
const materialsComplete = flow.stage === 'complete';
const materialsCurrent = flow.stage === 'materials' || flow.stage === 'review';
return [
{
index: 1,
title: '完成面试',
desc: '按岗位题目作答并生成面试记录',
status: interviewComplete ? 'complete' : 'current'
},
{
index: 2,
title: '提交资料',
desc: '上传简历、证书或相关图片',
status: materialsComplete ? 'complete' : materialsCurrent ? 'current' : 'pending'
},
{
index: 3,
title: '等待通知',
desc: 'HR 审核后同步展示最新状态',
status: materialsComplete ? 'current' : 'pending'
}
];
};
export const formatCandidateFileSize = (size: number) => {
if (!size) return '0KB';
if (size < 1024 * 1024) return `${Math.ceil(size / 1024)}KB`;
return `${(size / 1024 / 1024).toFixed(1)}MB`;
};
export const materialStatusTone = (status: string) => {
if (status === '已通过') return 'passed';
if (status === '已驳回') return 'rejected';
return 'pending';
};
+8
View File
@@ -2,6 +2,7 @@ import type {
PracticeFinishResponse,
PracticePrepCard,
PracticeRecord,
PracticeTeamSnapshot,
PracticeStartResponse,
PracticeTurnResponse,
CompetencyProfile,
@@ -105,6 +106,13 @@ export const getPracticeReviews = () =>
timeout: 15000
});
export const getPracticeTeamSnapshot = () =>
apiRequest<PracticeTeamSnapshot>({
url: '/api/aihr/mobile/practice/team',
method: 'GET',
timeout: 15000
});
export const getPracticeReviewDetail = (id: number) =>
apiRequest<ReviewDetail>({
url: `/api/aihr/mobile/practice/reviews/${id}`,
+284
View File
@@ -0,0 +1,284 @@
import type {
PracticeAlert,
PracticeAssignment,
PracticeRecord,
PracticeTeamMember
} from '@/types/api';
export type SupervisorMetricKey = 'pendingReviews' | 'pendingAssignments' | 'lowScores' | 'alerts';
export interface SupervisorMetric {
key: SupervisorMetricKey;
title: string;
value: number;
desc: string;
primary?: boolean;
}
export interface TeamProfile {
key: string;
extPartyId: string;
name: string;
displayName: string;
latestScore?: number;
latestScoreText: string;
latestScene: string;
lastPracticeAt: string;
pendingReview: number;
pendingAssignment: number;
reason: string;
risk: 'high' | 'medium' | 'low';
}
export const supervisorScenarioOptions = [
{ id: 'fee-parking', name: '催费沟通' },
{ id: 'complaint-water', name: '投诉接待' },
{ id: 'repair-elevator-delay', name: '电梯故障' }
] as const;
export type SupervisorScenarioId = typeof supervisorScenarioOptions[number]['id'];
export interface AsyncGenerationGuard {
capture: () => number;
begin: () => number;
invalidate: () => void;
isCurrent: (candidate: number) => boolean;
dispose: () => void;
}
export const createAsyncGenerationGuard = (): AsyncGenerationGuard => {
let generation = 0;
let disposed = false;
return {
capture: () => generation,
begin: () => ++generation,
invalidate: () => {
generation += 1;
},
isCurrent: (candidate) => !disposed && candidate === generation,
dispose: () => {
disposed = true;
generation += 1;
}
};
};
export const inferSupervisorScenarioId = (text: string): SupervisorScenarioId => {
if (/电梯/.test(text)) return 'repair-elevator-delay';
if (/投诉|报修|漏水|噪音|摔倒/.test(text)) return 'complaint-water';
return 'fee-parking';
};
interface TeamProfileDraft {
key: string;
extPartyId: string;
name: string;
hasReadableName: boolean;
latestScene: string;
latestPractice?: {
score: number;
scene: string;
time: string;
timeValue: number;
};
pendingLowScore?: number;
pendingReview: number;
pendingAssignment: number;
}
export const manualPracticeAssignments = (assignments: PracticeAssignment[]) =>
assignments.filter((item) => item.source !== 'daily');
export const actionablePracticeAlerts = (alerts: PracticeAlert[]) =>
alerts.filter((item) => item.type !== 'stable');
export const isReviewEditable = (status: string | null | undefined) => status === '待复盘';
export const isSupervisorAccessDenied = (error: unknown) => {
const message = error instanceof Error
? error.message
: typeof error === 'string' ? error : '';
return message.includes('无主管权限');
};
export const reviewCompletionAction = (previousRoute: string | undefined): 'back' | 'replace' =>
previousRoute?.replace(/^\/+/, '') === 'pages/supervisor/reviews/index' ? 'back' : 'replace';
const alertCount = (alerts: PracticeAlert[], type: string) => {
const match = alerts.find((item) => item.type === type)?.desc.match(/\d+/);
return match ? Number(match[0]) : 0;
};
export const buildSupervisorMetrics = (
reviews: PracticeRecord[],
assignments: PracticeAssignment[],
alerts: PracticeAlert[]
): SupervisorMetric[] => {
const pendingReviewAlertCount = alertCount(alerts, 'pending_review');
const pendingReviews = pendingReviewAlertCount
|| reviews.filter((item) => item.status === '待复盘').length;
const pendingAssignments = manualPracticeAssignments(assignments)
.filter((item) => item.status !== '已完成').length;
const lowScores = alertCount(alerts, 'low_score');
const actionableAlertCount = actionablePracticeAlerts(alerts).length;
return [
{
key: 'pendingReviews',
title: '当前待复盘',
value: pendingReviews,
desc: pendingReviews ? '优先处理低分和待处理记录' : '暂无待复盘记录',
primary: true
},
{
key: 'pendingAssignments',
title: '当前待训练',
value: pendingAssignments,
desc: pendingAssignments ? '仍有专项训练待完成' : '暂无未完成专项'
},
{
key: 'lowScores',
title: '低分预警',
value: lowScores,
desc: lowScores ? '训练低于 80 分' : '暂无低分预警'
},
{
key: 'alerts',
title: '团队预警',
value: actionableAlertCount,
desc: actionableAlertCount ? '请及时跟进风险项' : '暂无团队预警'
}
];
};
export const buildTeamProfiles = (
reviews: PracticeRecord[],
assignments: PracticeAssignment[],
members: PracticeTeamMember[] = []
): TeamProfile[] => {
const rows = new Map<string, TeamProfileDraft>();
const ensure = (rawKey: string | undefined, rawName: string, rawExtPartyId?: string) => {
const extPartyId = rawExtPartyId?.trim() || '';
const candidateName = rawName.trim();
const hasReadableName = candidateName.length > 0 && !/^[??]{3,}/.test(candidateName);
const key = rawKey?.trim() || extPartyId || candidateName || 'unknown';
const identity = extPartyId || key;
const name = hasReadableName ? candidateName : `员工 ${identitySuffix(identity)}`;
const current = rows.get(key);
if (current) {
if (hasReadableName && !current.hasReadableName) {
current.name = candidateName;
current.hasReadableName = true;
}
current.extPartyId = current.extPartyId || extPartyId || key;
return current;
}
const next: TeamProfileDraft = {
key,
extPartyId: extPartyId || key,
name,
hasReadableName,
latestScene: '',
pendingReview: 0,
pendingAssignment: 0
};
rows.set(key, next);
return next;
};
members.forEach((item) => {
ensure(item.extPartyId, item.name, item.extPartyId);
});
reviews.forEach((item) => {
const row = ensure(item.extPartyId || item.trainee, item.trainee, item.extPartyId);
if (isReviewEditable(item.status)) {
row.pendingReview += 1;
if (typeof item.score === 'number' && Number.isFinite(item.score) && item.score < 80) {
row.pendingLowScore = Math.min(row.pendingLowScore ?? item.score, item.score);
}
}
if (typeof item.score === 'number' && Number.isFinite(item.score)) {
const timeValue = practiceTimeValue(item.time);
if (!row.latestPractice || timeValue > row.latestPractice.timeValue) {
row.latestPractice = {
score: item.score,
scene: item.scene.trim(),
time: item.time,
timeValue
};
}
}
});
manualPracticeAssignments(assignments).forEach((item) => {
const row = ensure(item.extPartyId || item.traineeName, item.traineeName, item.extPartyId);
if (item.status !== '已完成') row.pendingAssignment += 1;
row.latestScene ||= item.scenarioName.trim();
});
const nameCounts = Array.from(rows.values()).reduce<Record<string, number>>((counts, row) => {
counts[row.name] = (counts[row.name] || 0) + 1;
return counts;
}, {});
const riskRank: Record<TeamProfile['risk'], number> = { high: 0, medium: 1, low: 2 };
return Array.from(rows.values())
.map<TeamProfile>((row) => {
const latestScore = row.latestPractice?.score;
const latestScene = row.latestPractice?.scene || row.latestScene;
const risk: TeamProfile['risk'] = row.pendingLowScore !== undefined
? 'high'
: row.pendingReview > 0
? 'medium'
: 'low';
const reason = risk === 'high'
? `${row.name} 有未复盘低分记录(${row.pendingLowScore}分),建议定向再练`
: risk === 'medium'
? `${row.name} 有 ${row.pendingReview} 条待复盘,建议完成点评后派专项`
: row.pendingAssignment > 0
? `${row.name} 有 ${row.pendingAssignment} 项待训练,建议跟进完成`
: `${row.name} 当前无待处理训练,可按需派发专项`;
return {
key: row.key,
extPartyId: row.extPartyId,
name: row.name,
displayName: nameCounts[row.name] > 1
? `${row.name} · ${identitySuffix(row.extPartyId)}`
: row.name,
latestScore,
latestScoreText: latestScore === undefined ? '暂无评分' : `${latestScore}分`,
latestScene,
lastPracticeAt: row.latestPractice?.time || '',
pendingReview: row.pendingReview,
pendingAssignment: row.pendingAssignment,
reason,
risk
};
})
.sort((a, b) =>
riskRank[a.risk] - riskRank[b.risk]
|| b.pendingReview - a.pendingReview
|| b.pendingAssignment - a.pendingAssignment
|| (a.latestScore ?? 100) - (b.latestScore ?? 100));
};
function identitySuffix(value: string) {
if (/^1[3-9]\d{9}$/.test(value)) return `尾号${value.slice(-4)}`;
if (value.length > 8) return `末尾${value.slice(-6)}`;
return value;
}
function practiceTimeValue(value: string) {
const parts = value.match(/\d+/g)?.map(Number) || [];
if (parts.length < 4 || parts.some((part) => !Number.isFinite(part))) {
return Number.NEGATIVE_INFINITY;
}
const normalized = parts.length >= 5 ? parts.slice(0, 6) : [0, ...parts.slice(0, 5)];
while (normalized.length < 6) normalized.push(0);
const [year, month, day, hour, minute, second] = normalized;
return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second;
}