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 TrainingTrendPoint { key: string; label: string; value: number; } 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 interface SupervisorScenarioOption { id: string; name: string; } export const supervisorScenarioOptions: SupervisorScenarioOption[] = [ { id: 'fee-parking', name: '催费沟通' }, { id: 'complaint-water', name: '投诉接待' }, { id: 'repair-elevator-delay', name: '电梯故障' } ]; export type SupervisorScenarioId = string; 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 buildTrainingTrend = (records: PracticeRecord[]): TrainingTrendPoint[] => records .filter((item) => Number.isFinite(item.score)) .sort((a, b) => practiceTimeValue(b.time) - practiceTimeValue(a.time)) .slice(0, 5) .reverse() .map((item, index) => ({ key: `${item.id || item.sessionId || item.time}-${index}`, label: trainingTrendLabel(item.time, index), value: Math.max(0, Math.min(100, item.score)) })); export const isReviewEditable = (status: string | null | undefined) => status === '待复盘'; export type ReviewFilter = 'all' | 'low' | 'pending'; export const filterReviewRecords = (reviews: PracticeRecord[], filter: ReviewFilter) => { if (filter === 'low') return reviews.filter((item) => item.score < 80); if (filter === 'pending') return reviews.filter((item) => isReviewEditable(item.status)); return reviews; }; 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(); 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>((counts, row) => { counts[row.name] = (counts[row.name] || 0) + 1; return counts; }, {}); const riskRank: Record = { high: 0, medium: 1, low: 2 }; return Array.from(rows.values()) .map((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)); }; export const filterTeamProfiles = >( profiles: T[], query: string ) => { const keyword = query.trim().toLowerCase(); if (!keyword) return profiles; return profiles.filter((item) => item.displayName.toLowerCase().includes(keyword) || item.extPartyId.toLowerCase().includes(keyword)); }; export const paginateTeamProfiles = (profiles: T[], requestedPage: number, pageSize: number) => { const safeSize = Math.max(1, Math.floor(pageSize)); const pageCount = Math.max(1, Math.ceil(profiles.length / safeSize)); const page = Math.min(pageCount, Math.max(1, Math.floor(requestedPage))); return { items: profiles.slice((page - 1) * safeSize, page * safeSize), page, pageCount }; }; 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 trainingTrendLabel(value: string, index: number) { const parts = value.match(/\d+/g) || []; const [first = '', second = '', third = ''] = parts; if (parts.length >= 3 && first.length === 4) return `${second.padStart(2, '0')}-${third.padStart(2, '0')}`; if (parts.length >= 2) return `${first.padStart(2, '0')}-${second.padStart(2, '0')}`; return `第${index + 1}次`; } 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 = 0, month = 0, day = 0, hour = 0, minute = 0, second = 0] = normalized; return (((((year * 13 + month) * 32 + day) * 24 + hour) * 60 + minute) * 60) + second; }