feat(aihr): complete phase two pilot readiness
This commit is contained in:
@@ -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';
|
||||
};
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user