233 lines
8.4 KiB
TypeScript
233 lines
8.4 KiB
TypeScript
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<string>()
|
|
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<string, unknown>
|
|
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<string, unknown>
|
|
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<string, unknown>).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<WorkReviewReviewerRole> = ['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',
|
|
)
|
|
}
|