export type WorkReviewDecision = 'approved' | 'revision_required' export type WorkReviewReviewerRole = 'manager' | 'final_reviewer' export type NormalizedWorkReviewAnnotation = { id: string x: number y: number x2?: number y2?: number shape?: 'point' | 'rectangle' | 'ellipse' | 'arrow' color?: string comment: string } export type WorkReviewAnnotationValidationResult = | { ok: true; annotations: NormalizedWorkReviewAnnotation[] } | { ok: false; error: string } const WORK_REVIEW_ANNOTATION_LIMIT = 50 const WORK_REVIEW_ANNOTATION_COMMENT_LIMIT = 1000 const WORK_REVIEW_ANNOTATION_SHAPES = new Set(['point', 'rectangle', 'ellipse', 'arrow']) const WORK_REVIEW_ANNOTATION_COLORS = new Set([ '#0050cb', '#d92d20', '#087a3c', '#8a5200', '#101828', '#ffffff', ]) function normalizedCoordinate(value: unknown) { return typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= 1 ? Math.round(value * 1_000_000) / 1_000_000 : null } export function validateWorkReviewAnnotations(value: unknown): WorkReviewAnnotationValidationResult { if (value == null) return { ok: true, annotations: [] } if (!Array.isArray(value)) return { ok: false, error: '图片批注格式无效' } if (value.length > WORK_REVIEW_ANNOTATION_LIMIT) { return { ok: false, error: `单张图片最多添加 ${WORK_REVIEW_ANNOTATION_LIMIT} 个批注` } } const ids = new Set() const annotations: NormalizedWorkReviewAnnotation[] = [] for (const raw of value) { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { ok: false, error: '图片批注格式无效' } } const item = raw as Record const id = String(item.id ?? '').trim() const x = normalizedCoordinate(item.x) const y = normalizedCoordinate(item.y) const shape = item.shape == null ? undefined : String(item.shape).trim().toLowerCase() const color = item.color == null ? undefined : String(item.color).trim().toLowerCase() const x2 = item.x2 == null ? undefined : normalizedCoordinate(item.x2) const y2 = item.y2 == null ? undefined : normalizedCoordinate(item.y2) const comment = String(item.comment ?? '').trim() if (!id || id.length > 64 || ids.has(id)) { return { ok: false, error: '图片批注编号无效或重复' } } if (x == null || y == null) { return { ok: false, error: '图片批注位置无效' } } if ((item.x2 != null && x2 == null) || (item.y2 != null && y2 == null)) { return { ok: false, error: '图片框选范围无效' } } if (shape != null && !WORK_REVIEW_ANNOTATION_SHAPES.has(shape)) { return { ok: false, error: '图片批注形状无效' } } if (color != null && !WORK_REVIEW_ANNOTATION_COLORS.has(color)) { return { ok: false, error: '图片批注颜色无效' } } if (shape && shape !== 'point' && (x2 == null || y2 == null)) { return { ok: false, error: '图片框选范围无效' } } if (!comment) return { ok: false, error: '请填写每个标注点的修改意见' } if (comment.length > WORK_REVIEW_ANNOTATION_COMMENT_LIMIT) { return { ok: false, error: `单个标注点的修改意见不能超过 ${WORK_REVIEW_ANNOTATION_COMMENT_LIMIT} 个字` } } ids.add(id) annotations.push({ id, x, y, ...(x2 == null ? {} : { x2 }), ...(y2 == null ? {} : { y2 }), ...(shape == null ? {} : { shape: shape as NormalizedWorkReviewAnnotation['shape'] }), ...(color == null ? {} : { color }), comment, }) } return { ok: true, annotations } } type WorkReviewEmployeeIdentity = { id?: unknown dingtalkUserId?: unknown dingEmail?: unknown jobNumber?: unknown flowerName?: unknown realName?: unknown displayName?: unknown sourcePayload?: unknown } function normalizeIdentityValue(value: unknown) { const normalized = String(value ?? '').trim().toLowerCase() return normalized || null } function normalizeEmployeeAlias(value: unknown) { const normalized = String(value ?? '') .normalize('NFKC') .trim() .toLowerCase() .replace(/\s+/g, '') .replace(/無/g, '无') return normalized || null } export function getWorkReviewEmployeeMaterialUserIds(employee: WorkReviewEmployeeIdentity | null | undefined) { const payload = employee?.sourcePayload if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return [] const record = payload as Record return Array.from(new Set([ normalizeIdentityValue(record.materialUserId), normalizeIdentityValue(record.userId), ].filter((value): value is string => Boolean(value)))) } export function getWorkReviewEmployeeIdentityKeys(employee: WorkReviewEmployeeIdentity | null | undefined) { if (!employee) return [] const payload = employee.sourcePayload const materialEmail = payload && typeof payload === 'object' && !Array.isArray(payload) ? normalizeIdentityValue((payload as Record).materialUserEmail) : null const values = [ ['profile', normalizeIdentityValue(employee.id)], ['dingtalk', normalizeIdentityValue(employee.dingtalkUserId)], ['email', normalizeIdentityValue(employee.dingEmail)], ['job', normalizeIdentityValue(employee.jobNumber)], ['email', materialEmail], ...getWorkReviewEmployeeMaterialUserIds(employee).map((value) => ['user', value]), ] as Array<[string, string | null]> return Array.from(new Set(values.flatMap(([type, value]) => value ? [`${type}:${value}`] : []))) } export function getWorkReviewEmployeeAliasKeys(employee: WorkReviewEmployeeIdentity | null | undefined) { if (!employee) return [] return Array.from(new Set([ normalizeEmployeeAlias(employee.flowerName), normalizeEmployeeAlias(employee.displayName), normalizeEmployeeAlias(employee.realName), ].filter((value): value is string => Boolean(value)))) } export function isSameWorkReviewEmployeeIdentity( left: WorkReviewEmployeeIdentity | null | undefined, right: WorkReviewEmployeeIdentity | null | undefined, ) { const leftKeys = new Set(getWorkReviewEmployeeIdentityKeys(left)) return getWorkReviewEmployeeIdentityKeys(right).some((key) => leftKeys.has(key)) } export function isSameWorkReviewEmployeeAlias( left: WorkReviewEmployeeIdentity | null | undefined, right: WorkReviewEmployeeIdentity | null | undefined, ) { const leftAliases = new Set(getWorkReviewEmployeeAliasKeys(left)) return getWorkReviewEmployeeAliasKeys(right).some((alias) => leftAliases.has(alias)) } export function canSubmitWorkReview(employee: unknown) { return Boolean(employee) } export function canDeleteWorkReviewProject(user: { username?: unknown } | null | undefined) { const username = String(user?.username ?? '').trim().toLowerCase() return username === 'wuxie' || username === '无邪' } export function getWorkReviewReviewerSelectionError(input: { reviewerEmployeeId: string | null submitterEmployeeId: string finalReviewerEmployeeId: string }) { if (!input.reviewerEmployeeId) return '请选择评审人' if (input.reviewerEmployeeId === input.submitterEmployeeId) { return '评审人不能选择自己,请选择其他人员' } if (input.reviewerEmployeeId === input.finalReviewerEmployeeId) { return '元泽已是固定评审人,请选择另一名评审人' } return null } export function deriveWorkReviewDecision( decisions: ReadonlyArray<{ decision: WorkReviewDecision }>, ): WorkReviewDecision { return decisions.some((item) => item.decision === 'revision_required') ? 'revision_required' : 'approved' } export function deriveCompletedWorkReviewStatus( reviews: ReadonlyArray<{ reviewerRole: string decision: WorkReviewDecision }>, requiredRoles: ReadonlyArray = ['manager', 'final_reviewer'], ): WorkReviewDecision | null { const requiredReviews = requiredRoles.map((role) => ( reviews.find((review) => review.reviewerRole === role) )) if (requiredReviews.some((review) => !review)) return null return deriveWorkReviewDecision(requiredReviews as Array<{ decision: WorkReviewDecision }>) } export function canResubmitWorkReviewProject(project: { status: string versions: ReadonlyArray<{ status: string }> permissions: { canResubmit: boolean } }) { const latestVersion = project.versions[0] return Boolean( latestVersion && project.permissions.canResubmit && project.status === 'revision_required' && latestVersion.status === 'revision_required', ) }