关键改动: - 增加版本表单、发布校验和调研方向进度规则 - 扩展小宝预警已读状态、风险签名和今日证据 - 补充组长权限、加班查看范围、活动记录与相关测试 Co-Authored-By: Codex GPT-5 <codex@openai.com>
143 lines
4.8 KiB
TypeScript
143 lines
4.8 KiB
TypeScript
import type { VersionWithContext } from './derive';
|
|
import type { XiaobaoRiskInsight } from './xiaobao-risk-cache';
|
|
import type { XiaobaoVersionRisk } from './xiaobao-risk';
|
|
import { buildRiskInsightDisplaySignature, normalizeRiskInsightDisplaySignature } from './xiaobao-risk-ai';
|
|
import { WORK_HOURS } from './work-hours';
|
|
|
|
const UNFINISHED_VERSION_STATUSES = new Set(['planned', 'developing', 'paused']);
|
|
const HIGH_RISK_LEVELS = new Set<XiaobaoVersionRisk['riskLevel']>(['at_risk', 'likely_delayed', 'blocked']);
|
|
const PAGE_REFRESH_ADVICE_PATTERNS = [
|
|
/(刷新|重新加载|重载).*(页面|浏览器|小宝|预警)/i,
|
|
/(页面|浏览器|小宝|预警).*(刷新|重新加载|重载)/i,
|
|
/(手动|主动).*(触发|刷新).*(更新|预警|分析)/i,
|
|
/(manual|manually).*(refresh|reload|trigger)/i,
|
|
/(refresh|reload).*(page|browser|xiaobao|warning)/i,
|
|
];
|
|
|
|
export interface XiaobaoWarningVersionFilter {
|
|
canManage: boolean;
|
|
userName?: string;
|
|
}
|
|
|
|
export type XiaobaoWarningRiskFilter = 'all' | 'attention' | 'high';
|
|
|
|
export interface XiaobaoWarningRiskListFilter {
|
|
riskFilter?: XiaobaoWarningRiskFilter;
|
|
productId?: string;
|
|
projectId?: string;
|
|
}
|
|
|
|
export type RiskScoreTone = 'danger' | 'warn' | 'ok';
|
|
|
|
export interface XiaobaoWarningReadState {
|
|
userId: string;
|
|
versionId: string;
|
|
signature: string;
|
|
readAt: string;
|
|
}
|
|
|
|
export function filterXiaobaoWarningVersions(
|
|
versions: VersionWithContext[],
|
|
filter: XiaobaoWarningVersionFilter,
|
|
): VersionWithContext[] {
|
|
return versions.filter((version) => {
|
|
if (!UNFINISHED_VERSION_STATUSES.has(version.status)) return false;
|
|
if (filter.canManage) return true;
|
|
if (!filter.userName) return false;
|
|
return (version.members ?? []).some((member) => member.name === filter.userName);
|
|
});
|
|
}
|
|
|
|
export function filterXiaobaoRiskWarnings(
|
|
risks: XiaobaoVersionRisk[],
|
|
filter: XiaobaoWarningRiskListFilter = {},
|
|
): XiaobaoVersionRisk[] {
|
|
return risks.filter((risk) => {
|
|
if (risk.riskLevel === 'on_track') return false;
|
|
if (filter.productId && risk.productId !== filter.productId) return false;
|
|
if (filter.projectId && risk.projectId !== filter.projectId) return false;
|
|
if (filter.riskFilter === 'attention') return true;
|
|
if (filter.riskFilter === 'high') return HIGH_RISK_LEVELS.has(risk.riskLevel);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
export function getXiaobaoWarningRiskCount(risks: XiaobaoVersionRisk[]): number {
|
|
return filterXiaobaoRiskWarnings(risks).length;
|
|
}
|
|
|
|
export function getXiaobaoWarningUpdateSignature(risk: XiaobaoVersionRisk): string {
|
|
return buildRiskInsightDisplaySignature(risk);
|
|
}
|
|
|
|
export function isXiaobaoWarningUpdated(
|
|
risk: XiaobaoVersionRisk,
|
|
readStates: XiaobaoWarningReadState[],
|
|
userId?: string,
|
|
): boolean {
|
|
if (!userId) return false;
|
|
if (risk.aiInsightUpdating) return false;
|
|
const currentSignature = getXiaobaoWarningUpdateSignature(risk);
|
|
const readState = readStates.find((item) => item.userId === userId && item.versionId === risk.versionId);
|
|
const readSignature = readState?.signature
|
|
? normalizeRiskInsightDisplaySignature(readState.signature) ?? readState.signature
|
|
: undefined;
|
|
return readSignature !== currentSignature;
|
|
}
|
|
|
|
export function getXiaobaoWarningUnreadUpdateCount(
|
|
risks: XiaobaoVersionRisk[],
|
|
readStates: XiaobaoWarningReadState[],
|
|
userId?: string,
|
|
): number {
|
|
return risks.filter((risk) => isXiaobaoWarningUpdated(risk, readStates, userId)).length;
|
|
}
|
|
|
|
export function markXiaobaoWarningRead(
|
|
readStates: XiaobaoWarningReadState[],
|
|
userId: string,
|
|
risk: XiaobaoVersionRisk,
|
|
readAt: string,
|
|
): XiaobaoWarningReadState[] {
|
|
if (risk.aiInsightUpdating) return readStates;
|
|
const item: XiaobaoWarningReadState = {
|
|
userId,
|
|
versionId: risk.versionId,
|
|
signature: getXiaobaoWarningUpdateSignature(risk),
|
|
readAt,
|
|
};
|
|
return [
|
|
item,
|
|
...readStates.filter((row) => row.userId !== userId || row.versionId !== risk.versionId),
|
|
];
|
|
}
|
|
|
|
export function getRiskScoreTone(score: number): RiskScoreTone {
|
|
if (score >= 75) return 'danger';
|
|
if (score >= 55) return 'warn';
|
|
return 'ok';
|
|
}
|
|
|
|
export function formatRemainingWork(hours: number): string {
|
|
const safeHours = Number.isFinite(hours) && hours > 0 ? hours : 0;
|
|
const days = safeHours / WORK_HOURS.hoursPerDay;
|
|
return `${formatNumber(safeHours)}h / ${formatNumber(days)}天`;
|
|
}
|
|
|
|
export function sanitizeRiskInsight(insight: XiaobaoRiskInsight): XiaobaoRiskInsight {
|
|
return {
|
|
...insight,
|
|
suggestedActions: insight.suggestedActions.filter((action) => !isPageRefreshAdvice(action)),
|
|
ownerHints: insight.ownerHints.filter((hint) => !isPageRefreshAdvice(hint)),
|
|
};
|
|
}
|
|
|
|
function formatNumber(value: number): string {
|
|
if (Number.isInteger(value)) return String(value);
|
|
return value.toFixed(1).replace(/\.0$/, '');
|
|
}
|
|
|
|
function isPageRefreshAdvice(text: string): boolean {
|
|
return PAGE_REFRESH_ADVICE_PATTERNS.some((pattern) => pattern.test(text));
|
|
}
|