410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
import type {
|
||
PracticeAlert,
|
||
PracticeAssignment,
|
||
PracticeRecord,
|
||
PracticeTeamMember,
|
||
PracticeTeamProfileSummary
|
||
} 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;
|
||
completedToday?: boolean;
|
||
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');
|
||
|
||
const normalizedTeamCount = (value: number | null | undefined) =>
|
||
typeof value === 'number' && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
||
|
||
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[] = [],
|
||
profileSummaries: PracticeTeamProfileSummary[] = []
|
||
): TeamProfile[] => {
|
||
const rows = new Map<string, TeamProfileDraft>();
|
||
const summariesByExtPartyId = new Map(
|
||
profileSummaries
|
||
.filter((item) => Boolean(item?.extPartyId?.trim()))
|
||
.map((item) => [item.extPartyId.trim(), item] as const)
|
||
);
|
||
const hasFullTeamSummaries = summariesByExtPartyId.size > 0;
|
||
const memberExtPartyIds = new Set(
|
||
members.map((item) => item.extPartyId.trim()).filter(Boolean)
|
||
);
|
||
const normalizedExtPartyId = (value?: string | null) => value?.trim() || '';
|
||
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) => {
|
||
if (hasFullTeamSummaries && !memberExtPartyIds.has(normalizedExtPartyId(item.extPartyId))) return;
|
||
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) => {
|
||
if (hasFullTeamSummaries && !memberExtPartyIds.has(normalizedExtPartyId(item.extPartyId))) return;
|
||
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 summary = summariesByExtPartyId.get(row.extPartyId);
|
||
const latestScore = typeof summary?.latestScore === 'number' && Number.isFinite(summary.latestScore)
|
||
? summary.latestScore
|
||
: row.latestPractice?.score;
|
||
const latestScene = summary?.latestScene?.trim() || row.latestPractice?.scene || row.latestScene;
|
||
const lastPracticeAt = summary?.lastPracticeAt?.trim() || row.latestPractice?.time || '';
|
||
const completedToday = typeof summary?.completedToday === 'boolean'
|
||
? summary.completedToday
|
||
: undefined;
|
||
const pendingReview = summary ? normalizedTeamCount(summary.pendingReview) : row.pendingReview;
|
||
const pendingAssignment = summary ? normalizedTeamCount(summary.pendingAssignment) : row.pendingAssignment;
|
||
const pendingLowScore = summary && typeof summary.pendingLowScore === 'number' && Number.isFinite(summary.pendingLowScore)
|
||
? summary.pendingLowScore
|
||
: row.pendingLowScore;
|
||
const risk: TeamProfile['risk'] = pendingLowScore !== undefined
|
||
? 'high'
|
||
: pendingReview > 0
|
||
? 'medium'
|
||
: 'low';
|
||
const reason = risk === 'high'
|
||
? `${row.name} 有未复盘低分记录(${pendingLowScore}分),建议定向再练`
|
||
: risk === 'medium'
|
||
? `${row.name} 有 ${pendingReview} 条待复盘,建议完成点评后派专项`
|
||
: pendingAssignment > 0
|
||
? `${row.name} 有 ${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,
|
||
completedToday,
|
||
pendingReview,
|
||
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 interface TeamProfileRollup {
|
||
teamTotal: number;
|
||
pendingReview: number;
|
||
pendingAssignment: number;
|
||
todayDone: number;
|
||
}
|
||
|
||
/**
|
||
* Profiles are the only complete team-level source. Recent activity lists are
|
||
* intentionally capped, so dashboard counts must not derive from them.
|
||
*/
|
||
export const buildTeamProfileRollup = (
|
||
profiles: TeamProfile[],
|
||
todayKey: string
|
||
): TeamProfileRollup => profiles.reduce<TeamProfileRollup>((rollup, profile) => {
|
||
rollup.teamTotal += 1;
|
||
rollup.pendingReview += normalizedTeamCount(profile.pendingReview);
|
||
rollup.pendingAssignment += normalizedTeamCount(profile.pendingAssignment);
|
||
const completedToday = profile.completedToday === true
|
||
|| (profile.completedToday === undefined && Boolean(todayKey) && profile.lastPracticeAt.startsWith(todayKey));
|
||
if (completedToday) rollup.todayDone += 1;
|
||
return rollup;
|
||
}, {
|
||
teamTotal: 0,
|
||
pendingReview: 0,
|
||
pendingAssignment: 0,
|
||
todayDone: 0
|
||
});
|
||
|
||
export const filterTeamProfiles = <T extends Pick<TeamProfile, 'displayName' | 'extPartyId'>>(
|
||
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 = <T>(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 {
|
||
// The team page uses this as a progressive “load more” view, not a
|
||
// replacement pager. Keep earlier members visible when the next batch is
|
||
// requested so the control matches its label and does not make the list
|
||
// jump backward.
|
||
items: profiles.slice(0, 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;
|
||
}
|