feat: 新增作品过稿看板及本地测试环境

This commit is contained in:
江林铖 2026-07-28 17:29:47 +08:00
parent 1970c0f644
commit 417a3b0243
29 changed files with 4161 additions and 25 deletions

View File

@ -304,7 +304,7 @@ function createH5LoginErrorPage(message: string) {
</head> </head>
<body> <body>
<main> <main>
<h1>无法打开问卷</h1> <h1>无法打开页面</h1>
<p id="message"></p> <p id="message"></p>
</main> </main>
<script> <script>
@ -362,7 +362,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
}) })
if (!code) { if (!code) {
return createH5LoginErrorPage('暂时无法打开问卷,请从最新通知重新进入。') return createH5LoginErrorPage('暂时无法打开页面,请从最新通知重新进入。')
} }
try { try {
@ -390,7 +390,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
redirectPath, redirectPath,
message: data?.message || '', message: data?.message || '',
}) })
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。') return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
} }
if (data.needCompleteProfile) { if (data.needCompleteProfile) {
@ -406,7 +406,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
return createSessionTicketTransferPage({ request, redirectPath, loginResponse }) return createSessionTicketTransferPage({ request, redirectPath, loginResponse })
} catch (error: any) { } catch (error: any) {
console.error('钉钉H5免登页面跳转失败:', error) console.error('钉钉H5免登页面跳转失败:', error)
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。') return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
} }
} }

View File

@ -0,0 +1,72 @@
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
import { authOptions } from '@/lib/auth'
import { PerformanceSurveyApiError } from '@/lib/performance-survey-service'
import {
WorkReviewApiError,
createWorkReviewProject,
createWorkReviewVersion,
getWorkReviewBoard,
submitWorkReview,
} from '@/lib/work-review-service'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export const revalidate = 0
type RouteContext = {
params: {
path?: string[]
}
}
function ok(data: unknown, init?: ResponseInit) {
return NextResponse.json({ success: true, data }, init)
}
function errorResponse(error: unknown) {
if (error instanceof WorkReviewApiError || error instanceof PerformanceSurveyApiError) {
return NextResponse.json({ success: false, error: error.message }, { status: error.status })
}
console.error('[work-review] API error:', error)
return NextResponse.json({ success: false, error: '服务器错误,请稍后重试' }, { status: 500 })
}
function getPath(context: RouteContext) {
return context.params.path || []
}
export async function GET(request: Request, context: RouteContext) {
try {
const path = getPath(context)
if (path.length > 0) {
return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 })
}
const session = await getServerSession(authOptions)
return ok(await getWorkReviewBoard(session, new URL(request.url).searchParams))
} catch (error) {
return errorResponse(error)
}
}
export async function POST(request: Request, context: RouteContext) {
try {
const session = await getServerSession(authOptions)
const path = getPath(context)
const body = await request.json().catch(() => ({}))
if (path[0] === 'projects' && path.length === 1) {
return ok(await createWorkReviewProject(session, body), { status: 201 })
}
if (path[0] === 'projects' && path[1] && path[2] === 'versions' && path.length === 3) {
return ok(await createWorkReviewVersion(session, path[1], body), { status: 201 })
}
if (path[0] === 'versions' && path[1] && path[2] === 'reviews' && path.length === 3) {
return ok(await submitWorkReview(session, path[1], body))
}
return NextResponse.json({ success: false, error: '接口不存在' }, { status: 404 })
} catch (error) {
return errorResponse(error)
}
}

View File

@ -124,7 +124,7 @@ async function hasActiveSession() {
export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEntryClientProps) { export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEntryClientProps) {
const startedRef = useRef(false) const startedRef = useRef(false)
const [message, setMessage] = useState('正在打开问卷...') const [message, setMessage] = useState('正在打开页面...')
useEffect(() => { useEffect(() => {
if (startedRef.current) return if (startedRef.current) return
@ -132,7 +132,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
const run = async () => { const run = async () => {
if (!corpId) { if (!corpId) {
setMessage('请返回钉钉,从最新通知重新打开问卷。') setMessage('请返回钉钉,从最新通知重新打开。')
return return
} }
@ -142,7 +142,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
} }
try { try {
setMessage('正在打开问卷...') setMessage('正在打开页面...')
if (await hasActiveSession()) { if (await hasActiveSession()) {
window.location.replace(redirectPath) window.location.replace(redirectPath)
return return
@ -154,8 +154,8 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
navigateDingtalkH5Login(authCode, redirectPath) navigateDingtalkH5Login(authCode, redirectPath)
} catch (error) { } catch (error) {
console.error('[Dingtalk Entry] 打开问卷失败:', error) console.error('[Dingtalk Entry] 打开页面失败:', error)
setMessage('请返回钉钉,从最新通知重新打开问卷。') setMessage('请返回钉钉,从最新通知重新打开。')
} }
} }
@ -174,13 +174,13 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
<section className="mx-auto max-w-[430px] px-5 py-8"> <section className="mx-auto max-w-[430px] px-5 py-8">
<div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]"> <div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
<div className="text-[14px] font-semibold text-[#1677ff]">考核调研</div> <div className="text-[14px] font-semibold text-[#1677ff]">由壹点工作台</div>
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1> <h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开页面</h1>
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">{message}</p> <p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">{message}</p>
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4"> <div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<span className="text-[15px] font-semibold text-[#101828]">准备问卷内容</span> <span className="text-[15px] font-semibold text-[#101828]">准备页面内容</span>
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span> <span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
</div> </div>
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]"> <div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
@ -189,7 +189,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
</div> </div>
<div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5"> <div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5">
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div> <div className="text-[15px] font-semibold text-[#7b8494]">业务工作台</div>
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" /> <div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" /> <div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
<div className="mt-8 space-y-5"> <div className="mt-8 space-y-5">

22
app/work-review/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Suspense } from 'react'
import WorkReviewBoard from '@/components/work-review/WorkReviewBoard'
function WorkReviewFallback() {
return (
<div className="min-h-screen bg-[#f6f8fb]">
<div className="h-16 bg-[#1f2937]" />
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-6">
<div className="h-8 w-48 animate-pulse rounded bg-[#e6eaf0]" />
<div className="mt-3 h-4 w-72 animate-pulse rounded bg-[#edf0f4]" />
</main>
</div>
)
}
export default function WorkReviewPage() {
return (
<Suspense fallback={<WorkReviewFallback />}>
<WorkReviewBoard />
</Suspense>
)
}

View File

@ -587,6 +587,18 @@ export default function Header({ session }: HeaderProps) {
<span>考核调研</span> <span>考核调研</span>
</Link> </Link>
{/* 作品过稿看板 */}
<Link
href="/work-review"
onClick={() => setShowDropdown(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm4 4h8M8 13h5m-5 4h3" />
</svg>
<span>作品过稿看板</span>
</Link>
{/* 更新记录 */} {/* 更新记录 */}
<button <button
onClick={() => { onClick={() => {

View File

@ -0,0 +1,461 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import ToastBubble from '@/components/ToastBubble'
import { useToast } from '@/hooks/useToast'
import {
createWorkReviewProject,
createWorkReviewVersion,
getWorkReviewBoard,
submitWorkReview,
WorkReviewRequestError,
} from '@/lib/work-review-client'
import type {
WorkReviewBoardData,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewRole,
WorkReviewStatus,
WorkReviewSubmissionInput,
WorkReviewVersion,
} from '@/types/work-review'
import { WorkReviewSubmissionDialog, WorkReviewReviewDialog } from './WorkReviewDialogs'
import { WorkReviewHeader } from './WorkReviewHeader'
import { WorkReviewProjectCard } from './WorkReviewProjectCard'
const statusOptions: Array<{ value: WorkReviewStatus | ''; label: string }> = [
{ value: '', label: '全部状态' },
{ value: 'pending_review', label: '评审中' },
{ value: 'revision_required', label: '待修改' },
{ value: 'approved', label: '已通过' },
]
function normalizeStatus(value: string | null): WorkReviewStatus | '' {
return ['pending_review', 'revision_required', 'approved'].includes(String(value || ''))
? value as WorkReviewStatus
: ''
}
function normalizePage(value: string | null) {
return Math.max(1, Number(value || 1) || 1)
}
function LoadingScreen() {
return (
<div className="min-h-screen bg-[#f6f8fb]">
<div className="h-16 bg-[#1f2937]" />
<main className="mx-auto max-w-[1180px] px-4 py-8 sm:px-6">
<div className="h-8 w-48 animate-pulse rounded bg-[#e6eaf0]" />
<div className="mt-3 h-4 w-72 animate-pulse rounded bg-[#edf0f4]" />
<div className="mt-8 grid gap-3 sm:grid-cols-3">
{[0, 1, 2].map((item) => <div key={item} className="h-20 animate-pulse rounded-xl bg-white" />)}
</div>
<div className="mt-6 space-y-3">
{[0, 1, 2].map((item) => <div key={item} className="h-32 animate-pulse rounded-xl bg-white" />)}
</div>
</main>
</div>
)
}
export default function WorkReviewBoard() {
const { data: session, status: sessionStatus } = useSession()
const router = useRouter()
const searchParams = useSearchParams()
const initialQueryRef = useRef(searchParams.get('query') || '')
const initialStatusRef = useRef(normalizeStatus(searchParams.get('status')))
const initialPageRef = useRef(normalizePage(searchParams.get('page')))
const initialProjectRef = useRef(searchParams.get('project') || '')
const deepLinkProjectRef = useRef(initialProjectRef.current)
const requestSequenceRef = useRef(0)
const abortRef = useRef<AbortController | null>(null)
const [query, setQuery] = useState(initialQueryRef.current)
const [debouncedQuery, setDebouncedQuery] = useState(initialQueryRef.current)
const [statusFilter, setStatusFilter] = useState<WorkReviewStatus | ''>(initialStatusRef.current)
const [page, setPage] = useState(initialPageRef.current)
const [data, setData] = useState<WorkReviewBoardData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deepLinkedProjectMissing, setDeepLinkedProjectMissing] = useState(false)
const [expandedProjectId, setExpandedProjectId] = useState(initialProjectRef.current)
const [submissionDialog, setSubmissionDialog] = useState<{
mode: 'create' | 'resubmit'
project?: WorkReviewProject
} | null>(null)
const [reviewDialog, setReviewDialog] = useState<{
project: WorkReviewProject
version: WorkReviewVersion
role: WorkReviewRole
} | null>(null)
const { toast, showToast } = useToast()
useEffect(() => {
const timer = window.setTimeout(() => {
setDebouncedQuery(query.trim())
setPage(1)
}, 300)
return () => window.clearTimeout(timer)
}, [query])
useEffect(() => {
if (sessionStatus !== 'unauthenticated') return
const currentPath = typeof window === 'undefined'
? '/work-review'
: `${window.location.pathname}${window.location.search}`
router.replace(`/login?redirect=${encodeURIComponent(currentPath)}`)
}, [router, sessionStatus])
const updateUrl = useCallback((input?: { project?: string; replaceProject?: boolean }) => {
const params = new URLSearchParams()
if (debouncedQuery) params.set('query', debouncedQuery)
if (statusFilter) params.set('status', statusFilter)
if (page > 1) params.set('page', String(page))
const projectId = input?.replaceProject
? input.project || ''
: expandedProjectId
if (projectId) params.set('project', projectId)
const value = params.toString()
router.replace(`/work-review${value ? `?${value}` : ''}`, { scroll: false })
}, [debouncedQuery, expandedProjectId, page, router, statusFilter])
useEffect(() => {
if (sessionStatus !== 'authenticated') return
updateUrl()
}, [sessionStatus, updateUrl])
const load = useCallback(async () => {
if (sessionStatus !== 'authenticated') return
const sequence = requestSequenceRef.current + 1
requestSequenceRef.current = sequence
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
setLoading(true)
setError(null)
try {
const deepLinkProject = deepLinkProjectRef.current
const nextData = await getWorkReviewBoard({
query: debouncedQuery,
status: statusFilter,
project: deepLinkProject,
page,
pageSize: 20,
}, controller.signal)
if (sequence !== requestSequenceRef.current) return
if (deepLinkProject) {
setDeepLinkedProjectMissing(!nextData.items.some((item) => item.id === deepLinkProject))
deepLinkProjectRef.current = ''
} else {
setDeepLinkedProjectMissing(false)
}
setData(nextData)
} catch (loadError) {
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return
if (loadError instanceof WorkReviewRequestError && loadError.status === 401) {
const currentPath = `${window.location.pathname}${window.location.search}`
router.replace(`/login?redirect=${encodeURIComponent(currentPath)}`)
return
}
setError(loadError instanceof Error ? loadError.message : '看板加载失败')
} finally {
if (sequence === requestSequenceRef.current) setLoading(false)
}
}, [debouncedQuery, page, router, sessionStatus, statusFilter])
useEffect(() => {
void load()
return () => {
requestSequenceRef.current += 1
abortRef.current?.abort()
}
}, [load])
useEffect(() => {
if (!expandedProjectId || !data?.items.some((item) => item.id === expandedProjectId)) return
const frame = window.requestAnimationFrame(() => {
document.getElementById(`work-review-project-${expandedProjectId}`)?.scrollIntoView({
block: 'start',
behavior: 'smooth',
})
})
return () => window.cancelAnimationFrame(frame)
}, [data?.items, expandedProjectId])
const replaceProject = useCallback((project: WorkReviewProject) => {
setData((current) => current ? {
...current,
items: current.items.map((item) => item.id === project.id ? project : item),
} : current)
}, [])
const submitProject = async (input: WorkReviewSubmissionInput) => {
const project = await createWorkReviewProject(input)
setStatusFilter('')
setQuery('')
setDebouncedQuery('')
setPage(1)
setData((current) => current ? {
...current,
items: [project, ...current.items.filter((item) => item.id !== project.id)].slice(0, current.pagination.pageSize),
pagination: { ...current.pagination, page: 1, total: current.pagination.total + 1 },
} : current)
setExpandedProjectId(project.id)
setSubmissionDialog(null)
showToast('作品已提交,评审通知正在发送')
}
const submitVersion = async (projectId: string, input: WorkReviewSubmissionInput) => {
const project = await createWorkReviewVersion(projectId, input)
replaceProject(project)
setExpandedProjectId(project.id)
setSubmissionDialog(null)
showToast(`V${project.latestVersion} 已提交,评审通知正在发送`)
}
const submitReview = async (versionId: string, input: WorkReviewReviewInput) => {
const project = await submitWorkReview(versionId, input)
replaceProject(project)
setReviewDialog(null)
setExpandedProjectId(project.id)
showToast(input.reviewerRole === 'manager' ? '直属主管评审已提交' : '元泽评审已提交')
}
const pageStats = useMemo(() => {
const items = data?.items || []
return {
action: items.filter((item) => item.permissions.canResubmit || item.permissions.pendingReviewRoles.length > 0).length,
pending: items.filter((item) => item.status === 'pending_review').length,
revision: items.filter((item) => item.status === 'revision_required').length,
approved: items.filter((item) => item.status === 'approved').length,
}
}, [data?.items])
if (sessionStatus === 'loading' || (sessionStatus === 'authenticated' && !data && loading)) {
return <LoadingScreen />
}
if (sessionStatus === 'unauthenticated' || !session) return null
const me = data?.me
const canSubmit = Boolean(me?.permissions.canSubmit)
return (
<div className="min-h-screen bg-[#f6f8fb] text-[#101828]">
<WorkReviewHeader
name={me?.user.name || session.user?.name || '当前用户'}
avatar={me?.user.avatar || (session.user as any)?.avatar || null}
canCreate={canSubmit}
onCreate={() => setSubmissionDialog({ mode: 'create' })}
/>
<main className="mx-auto w-full max-w-[1180px] px-4 py-7 pb-16 sm:px-6 sm:py-9">
<section className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-[24px] font-bold tracking-tight text-[#101828]">作品与评审记录</h1>
<p className="mt-1.5 text-[13px] font-medium text-[#667085]">
同一作品的所有提交版本与双评审意见会归档在一张卡片中
</p>
</div>
{data && (
<div className="text-[12px] font-semibold text-[#7b8494]">
共 {data.pagination.total} 个作品
</div>
)}
</section>
<section className="mt-6 grid grid-cols-2 gap-2 sm:grid-cols-4" aria-label="本页状态概览">
{[
{ label: '待我操作', value: pageStats.action, tone: 'text-[#0052d9]', filter: '' as const },
{ label: '评审中', value: pageStats.pending, tone: 'text-[#0052d9]', filter: 'pending_review' as const },
{ label: '待修改', value: pageStats.revision, tone: 'text-[#8a5200]', filter: 'revision_required' as const },
{ label: '已通过', value: pageStats.approved, tone: 'text-[#087a3c]', filter: 'approved' as const },
].map((item) => (
<button
key={item.label}
type="button"
onClick={() => {
setStatusFilter(item.filter)
setPage(1)
setExpandedProjectId('')
}}
className={`rounded-xl border bg-white px-4 py-3 text-left transition-colors ${
statusFilter === item.filter && item.label !== '待我操作'
? 'border-[#8ab9ff] ring-1 ring-[#cfe0ff]'
: 'border-[#e1e7ef] hover:border-[#b8cae2]'
}`}
>
<div className={`text-[22px] font-bold leading-none ${item.tone}`}>{item.value}</div>
<div className="mt-1.5 text-[11px] font-semibold text-[#667085]">{item.label} <span className="font-normal text-[#98a2b3]">· 本页</span></div>
</button>
))}
</section>
<section className="mt-5 rounded-xl border border-[#e1e7ef] bg-white p-3 sm:p-4" aria-label="作品筛选">
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_180px_auto]">
<label className="relative">
<span className="sr-only">搜索作品</span>
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#98a2b3]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索作品名称或提交人"
className="h-11 w-full rounded border border-[#d7dde7] bg-white pl-10 pr-3 text-[13px] outline-none placeholder:text-[#98a2b3] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff]"
/>
</label>
<label>
<span className="sr-only">状态筛选</span>
<select
value={statusFilter}
onChange={(event) => {
setStatusFilter(normalizeStatus(event.target.value))
setPage(1)
setExpandedProjectId('')
}}
className="h-11 w-full rounded border border-[#d7dde7] bg-white px-3 text-[13px] font-semibold text-[#344054] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff]"
>
{statusOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<button
type="button"
onClick={() => void load()}
disabled={loading}
className="h-11 rounded border border-[#d7dde7] bg-white px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:opacity-50"
>
{loading ? '刷新中...' : '刷新'}
</button>
</div>
</section>
{deepLinkedProjectMissing && (
<div className="mt-4 rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-semibold leading-5 text-[#8a5200]">
通知对应的作品不在当前结果页中。请清除筛选后搜索作品名称;如仍未找到,请联系管理员确认访问权限。
</div>
)}
{error && (
<div className="mt-5 rounded-xl border border-[#f0b8b8] bg-[#fff5f5] px-4 py-4" role="alert">
<div className="text-[13px] font-bold text-[#b42318]">看板加载失败</div>
<p className="mt-1 text-[12px] font-medium leading-5 text-[#93000a]">{error}</p>
<button
type="button"
onClick={() => void load()}
className="mt-3 min-h-9 rounded bg-[#b42318] px-4 text-[12px] font-bold text-white hover:bg-[#931f16]"
>
重新加载
</button>
</div>
)}
{data && !error && (
<section className={`mt-5 space-y-3 transition-opacity ${loading ? 'opacity-55' : 'opacity-100'}`} aria-busy={loading}>
{data.items.map((project) => (
<WorkReviewProjectCard
key={project.id}
project={project}
expanded={expandedProjectId === project.id}
onToggle={() => {
const nextId = expandedProjectId === project.id ? '' : project.id
setExpandedProjectId(nextId)
updateUrl({ project: nextId, replaceProject: true })
}}
onResubmit={() => setSubmissionDialog({ mode: 'resubmit', project })}
onReview={(version, role) => setReviewDialog({ project, version, role })}
/>
))}
{data.items.length === 0 && (
<div className="rounded-xl border border-dashed border-[#c8d1dd] bg-white px-5 py-14 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-[#eef3f8] text-[20px] font-bold text-[#7b8494]">0</div>
<h2 className="mt-4 text-[17px] font-bold text-[#101828]">
{debouncedQuery || statusFilter ? '没有符合条件的作品' : '还没有作品记录'}
</h2>
<p className="mt-1.5 text-[12px] font-medium text-[#7b8494]">
{debouncedQuery || statusFilter ? '可以调整搜索词或状态筛选' : canSubmit ? '提交第一份作品,开始双评审流程' : '有作品分配给你后会显示在这里'}
</p>
{(debouncedQuery || statusFilter) ? (
<button
type="button"
onClick={() => {
setQuery('')
setDebouncedQuery('')
setStatusFilter('')
setPage(1)
}}
className="mt-5 min-h-10 rounded border border-[#cfd6e1] px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa]"
>
清除筛选
</button>
) : canSubmit ? (
<button
type="button"
onClick={() => setSubmissionDialog({ mode: 'create' })}
className="mt-5 min-h-10 rounded bg-[#0050cb] px-4 text-[12px] font-bold text-white hover:bg-[#003fa3]"
>
提交作品
</button>
) : null}
</div>
)}
</section>
)}
{data && data.pagination.totalPages > 1 && (
<nav className="mt-6 flex items-center justify-center gap-3" aria-label="作品分页">
<button
type="button"
disabled={page <= 1 || loading}
onClick={() => {
setExpandedProjectId('')
setPage((current) => Math.max(1, current - 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="min-h-10 rounded border border-[#d7dde7] bg-white px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:text-[#b6bdc8]"
>
上一页
</button>
<span className="min-w-[88px] text-center text-[12px] font-semibold text-[#667085]">
{data.pagination.page} / {data.pagination.totalPages}
</span>
<button
type="button"
disabled={page >= data.pagination.totalPages || loading}
onClick={() => {
setExpandedProjectId('')
setPage((current) => Math.min(data.pagination.totalPages, current + 1))
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="min-h-10 rounded border border-[#d7dde7] bg-white px-4 text-[12px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:cursor-not-allowed disabled:text-[#b6bdc8]"
>
下一页
</button>
</nav>
)}
</main>
{submissionDialog && (
<WorkReviewSubmissionDialog
mode={submissionDialog.mode}
project={submissionDialog.project}
onClose={() => setSubmissionDialog(null)}
onSubmit={(input) => submissionDialog.mode === 'create'
? submitProject(input)
: submitVersion(submissionDialog.project!.id, input)}
/>
)}
{reviewDialog && (
<WorkReviewReviewDialog
project={reviewDialog.project}
version={reviewDialog.version}
role={reviewDialog.role}
onClose={() => setReviewDialog(null)}
onSubmit={(input) => submitReview(reviewDialog.version.id, input)}
/>
)}
{toast?.show && <ToastBubble message={toast.message} variant={toast.variant} topOffsetClass="top-20" zIndexClass="z-[1300]" />}
</div>
)
}

View File

@ -0,0 +1,529 @@
'use client'
import Image from 'next/image'
import {
type ReactNode,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { uploadWorkReviewImages } from '@/lib/work-review-client'
import type {
WorkReviewDecision,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewRole,
WorkReviewSubmissionInput,
WorkReviewVersion,
} from '@/types/work-review'
import { WorkReviewImageDropzone } from './WorkReviewImageDropzone'
function todayValue() {
const now = new Date()
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60_000)
return local.toISOString().slice(0, 10)
}
function proxyImageUrl(value: string) {
if (!/^https?:\/\//i.test(value)) return value
try {
const parsed = new URL(value)
if (['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'].includes(parsed.hostname.toLowerCase())) {
return `/api/proxy-media?url=${encodeURIComponent(value)}`
}
} catch {
// 保留原地址。
}
return value
}
function WorkReviewDialogShell({
title,
subtitle,
busy,
onClose,
children,
footer,
widthClass = 'max-w-2xl',
}: {
title: string
subtitle?: string
busy: boolean
onClose: () => void
children: ReactNode
footer: ReactNode
widthClass?: string
}) {
const [ready, setReady] = useState(false)
const titleId = useId()
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => setReady(true), [])
useEffect(() => {
if (!ready) return
const previousOverflow = document.body.style.overflow
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
document.body.style.overflow = 'hidden'
const frame = window.requestAnimationFrame(() => closeRef.current?.focus())
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !busy) {
event.preventDefault()
onClose()
return
}
if (event.key !== 'Tab') return
const elements = Array.from(dialogRef.current?.querySelectorAll<HTMLElement>(
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])',
) || [])
if (elements.length === 0) return
const first = elements[0]
const last = elements[elements.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = previousOverflow
previousFocus?.focus()
}
}, [busy, onClose, ready])
if (!ready) return null
return createPortal(
<div
className="fixed inset-0 z-[1000] flex items-end justify-center bg-black/45 sm:items-center sm:p-5 sm:backdrop-blur-[2px]"
onMouseDown={(event) => {
if (!busy && event.target === event.currentTarget) onClose()
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-busy={busy}
tabIndex={-1}
className={`flex max-h-[94dvh] w-full flex-col overflow-hidden rounded-t-2xl bg-white text-[#101828] shadow-2xl sm:max-h-[calc(100dvh-40px)] sm:rounded-xl ${widthClass}`}
>
<div className="flex shrink-0 items-start justify-between gap-4 border-b border-[#e4e7ec] px-4 py-4 sm:px-5">
<div className="min-w-0">
<h2 id={titleId} className="text-[18px] font-bold">{title}</h2>
{subtitle && <p className="mt-1 text-[12px] font-medium leading-5 text-[#667085]">{subtitle}</p>}
</div>
<button
ref={closeRef}
type="button"
disabled={busy}
onClick={onClose}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[25px] font-light text-[#667085] hover:bg-[#f2f4f7] hover:text-[#101828] disabled:opacity-40"
aria-label="关闭"
>
×
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-5">{children}</div>
<div className="shrink-0 border-t border-[#e4e7ec] bg-white px-4 py-4 sm:px-5">{footer}</div>
</div>
</div>,
document.body,
)
}
export function WorkReviewSubmissionDialog({
mode,
project,
onClose,
onSubmit,
}: {
mode: 'create' | 'resubmit'
project?: WorkReviewProject | null
onClose: () => void
onSubmit: (input: WorkReviewSubmissionInput) => Promise<void>
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [workDate, setWorkDate] = useState(todayValue)
const [submissionNote, setSubmissionNote] = useState('')
const [files, setFiles] = useState<File[]>([])
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<{ message: string; percent?: number } | null>(null)
const isCreate = mode === 'create'
const submit = async () => {
if (isCreate && !title.trim()) {
setError('请填写作品名称')
return
}
if (isCreate && title.trim().length > 120) {
setError('作品名称不能超过 120 个字')
return
}
if (isCreate && !workDate) {
setError('请选择作品日期')
return
}
if (files.length === 0) {
setError('请至少选择 1 张作品图片')
return
}
setBusy(true)
setError(null)
setProgress({ message: '正在准备图片' })
try {
const processedImages = await uploadWorkReviewImages(files, (message, percent) => {
setProgress({ message, percent })
})
await onSubmit({
...(isCreate
? {
title: title.trim(),
description: description.trim(),
workDate,
}
: {}),
submissionNote: submissionNote.trim(),
processedImages,
})
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '提交失败,请重试')
} finally {
setBusy(false)
}
}
return (
<WorkReviewDialogShell
title={isCreate ? '提交新作品' : `提交 ${project?.title || '作品'} 的新版本`}
subtitle={isCreate
? '提交后会同时通知直属主管和元泽进行逐图评审'
: `将创建 V${Number(project?.latestVersion || 0) + 1},历史图片和评审意见会完整保留`}
busy={busy}
onClose={onClose}
footer={(
<div>
{progress && (
<div className="mb-3" aria-live="polite">
<div className="mb-1.5 flex items-center justify-between gap-3 text-[11px] font-semibold text-[#667085]">
<span>{progress.message}</span>
{typeof progress.percent === 'number' && <span>{progress.percent}%</span>}
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-[#e8eef6]">
<div
className={`h-full rounded-full bg-[#0050cb] transition-[width] ${typeof progress.percent !== 'number' ? 'w-2/3 animate-pulse' : ''}`}
style={typeof progress.percent === 'number' ? { width: `${progress.percent}%` } : undefined}
/>
</div>
</div>
)}
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
disabled={busy}
onClick={onClose}
className="min-h-11 rounded border border-[#cfd6e1] px-4 text-[13px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:opacity-45"
>
取消
</button>
<button
type="button"
disabled={busy}
onClick={() => void submit()}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white hover:bg-[#003fa3] disabled:cursor-not-allowed disabled:bg-[#9bbcec]"
>
{busy ? '正在提交...' : isCreate ? '提交并通知评审人' : '提交新版本'}
</button>
</div>
</div>
)}
>
<div className="space-y-5">
{isCreate ? (
<>
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">作品名称 <span className="text-[#b42318]">*</span></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
maxLength={120}
disabled={busy}
placeholder="例如:夏季新品主视觉"
className="h-11 rounded border border-[#cfd6e1] px-3 text-[14px] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
</label>
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">作品日期 <span className="text-[#b42318]">*</span></span>
<input
type="date"
value={workDate}
onChange={(event) => setWorkDate(event.target.value)}
disabled={busy}
className="h-11 rounded border border-[#cfd6e1] px-3 text-[14px] outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
</label>
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">作品说明</span>
<textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
disabled={busy}
rows={3}
placeholder="补充项目背景或本次作品目标(可选)"
className="resize-y rounded border border-[#cfd6e1] px-3 py-2.5 text-[14px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
</label>
</>
) : (
<div className="rounded-lg border border-[#f3d29b] bg-[#fffaf0] px-4 py-3 text-[12px] font-medium leading-5 text-[#8a5200]">
请确认已处理当前版本中的直属主管和元泽评审建议。提交后,两位评审人会收到新一轮通知。
</div>
)}
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">{isCreate ? '提交说明' : '本次修改说明'}</span>
<textarea
value={submissionNote}
onChange={(event) => setSubmissionNote(event.target.value)}
disabled={busy}
rows={3}
placeholder={isCreate ? '补充需要评审人关注的内容(可选)' : '简要说明本次修改了什么(可选)'}
className="resize-y rounded border border-[#cfd6e1] px-3 py-2.5 text-[14px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
</label>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<span className="text-[13px] font-bold text-[#344054]">作品图片 <span className="text-[#b42318]">*</span></span>
<span className="text-[11px] font-medium text-[#7b8494]">{files.length} 张</span>
</div>
<WorkReviewImageDropzone files={files} onChange={setFiles} disabled={busy} />
</div>
{error && (
<div className="rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2.5 text-[12px] font-semibold leading-5 text-[#b42318]" role="alert">
{error}
</div>
)}
</div>
</WorkReviewDialogShell>
)
}
type ReviewDraft = {
decision: WorkReviewDecision | ''
comment: string
}
export function WorkReviewReviewDialog({
project,
version,
role,
onClose,
onSubmit,
}: {
project: WorkReviewProject
version: WorkReviewVersion
role: WorkReviewRole
onClose: () => void
onSubmit: (input: WorkReviewReviewInput) => Promise<void>
}) {
const groups = version.imageGroups || []
const [drafts, setDrafts] = useState<ReviewDraft[]>(() => (
groups.map(() => ({ decision: '', comment: '' }))
))
const [generalComment, setGeneralComment] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const roleLabel = role === 'manager' ? '直属主管评审' : '元泽评审'
const completedCount = drafts.filter((item) => item.decision && item.comment.trim()).length
const finalDecision = useMemo<WorkReviewDecision | null>(() => {
if (completedCount !== groups.length || groups.length === 0) return null
return drafts.some((item) => item.decision === 'revision_required') ? 'revision_required' : 'approved'
}, [completedCount, drafts, groups.length])
const submit = async () => {
if (groups.length === 0) {
setError('当前版本没有可评审的图片')
return
}
const incompleteIndex = drafts.findIndex((item) => !item.decision || !item.comment.trim())
if (incompleteIndex >= 0) {
setError(`请完成图片 ${incompleteIndex + 1} 的评审结论和修改建议`)
return
}
if (drafts.some((item) => item.comment.trim().length > 2000)) {
setError('单张图片的评审建议不能超过 2000 个字')
return
}
if (generalComment.trim().length > 4000) {
setError('总体意见不能超过 4000 个字')
return
}
setBusy(true)
setError(null)
try {
await onSubmit({
reviewerRole: role,
imageComments: drafts.map((item, imageIndex) => ({
imageIndex,
decision: item.decision as WorkReviewDecision,
comment: item.comment.trim(),
})),
generalComment: generalComment.trim(),
})
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : '评审提交失败')
} finally {
setBusy(false)
}
}
return (
<WorkReviewDialogShell
title={`${roleLabel} · V${version.version}`}
subtitle={`${project.title} · 请逐张选择结论并填写评审建议`}
busy={busy}
onClose={onClose}
widthClass="max-w-3xl"
footer={(
<div>
<div className={`mb-3 flex items-center justify-between gap-3 rounded px-3 py-2 text-[12px] font-semibold ${
finalDecision === 'approved'
? 'bg-[#ecfdf3] text-[#087a3c]'
: finalDecision === 'revision_required'
? 'bg-[#fff8e8] text-[#8a5200]'
: 'bg-[#f2f4f7] text-[#667085]'
}`}>
<span>
{finalDecision === 'approved'
? '全部图片通过'
: finalDecision === 'revision_required'
? '本区域结论:需修改'
: `已完成 ${completedCount}/${groups.length} 张`}
</span>
<span>{generalComment.length}/4000</span>
</div>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
disabled={busy}
onClick={onClose}
className="min-h-11 rounded border border-[#cfd6e1] px-4 text-[13px] font-bold text-[#344054] hover:bg-[#f7f8fa] disabled:opacity-45"
>
取消
</button>
<button
type="button"
disabled={busy || completedCount !== groups.length}
onClick={() => void submit()}
className="min-h-11 rounded bg-[#0050cb] px-5 text-[13px] font-bold text-white hover:bg-[#003fa3] disabled:cursor-not-allowed disabled:bg-[#9bbcec]"
>
{busy ? '正在提交...' : `提交${roleLabel}`}
</button>
</div>
</div>
)}
>
<div className="space-y-4">
{groups.map((group, index) => {
const draft = drafts[index]
return (
<section key={`${group.sourceName}-${index}`} className="rounded-xl border border-[#dfe6f0] bg-white p-3 sm:p-4">
<div className="grid gap-4 sm:grid-cols-[150px_minmax(0,1fr)]">
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border border-[#e1e7ef] bg-[#f6f8fb] sm:aspect-auto sm:h-[130px]">
<Image
src={proxyImageUrl(group.previewUrl || group.slices?.[0]?.url || '')}
alt={group.sourceName}
fill
unoptimized
className="object-cover object-top"
/>
<span className="absolute left-2 top-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
图 {index + 1}
</span>
</div>
<div className="min-w-0">
<div className="truncate text-[13px] font-bold text-[#101828]" title={group.sourceName}>{group.sourceName}</div>
<div className="mt-2 grid grid-cols-2 gap-2">
{([
['approved', '通过'],
['revision_required', '需修改'],
] as const).map(([decision, label]) => (
<button
key={decision}
type="button"
disabled={busy}
onClick={() => {
setError(null)
setDrafts((current) => current.map((item, itemIndex) => (
itemIndex === index ? { ...item, decision } : item
)))
}}
className={`min-h-10 rounded border text-[12px] font-bold transition-colors ${
draft.decision === decision
? decision === 'approved'
? 'border-[#149447] bg-[#ecfdf3] text-[#087a3c]'
: 'border-[#d49a31] bg-[#fff8e8] text-[#8a5200]'
: 'border-[#d7dde7] bg-white text-[#667085] hover:border-[#9db7dd]'
}`}
>
{label}
</button>
))}
</div>
<textarea
value={draft.comment}
onChange={(event) => {
setError(null)
setDrafts((current) => current.map((item, itemIndex) => (
itemIndex === index ? { ...item, comment: event.target.value.slice(0, 2000) } : item
)))
}}
disabled={busy}
rows={3}
placeholder="填写这张图的评审建议(必填)"
className="mt-2 w-full resize-y rounded border border-[#cfd6e1] px-3 py-2 text-[13px] leading-5 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
<div className="mt-1 text-right text-[10px] text-[#98a2b3]">{draft.comment.length}/2000</div>
</div>
</div>
</section>
)
})}
<label className="grid gap-2">
<span className="text-[13px] font-bold text-[#344054]">总体意见</span>
<textarea
value={generalComment}
onChange={(event) => setGeneralComment(event.target.value.slice(0, 4000))}
disabled={busy}
rows={4}
placeholder="补充本轮整体方向或优先级(可选)"
className="resize-y rounded border border-[#cfd6e1] px-3 py-2.5 text-[13px] leading-6 outline-none focus:border-[#0050cb] focus:ring-2 focus:ring-[#dcecff] disabled:bg-[#f2f4f7]"
/>
</label>
{error && (
<div className="rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2.5 text-[12px] font-semibold leading-5 text-[#b42318]" role="alert">
{error}
</div>
)}
</div>
</WorkReviewDialogShell>
)
}

View File

@ -0,0 +1,62 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
export function WorkReviewHeader({
name,
avatar,
onCreate,
canCreate,
}: {
name: string
avatar?: string | null
canCreate: boolean
onCreate: () => void
}) {
const initials = name.trim().slice(0, 2) || '我'
return (
<header className="sticky top-0 z-30 bg-[#1f2937] text-white shadow-sm">
<div className="mx-auto flex min-h-[64px] w-full max-w-[1280px] items-center justify-between gap-4 px-4 py-3 sm:px-6">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-white/15 bg-white/15 text-[12px] font-bold">
{avatar ? (
<Image
src={avatar}
alt={name}
width={36}
height={36}
unoptimized
className="h-full w-full object-cover"
/>
) : initials}
</div>
<div className="min-w-0">
<div className="truncate text-[16px] font-bold">作品过稿看板</div>
<div className="truncate text-[11px] font-medium text-white/65">{name}</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{canCreate && (
<button
type="button"
onClick={onCreate}
className="inline-flex min-h-9 items-center justify-center rounded bg-[#0050cb] px-3 text-[12px] font-bold text-white transition-colors hover:bg-[#003fa3] sm:px-4 sm:text-[13px]"
>
<span className="mr-1 text-[17px] font-light leading-none">+</span>
提交作品
</button>
)}
<Link
href="/"
className="inline-flex min-h-9 items-center justify-center rounded px-2 text-[12px] font-semibold text-white/80 transition-colors hover:bg-white/10 hover:text-white sm:px-3 sm:text-[13px]"
>
返回素材库
</Link>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,154 @@
'use client'
import Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
const MAX_FILE_COUNT = 60
const MAX_FILE_BYTES = 200 * 1024 * 1024
function fileKey(file: File) {
return `${file.name}-${file.size}-${file.lastModified}`
}
export function WorkReviewImageDropzone({
files,
onChange,
disabled,
}: {
files: File[]
onChange: (files: File[]) => void
disabled?: boolean
}) {
const inputRef = useRef<HTMLInputElement | null>(null)
const dragDepthRef = useRef(0)
const [dragging, setDragging] = useState(false)
const [error, setError] = useState<string | null>(null)
const previewUrls = useMemo(() => files.map((file) => URL.createObjectURL(file)), [files])
useEffect(() => () => {
previewUrls.forEach((url) => URL.revokeObjectURL(url))
}, [previewUrls])
const addFiles = (selected: FileList | File[]) => {
if (disabled) return
const candidates = [...files, ...Array.from(selected)]
const deduplicated = candidates.filter((file, index, items) => (
items.findIndex((candidate) => fileKey(candidate) === fileKey(file)) === index
))
const invalid = deduplicated.find((file) => !file.type.startsWith('image/'))
if (invalid) {
setError(`文件「${invalid.name}」不是图片`)
return
}
const oversized = deduplicated.find((file) => file.size > MAX_FILE_BYTES)
if (oversized) {
setError(`图片「${oversized.name}」超过 200MB`)
return
}
if (deduplicated.length > MAX_FILE_COUNT) {
setError(`一次最多选择 ${MAX_FILE_COUNT} 张图片`)
return
}
setError(null)
onChange(deduplicated)
}
return (
<div>
<input
ref={inputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
multiple
disabled={disabled}
className="hidden"
onChange={(event) => {
addFiles(event.currentTarget.files || [])
event.currentTarget.value = ''
}}
/>
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault()
if (disabled) return
dragDepthRef.current += 1
setDragging(true)
}}
onDragOver={(event) => {
event.preventDefault()
if (!disabled) event.dataTransfer.dropEffect = 'copy'
}}
onDragLeave={(event) => {
event.preventDefault()
if (disabled) return
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)
if (dragDepthRef.current === 0) setDragging(false)
}}
onDrop={(event) => {
event.preventDefault()
dragDepthRef.current = 0
setDragging(false)
addFiles(event.dataTransfer.files)
}}
className={`flex min-h-28 w-full flex-col items-center justify-center rounded-lg border border-dashed px-4 py-5 text-center transition-all disabled:cursor-not-allowed disabled:opacity-60 ${
dragging
? 'scale-[1.01] border-[#0050cb] bg-[#eef5ff]'
: 'border-[#aebbd0] bg-[#fbfcfe] hover:border-[#0050cb] hover:bg-[#f7faff]'
}`}
>
<span className={`flex h-9 w-9 items-center justify-center rounded-full text-[21px] ${
dragging ? 'bg-[#0050cb] text-white' : 'bg-[#eaf2ff] text-[#0050cb]'
}`}>↑</span>
<span className="mt-2 text-[13px] font-bold text-[#0050cb]">
{dragging ? '松开即可添加' : '拖拽图片到这里,或点击选择'}
</span>
<span className="mt-1 text-[11px] leading-5 text-[#7b8494]">
支持 PNG、JPG、GIF、WebP;长图会自动生成预览与切片
</span>
</button>
{error && <div className="mt-2 text-[12px] font-semibold text-[#b42318]" role="alert">{error}</div>}
{files.length > 0 && (
<div className="mt-3 grid grid-cols-2 gap-2 sm:grid-cols-3">
{files.map((file, index) => (
<div key={fileKey(file)} className="group relative overflow-hidden rounded-lg border border-[#e1e7ef] bg-white">
<div className="relative aspect-[4/3]">
<Image
src={previewUrls[index]}
alt={file.name}
fill
unoptimized
className="object-cover"
/>
<span className="absolute left-2 top-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
{index + 1}
</span>
<button
type="button"
disabled={disabled}
onClick={() => onChange(files.filter((_, fileIndex) => fileIndex !== index))}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/65 text-[18px] text-white opacity-90 transition-opacity hover:bg-[#b42318] disabled:cursor-not-allowed"
aria-label={`移除 ${file.name}`}
>
×
</button>
</div>
<div className="min-w-0 px-2.5 py-2">
<div className="truncate text-[11px] font-semibold text-[#344054]" title={file.name}>{file.name}</div>
<div className="mt-0.5 text-[10px] text-[#98a2b3]">
{file.size < 1024 * 1024
? `${Math.max(1, Math.round(file.size / 1024))} KB`
: `${(file.size / 1024 / 1024).toFixed(1)} MB`}
</div>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@ -0,0 +1,248 @@
'use client'
import Image from 'next/image'
import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { WorkReviewImageGroup } from '@/types/work-review'
const PROXY_HOSTS = new Set(['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'])
function normalizeImageUrl(value: string) {
const url = String(value || '').trim()
if (!url || !/^https?:\/\//i.test(url)) return url
try {
const parsed = new URL(url)
return PROXY_HOSTS.has(parsed.hostname.toLowerCase())
? `/api/proxy-media?url=${encodeURIComponent(url)}`
: url
} catch {
return url
}
}
function normalizeGroups(groups: WorkReviewImageGroup[], imageUrls: string[]): WorkReviewImageGroup[] {
if (groups.length > 0) {
return groups.map((group, index) => ({
...group,
sourceName: group.sourceName || `作品图片 ${index + 1}`,
previewUrl: normalizeImageUrl(group.previewUrl || group.slices?.[0]?.url || ''),
slices: (group.slices || []).map((slice) => ({
...slice,
url: normalizeImageUrl(slice.url),
})),
})).filter((group) => Boolean(group.previewUrl))
}
return imageUrls.map((url, index) => ({
sourceName: `作品图片 ${index + 1}`,
previewUrl: normalizeImageUrl(url),
previewWidth: 1200,
previewHeight: 900,
width: 1200,
height: 900,
slices: [{ url: normalizeImageUrl(url), width: 1200, height: 900 }],
}))
}
export function WorkReviewImageGallery({
groups,
imageUrls = [],
compact = false,
}: {
groups: WorkReviewImageGroup[]
imageUrls?: string[]
compact?: boolean
}) {
const [activeIndex, setActiveIndex] = useState<number | null>(null)
const displayGroups = useMemo(() => normalizeGroups(groups || [], imageUrls || []), [groups, imageUrls])
if (displayGroups.length === 0) {
return (
<div className="rounded-lg border border-dashed border-[#cfd7e3] bg-[#fafbfc] px-4 py-8 text-center text-[12px] font-semibold text-[#7b8494]">
暂无作品图片
</div>
)
}
return (
<>
<div className={`grid gap-2 ${compact ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 md:grid-cols-3'}`}>
{displayGroups.map((group, index) => (
<button
key={group.imageId || `${group.sourceName}-${index}`}
type="button"
onClick={() => setActiveIndex(index)}
className="group overflow-hidden rounded-lg border border-[#e1e7ef] bg-[#f6f8fb] text-left transition-colors hover:border-[#8ab9ff]"
aria-label={`查看${group.sourceName}`}
>
<div className={`relative overflow-hidden ${compact ? 'aspect-[4/3]' : 'aspect-[4/3]'}`}>
<Image
src={group.previewUrl}
alt={group.sourceName}
fill
unoptimized
className="object-cover object-top transition-transform duration-300 group-hover:scale-[1.02]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/65 to-transparent px-2.5 pb-2 pt-7">
<div className="truncate text-[11px] font-semibold text-white">{group.sourceName}</div>
</div>
{group.slices.length > 1 && (
<span className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-0.5 text-[10px] font-bold text-white">
长图 · {group.slices.length} 段
</span>
)}
</div>
</button>
))}
</div>
<WorkReviewImageViewer
group={activeIndex == null ? null : displayGroups[activeIndex]}
index={activeIndex}
total={displayGroups.length}
onClose={() => setActiveIndex(null)}
onPrevious={() => setActiveIndex((current) => (
current == null ? null : (current - 1 + displayGroups.length) % displayGroups.length
))}
onNext={() => setActiveIndex((current) => (
current == null ? null : (current + 1) % displayGroups.length
))}
/>
</>
)
}
function WorkReviewImageViewer({
group,
index,
total,
onClose,
onPrevious,
onNext,
}: {
group: WorkReviewImageGroup | null
index: number | null
total: number
onClose: () => void
onPrevious: () => void
onNext: () => void
}) {
const [portalReady, setPortalReady] = useState(false)
const dialogRef = useRef<HTMLDivElement | null>(null)
const closeRef = useRef<HTMLButtonElement | null>(null)
useEffect(() => setPortalReady(true), [])
useEffect(() => {
if (!group) return
const previousOverflow = document.body.style.overflow
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
document.body.style.overflow = 'hidden'
const frame = window.requestAnimationFrame(() => closeRef.current?.focus())
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
} else if (event.key === 'ArrowLeft' && total > 1) {
event.preventDefault()
onPrevious()
} else if (event.key === 'ArrowRight' && total > 1) {
event.preventDefault()
onNext()
} else if (event.key === 'Tab') {
const buttons = Array.from(dialogRef.current?.querySelectorAll<HTMLButtonElement>('button:not([disabled])') || [])
if (buttons.length === 0) return
const first = buttons[0]
const last = buttons[buttons.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.cancelAnimationFrame(frame)
window.removeEventListener('keydown', onKeyDown)
document.body.style.overflow = previousOverflow
previousFocus?.focus()
}
}, [group, onClose, onNext, onPrevious, total])
if (!group || index == null || !portalReady) return null
return createPortal(
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`查看${group.sourceName}`}
tabIndex={-1}
className="fixed inset-0 z-[1200] flex flex-col bg-black/[0.94] text-white"
onClick={onClose}
>
<div className="flex min-h-14 shrink-0 items-center justify-between gap-4 border-b border-white/10 px-4" onClick={(event) => event.stopPropagation()}>
<div className="min-w-0">
<div className="truncate text-[13px] font-semibold">{group.sourceName}</div>
<div className="mt-0.5 text-[11px] text-white/55">{index + 1} / {total}</div>
</div>
<button
ref={closeRef}
type="button"
onClick={onClose}
className="flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-[27px] font-light hover:bg-white/20"
aria-label="关闭图片预览"
>
×
</button>
</div>
<div
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-3 sm:px-6"
onClick={(event) => event.stopPropagation()}
>
{total > 1 && (
<>
<button
type="button"
onClick={onPrevious}
className="fixed left-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-2xl hover:bg-black/70"
aria-label="上一张"
>
‹
</button>
<button
type="button"
onClick={onNext}
className="fixed right-3 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-black/45 text-2xl hover:bg-black/70"
aria-label="下一张"
>
›
</button>
</>
)}
<div className="mx-auto max-w-[1200px] overflow-hidden rounded bg-white">
{(group.slices.length > 0 ? group.slices : [{
url: group.previewUrl,
width: group.previewWidth,
height: group.previewHeight,
}]).map((slice, sliceIndex) => (
<Image
key={`${slice.url}-${sliceIndex}`}
src={slice.url}
alt={`${group.sourceName}${sliceIndex + 1}`}
width={slice.width}
height={slice.height}
unoptimized
loading="lazy"
className="block h-auto w-full"
/>
))}
</div>
</div>
</div>,
document.body,
)
}

View File

@ -0,0 +1,334 @@
'use client'
import Image from 'next/image'
import { useEffect, useMemo, useState } from 'react'
import type {
WorkReviewProject,
WorkReviewReview,
WorkReviewRole,
WorkReviewVersion,
} from '@/types/work-review'
import { WorkReviewImageGallery } from './WorkReviewImageGallery'
import { WorkReviewDecisionBadge, WorkReviewStatusBadge } from './WorkReviewStatusBadge'
function formatDateOnly(value: string) {
const raw = String(value || '')
const datePart = raw.match(/^\d{4}-\d{2}-\d{2}/)?.[0]
return datePart || '-'
}
function formatDateTime(value?: string | null) {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(date)
}
function proxyImageUrl(value: string) {
if (!/^https?:\/\//i.test(value)) return value
try {
const parsed = new URL(value)
if (['s3.jiansun.vip', 's3.yydd.ai', 's3.1080p.hk'].includes(parsed.hostname.toLowerCase())) {
return `/api/proxy-media?url=${encodeURIComponent(value)}`
}
} catch {
// 使用原地址。
}
return value
}
function getVersionReview(version: WorkReviewVersion, role: WorkReviewRole) {
return version.reviews.find((review) => review.reviewerRole === role) || null
}
function ReviewProgress({
project,
version,
}: {
project: WorkReviewProject
version: WorkReviewVersion
}) {
const roles: Array<{ role: WorkReviewRole; label: string; name: string }> = [
{ role: 'manager', label: '主管', name: project.manager.name },
{ role: 'final_reviewer', label: '元泽', name: project.finalReviewer.name },
]
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
{roles.map((item) => {
const review = getVersionReview(version, item.role)
return (
<span key={item.role} className="inline-flex items-center gap-1.5 text-[11px] font-semibold text-[#667085]" title={item.name}>
<span className={`h-2 w-2 rounded-full ${review ? 'bg-[#149447]' : 'bg-[#d6dce5]'}`} />
{item.label}{review ? '已评' : '待评'}
</span>
)
})}
</div>
)
}
export function WorkReviewProjectCard({
project,
expanded,
onToggle,
onResubmit,
onReview,
}: {
project: WorkReviewProject
expanded: boolean
onToggle: () => void
onResubmit: () => void
onReview: (version: WorkReviewVersion, role: WorkReviewRole) => void
}) {
const latestVersion = project.versions[0]
const [selectedVersionId, setSelectedVersionId] = useState(latestVersion?.id || '')
useEffect(() => {
if (!project.versions.some((version) => version.id === selectedVersionId)) {
setSelectedVersionId(project.versions[0]?.id || '')
}
}, [project.versions, selectedVersionId])
const selectedVersion = useMemo(
() => project.versions.find((version) => version.id === selectedVersionId) || latestVersion,
[latestVersion, project.versions, selectedVersionId],
)
const cover = latestVersion?.imageGroups?.[0]?.previewUrl || latestVersion?.imageUrls?.[0] || ''
const isLatestSelected = selectedVersion?.id === latestVersion?.id
if (!latestVersion || !selectedVersion) return null
return (
<article
id={`work-review-project-${project.id}`}
className={`overflow-hidden rounded-xl border bg-white transition-shadow ${
expanded ? 'border-[#9ebeff] shadow-[0_14px_38px_rgba(20,52,102,0.10)]' : 'border-[#e1e7ef] hover:shadow-[0_8px_24px_rgba(15,23,42,0.07)]'
}`}
>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="grid w-full gap-4 p-4 text-left sm:grid-cols-[132px_minmax(0,1fr)_auto] sm:items-center"
>
<div className="relative aspect-[4/3] overflow-hidden rounded-lg border border-[#e5eaf1] bg-[#f2f4f7] sm:h-[92px] sm:w-[132px]">
{cover ? (
<Image
src={proxyImageUrl(cover)}
alt={project.title}
fill
unoptimized
className="object-cover object-top"
/>
) : (
<span className="flex h-full items-center justify-center text-[12px] font-semibold text-[#98a2b3]">暂无图片</span>
)}
<span className="absolute bottom-2 right-2 rounded-full bg-black/65 px-2 py-0.5 text-[10px] font-bold text-white">
V{project.latestVersion}
</span>
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="min-w-0 truncate text-[17px] font-bold text-[#101828]">{project.title}</h2>
<WorkReviewStatusBadge status={project.status} />
</div>
{project.description && (
<p className="mt-1.5 line-clamp-2 text-[12px] font-medium leading-5 text-[#667085]">{project.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px] font-medium text-[#7b8494]">
<span>提交人:{project.submitter.name}</span>
<span>作品日期:{formatDateOnly(project.workDate)}</span>
<span>最近提交:{formatDateTime(latestVersion.submittedAt)}</span>
</div>
<div className="mt-2">
<ReviewProgress project={project} version={latestVersion} />
</div>
</div>
<span className={`hidden h-9 w-9 items-center justify-center rounded-full bg-[#f2f4f7] text-[18px] text-[#667085] transition-transform sm:flex ${
expanded ? 'rotate-180' : ''
}`}>
⌄
</span>
</button>
{expanded && (
<div className="border-t border-[#e5eaf1] bg-[#fbfcfe] px-4 py-5 sm:px-5">
<div className="grid gap-5 lg:grid-cols-[170px_minmax(0,1fr)]">
<aside>
<div className="mb-2 text-[11px] font-bold uppercase tracking-[0.08em] text-[#7b8494]">版本记录</div>
<div className="flex gap-2 overflow-x-auto pb-1 lg:grid lg:overflow-visible">
{project.versions.map((version, index) => {
const active = version.id === selectedVersion.id
return (
<button
key={version.id}
type="button"
onClick={() => setSelectedVersionId(version.id)}
className={`min-w-[142px] rounded-lg border px-3 py-2.5 text-left transition-colors lg:min-w-0 ${
active
? 'border-[#0050cb] bg-[#eef5ff]'
: 'border-[#dfe5ee] bg-white hover:border-[#abc7ef]'
}`}
>
<div className="flex items-center justify-between gap-2">
<span className={`text-[13px] font-bold ${active ? 'text-[#0050cb]' : 'text-[#344054]'}`}>V{version.version}</span>
{index === 0 && <span className="text-[9px] font-bold text-[#0050cb]">最新</span>}
</div>
<div className="mt-1 text-[10px] text-[#7b8494]">{formatDateTime(version.submittedAt)}</div>
<div className="mt-2"><WorkReviewStatusBadge status={version.status} compact /></div>
</button>
)
})}
</div>
</aside>
<div className="min-w-0">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h3 className="text-[17px] font-bold text-[#101828]">V{selectedVersion.version} 作品内容</h3>
{!isLatestSelected && <span className="text-[11px] font-semibold text-[#7b8494]">历史版本 · 只读</span>}
</div>
<p className="mt-1 text-[11px] font-medium text-[#7b8494]">
提交于 {formatDateTime(selectedVersion.submittedAt)}
{selectedVersion.completedAt ? ` · 评审完成于 ${formatDateTime(selectedVersion.completedAt)}` : ''}
</p>
</div>
{isLatestSelected && project.permissions.canResubmit && (
<button
type="button"
onClick={onResubmit}
className="min-h-9 rounded bg-[#0050cb] px-3.5 text-[12px] font-bold text-white hover:bg-[#003fa3]"
>
提交新版本
</button>
)}
</div>
{selectedVersion.submissionNote && (
<div className="mb-4 rounded-lg border-l-2 border-[#8aafff] bg-[#f5f8ff] px-3 py-2.5">
<div className="text-[11px] font-bold text-[#526173]">本次提交说明</div>
<p className="mt-1 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#667085]">{selectedVersion.submissionNote}</p>
</div>
)}
<WorkReviewImageGallery
groups={selectedVersion.imageGroups || []}
imageUrls={selectedVersion.imageUrls || []}
/>
<div className="mt-6">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-[15px] font-bold text-[#101828]">双评审意见</h3>
<ReviewProgress project={project} version={selectedVersion} />
</div>
<div className="grid gap-3 xl:grid-cols-2">
<ReviewPanel
label="直属主管评审"
reviewerName={project.manager.name}
review={getVersionReview(selectedVersion, 'manager')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('manager')
)}
onReview={() => onReview(selectedVersion, 'manager')}
/>
<ReviewPanel
label="元泽评审"
reviewerName={project.finalReviewer.name}
review={getVersionReview(selectedVersion, 'final_reviewer')}
canReview={Boolean(
isLatestSelected
&& project.permissions.pendingReviewRoles.includes('final_reviewer')
)}
onReview={() => onReview(selectedVersion, 'final_reviewer')}
/>
</div>
</div>
</div>
</div>
</div>
)}
</article>
)
}
function ReviewPanel({
label,
reviewerName,
review,
canReview,
onReview,
}: {
label: string
reviewerName: string
review: WorkReviewReview | null
canReview: boolean
onReview: () => void
}) {
return (
<section className={`rounded-xl border bg-white p-4 ${
review ? 'border-[#dce5ef]' : canReview ? 'border-[#9ebeff]' : 'border-[#e1e7ef]'
}`}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h4 className="text-[14px] font-bold text-[#101828]">{label}</h4>
<div className="mt-0.5 truncate text-[11px] font-medium text-[#7b8494]">{review?.reviewerName || reviewerName}</div>
</div>
{review ? (
<WorkReviewDecisionBadge decision={review.decision} />
) : (
<span className="inline-flex rounded-full bg-[#f2f4f7] px-2 py-0.5 text-[11px] font-bold text-[#667085]">待评审</span>
)}
</div>
{review ? (
<div className="mt-4 space-y-3">
{(review.imageComments || []).map((item) => (
<div key={item.imageId || item.imageIndex} className="rounded-lg bg-[#f7f9fc] px-3 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="truncate text-[11px] font-bold text-[#344054]">
图 {item.imageIndex + 1}{item.sourceName ? ` · ${item.sourceName}` : ''}
</div>
<WorkReviewDecisionBadge decision={item.decision} />
</div>
<p className="mt-1.5 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#526173]">{item.comment}</p>
</div>
))}
{review.generalComment && (
<div className="border-t border-[#e5eaf1] pt-3">
<div className="text-[11px] font-bold text-[#7b8494]">总体意见</div>
<p className="mt-1 whitespace-pre-wrap text-[12px] font-medium leading-5 text-[#526173]">{review.generalComment}</p>
</div>
)}
<div className="text-right text-[10px] font-medium text-[#98a2b3]">提交于 {formatDateTime(review.reviewedAt)}</div>
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed border-[#d7dde7] bg-[#fafbfc] px-3 py-6 text-center">
<p className="text-[12px] font-semibold text-[#7b8494]">
{canReview ? '当前版本等待你填写逐图评审建议' : '等待评审人提交'}
</p>
{canReview && (
<button
type="button"
onClick={onReview}
className="mt-3 min-h-9 rounded bg-[#0050cb] px-4 text-[12px] font-bold text-white hover:bg-[#003fa3]"
>
开始评审
</button>
)}
</div>
)}
</section>
)
}

View File

@ -0,0 +1,44 @@
import type { WorkReviewDecision, WorkReviewStatus } from '@/types/work-review'
const statusMeta: Record<WorkReviewStatus, { label: string; className: string }> = {
pending_review: {
label: '评审中',
className: 'border-[#b8d0ff] bg-[#edf4ff] text-[#0052d9]',
},
revision_required: {
label: '待修改',
className: 'border-[#f3d29b] bg-[#fff8e8] text-[#8a5200]',
},
approved: {
label: '已通过',
className: 'border-[#b9ead0] bg-[#ecfdf3] text-[#087a3c]',
},
}
export function WorkReviewStatusBadge({
status,
compact = false,
}: {
status: WorkReviewStatus
compact?: boolean
}) {
const meta = statusMeta[status] || statusMeta.pending_review
return (
<span className={`inline-flex shrink-0 items-center rounded-full border font-bold ${meta.className} ${
compact ? 'px-2 py-0.5 text-[10px]' : 'px-2.5 py-1 text-[12px]'
}`}>
{meta.label}
</span>
)
}
export function WorkReviewDecisionBadge({ decision }: { decision: WorkReviewDecision }) {
const approved = decision === 'approved'
return (
<span className={`inline-flex shrink-0 rounded-full px-2 py-0.5 text-[11px] font-bold ${
approved ? 'bg-[#ecfdf3] text-[#087a3c]' : 'bg-[#fff4e5] text-[#8a5200]'
}`}>
{approved ? '通过' : '需修改'}
</span>
)
}

View File

@ -0,0 +1,139 @@
name: gongxiangp2-local-test
services:
postgres:
image: docker.m.daocloud.io/library/postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: gongxiangp2_test
POSTGRES_USER: workreview
POSTGRES_PASSWORD: ${LOCAL_POSTGRES_PASSWORD}
ports:
- "127.0.0.1:55432:5432"
volumes:
- local-test-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U workreview -d gongxiangp2_test"]
interval: 5s
timeout: 3s
retries: 30
redis:
image: docker.1ms.run/library/redis:7-alpine
restart: unless-stopped
command: redis-server --save "" --appendonly no
ports:
- "127.0.0.1:56379:6379"
volumes:
- local-test-redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 30
minio:
image: docker.m.daocloud.io/minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${LOCAL_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${LOCAL_MINIO_ROOT_PASSWORD}
ports:
- "127.0.0.1:59000:9000"
- "127.0.0.1:59001:9001"
volumes:
- local-test-minio:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 3s
retries: 30
minio-init:
image: docker.m.daocloud.io/minio/mc:latest
restart: "no"
depends_on:
minio:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- >
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" &&
mc mb --ignore-existing local/gongxiangp2-test &&
mc anonymous set download local/gongxiangp2-test
environment:
MINIO_ROOT_USER: ${LOCAL_MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${LOCAL_MINIO_ROOT_PASSWORD}
app:
build:
context: .
dockerfile: Dockerfile
args:
BASE_IMAGE: docker.m.daocloud.io/library/node:18-alpine
image: gongxiangp2-local-test:latest
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio-init:
condition: service_completed_successfully
ports:
- "127.0.0.1:4321:3000"
environment:
NODE_ENV: production
PORT: 3000
DATABASE_URL: postgresql://workreview:${LOCAL_POSTGRES_PASSWORD}@postgres:5432/gongxiangp2_test
NEXTAUTH_URL: http://localhost:4321
APP_PUBLIC_URL: http://localhost:4321
NEXT_PUBLIC_APP_PUBLIC_URL: http://localhost:4321
NEXTAUTH_SECRET: ${LOCAL_NEXTAUTH_SECRET}
CORS_ALLOWED_ORIGINS: http://localhost:4321,http://127.0.0.1:4321
UPLOAD_DIR: /app/uploads
USE_REDIS: "true"
REDIS_URL: redis://redis:6379
STORAGE_PROVIDER: s3
S3_ENDPOINT: http://minio:9000
S3_BUCKET: gongxiangp2-test
S3_ACCESS_KEY_ID: ${LOCAL_MINIO_ROOT_USER}
S3_SECRET_ACCESS_KEY: ${LOCAL_MINIO_ROOT_PASSWORD}
S3_REGION: us-east-1
S3_PUBLIC_DOMAIN: http://localhost:59000/gongxiangp2-test
S3_FILE_PATH: local-test/
S3_ENABLE_PATH_STYLE: "1"
S3_SET_ACL: "1"
SERVER_IP: 127.0.0.1
MINIO_API_PORT: 59000
KEY_VAULTS_SECRET: ${LOCAL_KEY_VAULTS_SECRET}
PGVECTOR_DB_URL: postgresql://workreview:${LOCAL_POSTGRES_PASSWORD}@postgres:5432/gongxiangp2_test
DOWNLOAD_TOKEN_SECRET: ${LOCAL_DOWNLOAD_TOKEN_SECRET}
ADMIN_API_KEY: ${LOCAL_ADMIN_API_KEY}
GEMINI_API_KEY: ""
DINGTALK_CLIENT_ID: ""
DINGTALK_CLIENT_SECRET: ""
DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL: work_notice
YYD_NOTIFICATION_CENTER_URL: ""
PERFORMANCE_SURVEY_DAILY_SYNC_INITIAL_DELAY_SECONDS: "86400"
volumes:
- local-test-uploads:/app/uploads
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/login').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 30
start_period: 30s
volumes:
local-test-postgres:
local-test-redis:
local-test-minio:
local-test-uploads:

View File

@ -0,0 +1,61 @@
/** @jest-environment node */
import { serializePerformanceSurveyImagePayload } from '@/lib/performance-survey-image-processing'
import { validatePerformanceSurveyProcessedImages } from '@/lib/performance-survey-processed-images'
function slice(width: number) {
return {
uploadId: 'work-review-slice',
mimeType: 'image/webp',
width,
height: 1500,
kind: 'slice',
sourceName: '作品主视觉.png',
sourceIndex: 0,
sliceIndex: 0,
sliceCount: 1,
}
}
describe('work review image profile', () => {
test('keeps the performance survey default width at 1600 pixels', () => {
expect(() => validatePerformanceSurveyProcessedImages([
slice(3200),
])).toThrow('图片宽度无效')
})
test('allows 3200 pixel review slices only when explicitly requested', () => {
const groups = validatePerformanceSurveyProcessedImages(
[slice(3200)],
{ maxSliceWidth: 3200 },
)
expect(groups[0].slices[0].width).toBe(3200)
expect(() => validatePerformanceSurveyProcessedImages(
[slice(3201)],
{ maxSliceWidth: 3200 },
)).toThrow('图片宽度无效')
})
test('preserves the stable image id used by per-image review comments', () => {
const payload = serializePerformanceSurveyImagePayload([{
version: 1,
imageId: 'image-stable-id',
sourceName: '作品主视觉.png',
sourceWidth: 3200,
sourceHeight: 1500,
width: 3200,
height: 1500,
previewUrl: 'https://s3.example.com/preview.webp',
previewWidth: 720,
previewHeight: 338,
slices: [{
url: 'https://s3.example.com/slice.webp',
width: 3200,
height: 1500,
}],
}])
expect(payload.imageGroups[0].imageId).toBe('image-stable-id')
})
})

View File

@ -0,0 +1,35 @@
/** @jest-environment node */
import {
deriveCompletedWorkReviewStatus,
deriveWorkReviewDecision,
} from '@/lib/work-review-policy'
describe('work review policy', () => {
test('marks one reviewer region as revision required when any image needs changes', () => {
expect(deriveWorkReviewDecision([
{ decision: 'approved' },
{ decision: 'revision_required' },
])).toBe('revision_required')
})
test('does not complete the version until both independent reviewer regions exist', () => {
expect(deriveCompletedWorkReviewStatus([
{ reviewerRole: 'manager', decision: 'approved' },
])).toBeNull()
expect(deriveCompletedWorkReviewStatus([
{ reviewerRole: 'final_reviewer', decision: 'approved' },
])).toBeNull()
})
test('requires both reviewer regions to approve the final version', () => {
expect(deriveCompletedWorkReviewStatus([
{ reviewerRole: 'manager', decision: 'approved' },
{ reviewerRole: 'final_reviewer', decision: 'approved' },
])).toBe('approved')
expect(deriveCompletedWorkReviewStatus([
{ reviewerRole: 'manager', decision: 'revision_required' },
{ reviewerRole: 'final_reviewer', decision: 'approved' },
])).toBe('revision_required')
})
})

View File

@ -44,7 +44,10 @@ function assertPerformanceSurveyBrowserImageSupport() {
} }
} }
function runImageWorker(files: File[]): Promise<WorkerProcessedImage[]> { function runImageWorker(
files: File[],
profile: 'performance_survey' | 'work_review',
): Promise<WorkerProcessedImage[]> {
assertPerformanceSurveyBrowserImageSupport() assertPerformanceSurveyBrowserImageSupport()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -77,13 +80,16 @@ function runImageWorker(files: File[]): Promise<WorkerProcessedImage[]> {
worker.onerror = () => { worker.onerror = () => {
finish(() => reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试'))) finish(() => reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试')))
} }
worker.postMessage({ id: requestId, files }) worker.postMessage({ id: requestId, files, profile })
}) })
} }
export async function preparePerformanceSurveyImagesInBrowser( export async function preparePerformanceSurveyImagesInBrowser(
files: File[], files: File[],
onProgress?: (label: string) => void, onProgress?: (label: string) => void,
options?: {
profile?: 'performance_survey' | 'work_review'
},
): Promise<PerformanceSurveyBrowserProcessedImage[]> { ): Promise<PerformanceSurveyBrowserProcessedImage[]> {
if (files.length === 0) throw new Error('请至少选择 1 张图片') if (files.length === 0) throw new Error('请至少选择 1 张图片')
@ -93,7 +99,7 @@ export async function preparePerformanceSurveyImagesInBrowser(
? '正在浏览器后台生成预览和 WebP 切片' ? '正在浏览器后台生成预览和 WebP 切片'
: `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`, : `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`,
) )
const processed = await runImageWorker(orderedFiles) const processed = await runImageWorker(orderedFiles, options?.profile || 'performance_survey')
return processed.map((item) => ({ return processed.map((item) => ({
file: new File([item.blob], item.fileName, { file: new File([item.blob], item.fileName, {

View File

@ -62,6 +62,7 @@ export type PerformanceSurveyStoredImageSlice = {
export type PerformanceSurveyStoredImageGroup = { export type PerformanceSurveyStoredImageGroup = {
version: 1 version: 1
imageId?: string
sourceName: string sourceName: string
sourceWidth: number sourceWidth: number
sourceHeight: number sourceHeight: number
@ -411,6 +412,7 @@ export function serializePerformanceSurveyImagePayload(value: unknown): Performa
const previewUrl = String(item.previewUrl || slices[0].url).trim() const previewUrl = String(item.previewUrl || slices[0].url).trim()
return [{ return [{
version: 1, version: 1,
imageId: String(item.imageId || '').trim() || undefined,
sourceName: String(item.sourceName || '案例图片').trim() || '案例图片', sourceName: String(item.sourceName || '案例图片').trim() || '案例图片',
sourceWidth: Math.max(1, Number(item.sourceWidth) || width), sourceWidth: Math.max(1, Number(item.sourceWidth) || width),
sourceHeight: Math.max(1, Number(item.sourceHeight) || Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)), sourceHeight: Math.max(1, Number(item.sourceHeight) || Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),

View File

@ -11,6 +11,7 @@ export {}
type WorkerRequest = { type WorkerRequest = {
id: string id: string
files: File[] files: File[]
profile?: 'performance_survey' | 'work_review'
} }
type WorkerProcessedImage = { type WorkerProcessedImage = {
@ -26,14 +27,14 @@ type WorkerProcessedImage = {
sliceCount: number sliceCount: number
} }
const OUTPUT_WIDTH = 1_600
const SLICE_HEIGHT = 1_500 const SLICE_HEIGHT = 1_500
const PREVIEW_WIDTH = 720 const PREVIEW_WIDTH = 720
const PREVIEW_HEIGHT = 560 const PREVIEW_HEIGHT = 560
const MAX_SLICES_PER_SOURCE = 60 const MAX_SLICES_PER_SOURCE = 60
const MAX_ORDERED_SLICE_COUNT = 60 const MAX_ORDERED_SLICE_COUNT = 60
const SLICE_QUALITY = 0.76
const PREVIEW_QUALITY = 0.68 const PREVIEW_QUALITY = 0.68
let outputWidthLimit = 1_600
let sliceQuality = 0.76
const workerScope = self as unknown as DedicatedWorkerGlobalScope const workerScope = self as unknown as DedicatedWorkerGlobalScope
function removeFileExtension(fileName: string) { function removeFileExtension(fileName: string) {
@ -88,7 +89,7 @@ async function renderSlice(
outputWidth, outputWidth,
sliceHeight, sliceHeight,
) )
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: SLICE_QUALITY }) const blob = await canvas.convertToBlob({ type: 'image/webp', quality: sliceQuality })
return { blob, width: outputWidth, height: sliceHeight } return { blob, width: outputWidth, height: sliceHeight }
} }
@ -125,7 +126,7 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
const bitmap = await decodeImage(file) const bitmap = await decodeImage(file)
try { try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`) if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
const scale = Math.min(1, OUTPUT_WIDTH / bitmap.width) const scale = Math.min(1, outputWidthLimit / bitmap.width)
const outputWidth = Math.max(1, Math.round(bitmap.width * scale)) const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
const outputHeight = Math.max(1, Math.round(bitmap.height * scale)) const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT)) const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
@ -187,7 +188,7 @@ async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage
const bitmap = await decodeImage(file) const bitmap = await decodeImage(file)
try { try {
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`) if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
const scale = Math.min(1, OUTPUT_WIDTH / bitmap.width) const scale = Math.min(1, outputWidthLimit / bitmap.width)
const outputWidth = Math.max(1, Math.round(bitmap.width * scale)) const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
const outputHeight = Math.max(1, Math.round(bitmap.height * scale)) const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT)) const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
@ -240,9 +241,11 @@ async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage
} }
workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => { workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
const { id, files } = event.data || {} const { id, files, profile } = event.data || {}
try { try {
if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片') if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片')
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
sliceQuality = profile === 'work_review' ? 0.9 : 0.76
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') { if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传') throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
} }

View File

@ -50,7 +50,12 @@ function readInteger(value: unknown, label: string, min: number, max: number) {
return normalized return normalized
} }
function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDeclaration { function normalizeDeclaration(
value: unknown,
options?: {
maxSliceWidth?: number
},
): PerformanceSurveyProcessedImageDeclaration {
if (!value || typeof value !== 'object' || Array.isArray(value)) { if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new PerformanceSurveyProcessedImageError('预处理图片参数无效') throw new PerformanceSurveyProcessedImageError('预处理图片参数无效')
} }
@ -67,7 +72,7 @@ function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDe
const maxWidth = kind === 'preview' const maxWidth = kind === 'preview'
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewWidth ? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewWidth
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth : options?.maxSliceWidth || PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth
const maxHeight = kind === 'preview' const maxHeight = kind === 'preview'
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewHeight ? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewHeight
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceHeight : PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceHeight
@ -102,6 +107,9 @@ function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDe
export function validatePerformanceSurveyProcessedImages( export function validatePerformanceSurveyProcessedImages(
value: unknown, value: unknown,
options?: {
maxSliceWidth?: number
},
): PerformanceSurveyProcessedImageGroup[] { ): PerformanceSurveyProcessedImageGroup[] {
if (!Array.isArray(value) || value.length === 0) { if (!Array.isArray(value) || value.length === 0) {
throw new PerformanceSurveyProcessedImageError('每个案例至少上传 1 张预处理图片') throw new PerformanceSurveyProcessedImageError('每个案例至少上传 1 张预处理图片')
@ -110,7 +118,7 @@ export function validatePerformanceSurveyProcessedImages(
throw new PerformanceSurveyProcessedImageError('预处理图片数量过多') throw new PerformanceSurveyProcessedImageError('预处理图片数量过多')
} }
const declarations = value.map(normalizeDeclaration) const declarations = value.map((item) => normalizeDeclaration(item, options))
const uploadIds = new Set<string>() const uploadIds = new Set<string>()
const groups = new Map<number, PerformanceSurveyProcessedImageDeclaration[]>() const groups = new Map<number, PerformanceSurveyProcessedImageDeclaration[]>()
for (const declaration of declarations) { for (const declaration of declarations) {

View File

@ -603,6 +603,15 @@ export function classifyPublicTrafficPurpose(domain: string, pathValue: string,
} }
} }
if (pathLower.startsWith('/api/work-review') || pathLower.startsWith('/work-review')) {
return {
key: 'work-review',
label: '作品过稿看板',
category: '素材库',
detail: '作品提交、版本留档、主管与元泽评审相关请求。',
}
}
if (pathLower.startsWith('/admin') || pathLower.startsWith('/api/admin/')) { if (pathLower.startsWith('/admin') || pathLower.startsWith('/api/admin/')) {
return { return {
key: 'admin-console', key: 'admin-console',

144
lib/work-review-client.ts Normal file
View File

@ -0,0 +1,144 @@
'use client'
import { chunkUpload } from '@/lib/chunk-upload'
import { preparePerformanceSurveyImagesInBrowser } from '@/lib/performance-survey-image-browser'
import type {
WorkReviewBoardData,
WorkReviewProcessedImage,
WorkReviewProject,
WorkReviewReviewInput,
WorkReviewStatus,
WorkReviewSubmissionInput,
} from '@/types/work-review'
const API_BASE = '/api/work-review'
export class WorkReviewRequestError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
async function workReviewRequest<T>(path: string, options?: RequestInit): Promise<T> {
let response: Response
try {
response = await fetch(`${API_BASE}${path}`, {
...options,
cache: options?.method && options.method !== 'GET' ? options.cache : 'no-store',
headers: {
...(options?.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(options?.headers || {}),
},
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new WorkReviewRequestError('网络连接失败,请检查网络后重试', 0)
}
const contentType = response.headers.get('content-type') || ''
const payload = contentType.includes('application/json')
? await response.json().catch(() => null)
: await response.text().catch(() => '')
if (!response.ok || (payload && typeof payload === 'object' && payload.success === false)) {
const message = payload && typeof payload === 'object'
? String(payload.error || payload.message || '请求失败')
: String(payload || '请求失败')
throw new WorkReviewRequestError(message, response.status)
}
return (payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload) as T
}
export function getWorkReviewBoard(
input: {
query?: string
status?: WorkReviewStatus | ''
project?: string
page?: number
pageSize?: number
},
signal?: AbortSignal,
) {
const params = new URLSearchParams()
if (input.query?.trim()) params.set('query', input.query.trim())
if (input.status) params.set('status', input.status)
if (input.project?.trim()) params.set('project', input.project.trim())
params.set('page', String(input.page || 1))
params.set('pageSize', String(input.pageSize || 20))
return workReviewRequest<WorkReviewBoardData>(`?${params.toString()}`, { signal })
}
export function createWorkReviewProject(input: WorkReviewSubmissionInput) {
return workReviewRequest<WorkReviewProject>('/projects', {
method: 'POST',
body: JSON.stringify(input),
})
}
export function createWorkReviewVersion(projectId: string, input: WorkReviewSubmissionInput) {
return workReviewRequest<WorkReviewProject>(`/projects/${encodeURIComponent(projectId)}/versions`, {
method: 'POST',
body: JSON.stringify(input),
})
}
export function submitWorkReview(versionId: string, input: WorkReviewReviewInput) {
return workReviewRequest<WorkReviewProject>(`/versions/${encodeURIComponent(versionId)}/reviews`, {
method: 'POST',
body: JSON.stringify(input),
})
}
export async function uploadWorkReviewImages(
files: File[],
onProgress?: (message: string, percent?: number) => void,
): Promise<WorkReviewProcessedImage[]> {
if (files.length === 0) throw new Error('请至少选择 1 张作品图片')
const processed = await preparePerformanceSurveyImagesInBrowser(
files,
(message) => {
onProgress?.(message)
},
{ profile: 'work_review' },
)
const uploaded: WorkReviewProcessedImage[] = []
for (let index = 0; index < processed.length; index += 1) {
const image = processed[index]
onProgress?.(`正在上传图片 ${index + 1}/${processed.length}`, Math.round((index / processed.length) * 100))
const result = await chunkUpload(
image.file,
{
materialType: '作品过稿看板',
fileName: image.file.name,
},
{
skipComplete: true,
onProgress: (fileProgress) => {
const totalProgress = ((index + fileProgress / 100) / processed.length) * 100
onProgress?.(`正在上传图片 ${index + 1}/${processed.length}`, Math.round(totalProgress))
},
},
)
if (!result?.uploadId) throw new Error(`图片「${image.sourceName}」上传失败,请重试`)
uploaded.push({
uploadId: result.uploadId,
mimeType: image.mimeType,
width: image.width,
height: image.height,
kind: image.kind,
sourceName: image.sourceName,
sourceIndex: image.sourceIndex,
sliceIndex: image.sliceIndex,
sliceCount: image.sliceCount,
})
}
onProgress?.('图片上传完成,正在保存作品', 100)
return uploaded
}

View File

@ -0,0 +1,275 @@
import { randomUUID } from 'crypto'
import { prisma } from '@/lib/prisma'
import {
getDingtalkPerformanceSurveyNotificationChannelForUser,
sendDingtalkRobotOtoMessage,
sendDingtalkWorkNotice,
} from '@/lib/dingtalk-client'
import { submitYouyidianNotification } from '@/lib/youyidian-notification-center'
const STATUS = {
PENDING: 'pending',
PROCESSING: 'processing',
RETRYING: 'retrying',
SUCCEEDED: 'succeeded',
EXHAUSTED: 'exhausted',
SKIPPED: 'skipped',
} as const
const MAX_ATTEMPTS = 5
const RETRY_DELAYS_MS = [0, 60_000, 5 * 60_000, 15 * 60_000, 30 * 60_000, 60 * 60_000]
const RECOVERY_INTERVAL_MS = 60_000
const STALE_PROCESSING_MS = 5 * 60_000
type QueueWorkReviewNotificationInput = {
projectId: string
versionId: string
recipientEmployeeId?: string | null
recipientUserId?: string | null
dingtalkUserId?: string | null
recipientName: string
eventType: string
businessKey: string
title: string
content: string
actionUrl?: string | null
}
let workerStarted = false
let recoveryTimer: NodeJS.Timeout | null = null
const deliveryTimers = new Map<string, NodeJS.Timeout>()
function clean(value: unknown) {
const text = String(value ?? '').trim()
return text || null
}
async function resolveDingtalkUserId(notification: {
dingtalkUserId?: string | null
recipientUserId?: string | null
recipientEmployeeId?: string | null
}) {
const current = clean(notification.dingtalkUserId)
if (current) return current
if (notification.recipientUserId) {
const user = await prisma.user.findUnique({
where: { id: notification.recipientUserId },
select: { dingtalkUserId: true },
})
const userDingtalkUserId = clean(user?.dingtalkUserId)
if (userDingtalkUserId) return userDingtalkUserId
}
if (notification.recipientEmployeeId) {
const employee = await prisma.performanceSurveyEmployeeProfile.findUnique({
where: { id: notification.recipientEmployeeId },
select: { dingtalkUserId: true, dingEmail: true },
})
const employeeDingtalkUserId = clean(employee?.dingtalkUserId)
if (employeeDingtalkUserId) return employeeDingtalkUserId
if (employee?.dingEmail) {
const user = await prisma.user.findUnique({
where: { email: employee.dingEmail },
select: { dingtalkUserId: true },
})
return clean(user?.dingtalkUserId)
}
}
return null
}
function scheduleNotification(notificationId: string, delayMs = 0) {
const previous = deliveryTimers.get(notificationId)
if (previous) clearTimeout(previous)
const timer = setTimeout(() => {
deliveryTimers.delete(notificationId)
void processWorkReviewNotification(notificationId)
}, Math.max(0, delayMs))
timer.unref?.()
deliveryTimers.set(notificationId, timer)
}
async function processWorkReviewNotification(notificationId: string) {
const claimed = await prisma.workReviewNotification.updateMany({
where: {
id: notificationId,
status: { in: [STATUS.PENDING, STATUS.RETRYING] },
},
data: { status: STATUS.PROCESSING },
})
if (claimed.count === 0) return null
const notification = await prisma.workReviewNotification.findUnique({ where: { id: notificationId } })
if (!notification) return null
const dingtalkUserId = await resolveDingtalkUserId(notification)
if (!dingtalkUserId) {
await prisma.workReviewNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SKIPPED,
lastError: '收件人缺少钉钉 userId',
nextRetryAt: null,
completedAt: new Date(),
},
})
return { success: false, status: STATUS.SKIPPED }
}
if (dingtalkUserId !== notification.dingtalkUserId) {
await prisma.workReviewNotification.update({
where: { id: notificationId },
data: { dingtalkUserId },
})
}
try {
const providerType = getDingtalkPerformanceSurveyNotificationChannelForUser(dingtalkUserId)
const input = {
userIds: [dingtalkUserId],
title: notification.title,
text: notification.content,
actionUrl: notification.actionUrl,
actionTitle: '查看作品',
}
const result = providerType === 'notification_center'
? await submitYouyidianNotification({
idempotencyKey: `${notification.eventType}:${notification.businessKey}`,
recipientUserId: dingtalkUserId,
title: notification.title,
content: notification.content,
actionUrl: notification.actionUrl || '',
actionTitle: '查看作品',
})
: providerType === 'robot_oto'
? await sendDingtalkRobotOtoMessage(input)
: await sendDingtalkWorkNotice(input)
if ('skipped' in result && result.skipped) {
await prisma.workReviewNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SKIPPED,
providerType,
lastError: result.reason || '钉钉通知未发送',
nextRetryAt: null,
completedAt: new Date(),
},
})
return { success: false, status: STATUS.SKIPPED }
}
const providerMessageId = providerType === 'notification_center'
? ('id' in result ? result.id || null : null)
: providerType === 'robot_oto'
? ('processQueryKey' in result ? result.processQueryKey || null : null)
: ('taskId' in result ? result.taskId || result.requestId || null : null)
await prisma.workReviewNotification.update({
where: { id: notificationId },
data: {
status: STATUS.SUCCEEDED,
attemptCount: { increment: 1 },
providerType,
providerMessageId,
lastError: null,
sentAt: new Date(),
completedAt: new Date(),
nextRetryAt: null,
},
})
return { success: true, status: STATUS.SUCCEEDED }
} catch (error) {
const nextAttempt = notification.attemptCount + 1
const exhausted = nextAttempt >= notification.maxAttempts
const retryDelayMs = RETRY_DELAYS_MS[Math.min(nextAttempt, RETRY_DELAYS_MS.length - 1)]
await prisma.workReviewNotification.update({
where: { id: notificationId },
data: {
status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING,
attemptCount: { increment: 1 },
lastError: error instanceof Error ? error.message : '钉钉通知发送失败',
nextRetryAt: exhausted ? null : new Date(Date.now() + retryDelayMs),
completedAt: exhausted ? new Date() : null,
},
})
if (!exhausted) scheduleNotification(notificationId, retryDelayMs)
return { success: false, status: exhausted ? STATUS.EXHAUSTED : STATUS.RETRYING }
}
}
async function recoverDueNotifications() {
const staleBefore = new Date(Date.now() - STALE_PROCESSING_MS)
await prisma.workReviewNotification.updateMany({
where: {
status: STATUS.PROCESSING,
updatedAt: { lt: staleBefore },
},
data: {
status: STATUS.RETRYING,
nextRetryAt: new Date(),
lastError: '通知处理超时,已自动重试',
},
})
const due = await prisma.workReviewNotification.findMany({
where: {
status: { in: [STATUS.PENDING, STATUS.RETRYING] },
OR: [
{ nextRetryAt: null },
{ nextRetryAt: { lte: new Date() } },
],
},
select: { id: true },
orderBy: { createdAt: 'asc' },
take: 20,
})
due.forEach(({ id }) => scheduleNotification(id))
}
export function ensureWorkReviewNotificationWorkerStarted() {
if (workerStarted) return
workerStarted = true
void recoverDueNotifications().catch((error) => {
console.error('[work-review] 恢复待发送通知失败:', error)
})
recoveryTimer = setInterval(() => {
void recoverDueNotifications().catch((error) => {
console.error('[work-review] 恢复待发送通知失败:', error)
})
}, RECOVERY_INTERVAL_MS)
recoveryTimer.unref?.()
}
export async function queueWorkReviewNotification(input: QueueWorkReviewNotificationInput) {
ensureWorkReviewNotificationWorkerStarted()
const notification = await prisma.workReviewNotification.upsert({
where: {
eventType_businessKey: {
eventType: input.eventType,
businessKey: input.businessKey,
},
},
update: {},
create: {
id: randomUUID(),
projectId: input.projectId,
versionId: input.versionId,
recipientEmployeeId: input.recipientEmployeeId || null,
recipientUserId: input.recipientUserId || null,
dingtalkUserId: input.dingtalkUserId || null,
recipientName: input.recipientName,
eventType: input.eventType,
businessKey: input.businessKey,
title: input.title,
content: input.content,
actionUrl: input.actionUrl || null,
status: STATUS.PENDING,
maxAttempts: MAX_ATTEMPTS,
nextRetryAt: new Date(),
},
select: { id: true, status: true },
})
if ([STATUS.PENDING, STATUS.RETRYING].includes(notification.status as any)) {
scheduleNotification(notification.id)
}
return notification.id
}

22
lib/work-review-policy.ts Normal file
View File

@ -0,0 +1,22 @@
export type WorkReviewDecision = 'approved' | 'revision_required'
export type WorkReviewReviewerRole = 'manager' | 'final_reviewer'
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
}>,
): WorkReviewDecision | null {
const manager = reviews.find((review) => review.reviewerRole === 'manager')
const finalReviewer = reviews.find((review) => review.reviewerRole === 'final_reviewer')
if (!manager || !finalReviewer) return null
return deriveWorkReviewDecision([manager, finalReviewer])
}

917
lib/work-review-service.ts Normal file
View File

@ -0,0 +1,917 @@
import { randomUUID } from 'crypto'
import { open, rm, unlink } from 'fs/promises'
import path from 'path'
import type { Session } from 'next-auth'
import { prisma } from '@/lib/prisma'
import { getPublicAppOrigin } from '@/lib/public-app-url'
import { buildDingtalkEntryPath } from '@/lib/safe-redirect'
import {
getPerformanceSurveyContext,
} from '@/lib/performance-survey-service'
import {
getPerformanceSurveyAccountProfileWhere,
} from '@/lib/performance-survey-report-sync'
import {
ensureWorkReviewNotificationWorkerStarted,
queueWorkReviewNotification,
} from '@/lib/work-review-notifications'
import {
serializePerformanceSurveyImagePayload,
type PerformanceSurveyStoredImageGroup,
} from '@/lib/performance-survey-image-processing'
import {
PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS,
PerformanceSurveyProcessedImageError,
readPerformanceSurveyWebpDimensions,
validatePerformanceSurveyProcessedImages,
type PerformanceSurveyProcessedImageDeclaration,
} from '@/lib/performance-survey-processed-images'
import { deleteFromS3, uploadFileToS3 } from '@/lib/s3'
import {
deleteOwnedUploadSession,
getOwnedUploadedFile,
getOwnedUploadSession,
} from '@/lib/upload-sessions'
import {
deriveCompletedWorkReviewStatus,
deriveWorkReviewDecision,
} from '@/lib/work-review-policy'
const REVIEW_ROLES = {
MANAGER: 'manager',
FINAL_REVIEWER: 'final_reviewer',
} as const
const VERSION_STATUS = {
PENDING_REVIEW: 'pending_review',
REVISION_REQUIRED: 'revision_required',
APPROVED: 'approved',
} as const
type ReviewRole = typeof REVIEW_ROLES[keyof typeof REVIEW_ROLES]
type ResolvedReviewer = {
employee: any
userId: string | null
dingtalkUserId: string | null
name: string
}
type ResolvedProcessedImage = {
declaration: PerformanceSurveyProcessedImageDeclaration
filePath: string
fileSize: number
}
type ImageCommentInput = {
imageId: string
imageIndex: number
sourceName: string
decision: typeof VERSION_STATUS.APPROVED | typeof VERSION_STATUS.REVISION_REQUIRED
comment: string
}
export class WorkReviewApiError extends Error {
status: number
constructor(message: string, status = 400) {
super(message)
this.status = status
}
}
function clean(value: unknown) {
const text = String(value ?? '').trim()
return text || null
}
function escapeDingtalkMarkdown(value: unknown) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/([`*_\[\]#>])/g, '\\$1')
.trim()
}
function getEmployeeName(employee: any) {
return clean(employee?.flowerName)
|| clean(employee?.displayName)
|| clean(employee?.realName)
|| clean(employee?.jobNumber)
|| '未命名人员'
}
function getEmployeeSnapshot(employee: any) {
return {
id: employee.id,
dingtalkUserId: employee.dingtalkUserId || null,
dingEmail: employee.dingEmail || null,
jobNumber: employee.jobNumber || null,
flowerName: employee.flowerName || null,
realName: employee.realName || null,
displayName: employee.displayName,
departmentName: employee.departmentName || null,
departmentPath: employee.departmentPath || null,
positionTitle: employee.positionTitle || null,
positionLevel: employee.positionLevel || null,
levelType: employee.levelType || null,
managerName: employee.managerName || null,
}
}
function isPLevelEmployee(employee: any) {
const levelType = String(employee?.levelType || '').trim().toUpperCase()
const positionLevel = String(employee?.positionLevel || '').trim().toUpperCase()
return levelType === 'P' || /^P(?:\d|$)/.test(positionLevel)
}
function parseWorkDate(value: unknown) {
const text = clean(value)
if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) {
throw new WorkReviewApiError('请选择有效的作品日期')
}
const date = new Date(`${text}T00:00:00.000Z`)
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== text) {
throw new WorkReviewApiError('请选择有效的作品日期')
}
return date
}
function getWorkReviewActionUrl(projectId: string) {
const path = `/work-review?project=${encodeURIComponent(projectId)}`
return `${getPublicAppOrigin()}${buildDingtalkEntryPath(path)}`
}
async function resolveEmployeeReviewer(employee: any): Promise<ResolvedReviewer> {
const dingtalkUserId = clean(employee?.dingtalkUserId)
const dingEmail = clean(employee?.dingEmail)
const user = dingtalkUserId || dingEmail
? await prisma.user.findFirst({
where: {
OR: [
...(dingtalkUserId ? [{ dingtalkUserId }] : []),
...(dingEmail ? [{ email: dingEmail }] : []),
],
},
select: { id: true, dingtalkUserId: true },
})
: null
return {
employee,
userId: user?.id || null,
dingtalkUserId: dingtalkUserId || clean(user?.dingtalkUserId),
name: getEmployeeName(employee),
}
}
async function resolveDirectManager(employee: any) {
const managerDingtalkUserId = clean(employee?.managerDingtalkUserId)
const managerJobNumber = clean(employee?.managerJobNumber)
if (!managerDingtalkUserId && !managerJobNumber) {
throw new WorkReviewApiError(`无法确定${getEmployeeName(employee)}的直属主管,请先校准考核人员档案`, 409)
}
const managers = await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
AND: [
await getPerformanceSurveyAccountProfileWhere(),
{
OR: [
...(managerDingtalkUserId ? [{ dingtalkUserId: managerDingtalkUserId }] : []),
...(managerJobNumber ? [{ jobNumber: managerJobNumber }] : []),
],
},
],
},
take: 2,
})
if (managers.length !== 1) {
throw new WorkReviewApiError(`无法唯一匹配${getEmployeeName(employee)}的直属主管,请先校准考核人员档案`, 409)
}
return resolveEmployeeReviewer(managers[0])
}
async function resolveFinalReviewer() {
const configuredVersions = await prisma.performanceSurveyTemplateVersion.findMany({
where: {
status: 'published',
template: {
code: {
in: ['visual_brand_tone', 'visual_brand_tone_without_copy'],
},
},
},
select: { definition: true },
orderBy: [{ publishedAt: 'desc' }, { version: 'desc' }],
take: 10,
})
const configuredIds = Array.from(new Set(configuredVersions.flatMap((version) => {
const definition = version.definition as any
const stages = Array.isArray(definition?.workflow?.stages) ? definition.workflow.stages : []
return stages.flatMap((stage: any) => (
Array.isArray(stage?.reviewers)
? stage.reviewers
.filter((reviewer: any) => (
reviewer?.source === 'fixed_employee'
&& (!reviewer?.employeeName || reviewer.employeeName === '元泽')
))
.map((reviewer: any) => clean(reviewer.employeeId))
.filter(Boolean)
: []
))
}))) as string[]
if (configuredIds.length === 1) {
const configuredEmployee = await prisma.performanceSurveyEmployeeProfile.findFirst({
where: {
AND: [
await getPerformanceSurveyAccountProfileWhere(),
{ id: configuredIds[0] },
],
},
})
if (configuredEmployee) return resolveEmployeeReviewer(configuredEmployee)
}
const employees = await prisma.performanceSurveyEmployeeProfile.findMany({
where: {
AND: [
await getPerformanceSurveyAccountProfileWhere(),
{
OR: [
{ displayName: '元泽' },
{ flowerName: '元泽' },
{ realName: '元泽' },
],
},
],
},
take: 2,
})
if (employees.length !== 1) {
throw new WorkReviewApiError('无法唯一匹配元泽的人员档案,请先在考核调研人员管理中校准', 409)
}
return resolveEmployeeReviewer(employees[0])
}
async function getContext(session: Session | null) {
ensureWorkReviewNotificationWorkerStarted()
const ctx = await getPerformanceSurveyContext(session)
return {
...ctx,
canSubmit: isPLevelEmployee(ctx.employee),
}
}
function getAccessibleWhere(ctx: Awaited<ReturnType<typeof getContext>>) {
if (ctx.isSystemAdmin) return {}
const employeeId = clean(ctx.employee?.id)
return {
OR: [
{ submitterUserId: ctx.user.id },
...(employeeId
? [
{ submitterEmployeeId: employeeId },
{ managerEmployeeId: employeeId },
{ finalReviewerEmployeeId: employeeId },
]
: []),
],
}
}
function normalizeReviewRole(value: unknown): ReviewRole {
const role = clean(value)
if (role === REVIEW_ROLES.MANAGER || role === REVIEW_ROLES.FINAL_REVIEWER) return role
throw new WorkReviewApiError('评审区域无效')
}
function canReviewRole(
ctx: Awaited<ReturnType<typeof getContext>>,
project: {
managerEmployeeId: string
managerUserId: string | null
finalReviewerEmployeeId: string
finalReviewerUserId: string | null
},
role: ReviewRole,
) {
const employeeId = clean(ctx.employee?.id)
if (role === REVIEW_ROLES.MANAGER) {
return Boolean(
(employeeId && employeeId === project.managerEmployeeId)
|| project.managerUserId === ctx.user.id,
)
}
return Boolean(
(employeeId && employeeId === project.finalReviewerEmployeeId)
|| project.finalReviewerUserId === ctx.user.id,
)
}
function getPendingRoles(
ctx: Awaited<ReturnType<typeof getContext>>,
project: any,
version: any,
) {
const submittedRoles = new Set((version?.reviews || []).map((review: any) => review.reviewerRole))
return [REVIEW_ROLES.MANAGER, REVIEW_ROLES.FINAL_REVIEWER].filter((role) => (
!submittedRoles.has(role) && canReviewRole(ctx, project, role)
))
}
function serializeProject(ctx: Awaited<ReturnType<typeof getContext>>, project: any) {
const versions = [...(project.versions || [])]
.sort((left, right) => right.version - left.version)
.map((version) => {
const images = serializePerformanceSurveyImagePayload(version.imageUrls)
return {
id: version.id,
version: version.version,
submissionNote: version.submissionNote,
status: version.status,
submittedAt: version.submittedAt,
completedAt: version.completedAt,
imageUrls: images.imageUrls,
imageGroups: images.imageGroups,
reviews: [...(version.reviews || [])]
.sort((left, right) => left.reviewerRole.localeCompare(right.reviewerRole))
.map((review) => ({
id: review.id,
reviewerRole: review.reviewerRole,
reviewerName: review.reviewerName,
decision: review.decision,
imageComments: review.imageComments,
generalComment: review.generalComment,
reviewedAt: review.reviewedAt,
})),
}
})
const latest = versions[0] || null
return {
id: project.id,
title: project.title,
description: project.description,
workDate: project.workDate,
status: project.status,
latestVersion: project.latestVersion,
submitter: {
id: project.submitterEmployeeId,
name: project.submitterName,
snapshot: project.submitterSnapshot,
},
manager: {
id: project.managerEmployeeId,
name: project.managerName,
},
finalReviewer: {
id: project.finalReviewerEmployeeId,
name: project.finalReviewerName,
},
createdAt: project.createdAt,
updatedAt: project.updatedAt,
versions,
permissions: {
canResubmit: Boolean(
ctx.canSubmit
&& project.submitterUserId === ctx.user.id
&& project.status === VERSION_STATUS.REVISION_REQUIRED,
),
pendingReviewRoles: latest ? getPendingRoles(ctx, project, latest) : [],
},
}
}
export async function getWorkReviewBoard(session: Session | null, params?: URLSearchParams) {
const ctx = await getContext(session)
const status = clean(params?.get('status'))
const query = clean(params?.get('query'))
const projectId = clean(params?.get('project'))
const page = projectId ? 1 : Math.max(1, Number(params?.get('page') || 1) || 1)
const pageSize = Math.min(50, Math.max(1, Number(params?.get('pageSize') || 20) || 20))
const filters: any[] = [getAccessibleWhere(ctx)]
if (projectId) {
filters.push({ id: projectId })
} else if (status && Object.values(VERSION_STATUS).includes(status as any)) {
filters.push({ status })
}
if (!projectId && query) {
filters.push({
OR: [
{ title: { contains: query, mode: 'insensitive' } },
{ submitterName: { contains: query, mode: 'insensitive' } },
],
})
}
const where = filters.length === 1 ? filters[0] : { AND: filters }
const [total, projects] = await prisma.$transaction([
prisma.workReviewProject.count({ where }),
prisma.workReviewProject.findMany({
where,
include: {
versions: {
include: { reviews: true },
orderBy: { version: 'desc' },
},
},
orderBy: [{ workDate: 'desc' }, { updatedAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
])
const items = projects.map((project) => serializeProject(ctx, project))
return {
me: {
user: {
id: ctx.user.id,
name: getEmployeeName(ctx.employee) || ctx.user.username,
avatar: ctx.user.avatar,
role: ctx.user.role,
},
employee: ctx.employee ? getEmployeeSnapshot(ctx.employee) : null,
permissions: {
canSubmit: ctx.canSubmit,
isSystemAdmin: ctx.isSystemAdmin,
isManager: Boolean(ctx.isLeadership),
isFinalReviewer: Boolean(
ctx.employee
&& items.some((item) => item.finalReviewer.id === ctx.employee.id),
),
},
},
items,
pagination: {
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
}
}
async function readWebpDimensions(filePath: string) {
const handle = await open(filePath, 'r')
try {
const header = Buffer.alloc(64)
const { bytesRead } = await handle.read(header, 0, header.length, 0)
return readPerformanceSurveyWebpDimensions(header.subarray(0, bytesRead))
} finally {
await handle.close()
}
}
async function resolveProcessedUpload(
declaration: PerformanceSurveyProcessedImageDeclaration,
userId: string,
): Promise<ResolvedProcessedImage> {
const uploadSession = await getOwnedUploadSession(declaration.uploadId, userId)
if (!uploadSession) throw new WorkReviewApiError('图片上传已过期,请重新选择图片')
if (uploadSession.fileSize > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxArtifactBytes) {
throw new WorkReviewApiError('单张预处理图片不能超过 8MB')
}
const filePath = await getOwnedUploadedFile(declaration.uploadId, userId)
if (!filePath) throw new WorkReviewApiError(`图片「${declaration.sourceName}」上传不完整,请重试`)
return { declaration, filePath, fileSize: uploadSession.fileSize }
}
async function cleanupProcessedUploads(
userId: string,
uploads: Array<{ uploadId: string; filePath: string }>,
) {
await Promise.allSettled(uploads.flatMap(({ uploadId, filePath }) => [
unlink(filePath),
rm(path.join(process.env.UPLOAD_DIR || './uploads', 'temp', uploadId), { recursive: true, force: true }),
deleteOwnedUploadSession(uploadId, userId),
]))
}
async function withStoredImages<T>(input: {
userId: string
projectId: string
versionId: string
processedImages: unknown
task: (imageGroups: PerformanceSurveyStoredImageGroup[]) => Promise<T>
}) {
let groups: ReturnType<typeof validatePerformanceSurveyProcessedImages>
try {
groups = validatePerformanceSurveyProcessedImages(input.processedImages, { maxSliceWidth: 3_200 })
} catch (error) {
if (error instanceof PerformanceSurveyProcessedImageError) {
throw new WorkReviewApiError(error.message)
}
throw error
}
const uploads: Array<{ uploadId: string; filePath: string }> = []
const uploadedUrls: string[] = []
const resolvedByUploadId = new Map<string, ResolvedProcessedImage>()
try {
let totalBytes = 0
for (const group of groups) {
for (const declaration of [...group.slices, ...(group.preview ? [group.preview] : [])]) {
const resolved = await resolveProcessedUpload(declaration, input.userId)
uploads.push({ uploadId: declaration.uploadId, filePath: resolved.filePath })
totalBytes += resolved.fileSize
if (totalBytes > PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxBatchBytes) {
throw new WorkReviewApiError('本次作品的预处理图片总大小不能超过 256MB')
}
const dimensions = await readWebpDimensions(resolved.filePath)
if (dimensions.width !== declaration.width || dimensions.height !== declaration.height) {
throw new WorkReviewApiError(`图片「${declaration.sourceName}」尺寸校验失败,请重新上传`)
}
resolvedByUploadId.set(declaration.uploadId, resolved)
}
}
const imageGroups: PerformanceSurveyStoredImageGroup[] = []
for (const group of groups) {
const slices: PerformanceSurveyStoredImageGroup['slices'] = []
for (const declaration of group.slices) {
const resolved = resolvedByUploadId.get(declaration.uploadId)!
const fileName = [
'work-review',
input.projectId,
input.versionId,
`${group.sourceIndex + 1}-${declaration.sliceIndex + 1}.webp`,
].join('/')
const url = await uploadFileToS3(resolved.filePath, fileName, declaration.mimeType)
uploadedUrls.push(url)
slices.push({ url, width: declaration.width, height: declaration.height })
}
const firstSlice = slices[0]
let previewUrl = firstSlice.url
let previewWidth = firstSlice.width
let previewHeight = firstSlice.height
if (group.preview) {
const resolved = resolvedByUploadId.get(group.preview.uploadId)!
const fileName = [
'work-review',
input.projectId,
input.versionId,
`${group.sourceIndex + 1}-preview.webp`,
].join('/')
previewUrl = await uploadFileToS3(resolved.filePath, fileName, group.preview.mimeType)
uploadedUrls.push(previewUrl)
previewWidth = group.preview.width
previewHeight = group.preview.height
}
imageGroups.push({
version: 1,
imageId: randomUUID(),
sourceName: group.sourceName,
sourceWidth: Math.max(...slices.map((slice) => slice.width)),
sourceHeight: slices.reduce((sum, slice) => sum + slice.height, 0),
width: Math.max(...slices.map((slice) => slice.width)),
height: slices.reduce((sum, slice) => sum + slice.height, 0),
previewUrl,
previewWidth,
previewHeight,
slices,
})
}
return await input.task(imageGroups)
} catch (error) {
await Promise.allSettled(uploadedUrls.map((url) => deleteFromS3(url)))
throw error
} finally {
await cleanupProcessedUploads(input.userId, uploads)
}
}
async function queueSubmittedNotifications(project: any, version: any) {
const actionUrl = getWorkReviewActionUrl(project.id)
const content = [
'### ◆ 作品待评审',
'',
`**${escapeDingtalkMarkdown(project.title)}**`,
'',
`**提交人:** ${escapeDingtalkMarkdown(project.submitterName)} `,
`**作品日期:** ${new Date(project.workDate).toISOString().slice(0, 10)} `,
`**版本:** V${version.version} `,
'',
'> 请逐张查看作品图片并填写评审修改建议。',
].join('\n')
return Promise.all([
queueWorkReviewNotification({
projectId: project.id,
versionId: version.id,
recipientEmployeeId: project.managerEmployeeId,
recipientUserId: project.managerUserId,
dingtalkUserId: project.managerDingtalkUserId,
recipientName: project.managerName,
eventType: 'work_review.version.submitted.manager',
businessKey: `work-review-version:${version.id}:manager`,
title: '作品过稿看板',
content,
actionUrl,
}),
queueWorkReviewNotification({
projectId: project.id,
versionId: version.id,
recipientEmployeeId: project.finalReviewerEmployeeId,
recipientUserId: project.finalReviewerUserId,
dingtalkUserId: project.finalReviewerDingtalkUserId,
recipientName: project.finalReviewerName,
eventType: 'work_review.version.submitted.final_reviewer',
businessKey: `work-review-version:${version.id}:final-reviewer`,
title: '作品过稿看板',
content,
actionUrl,
}),
])
}
async function queueCompletedNotification(project: any, version: any) {
const approved = version.status === VERSION_STATUS.APPROVED
return queueWorkReviewNotification({
projectId: project.id,
versionId: version.id,
recipientEmployeeId: project.submitterEmployeeId,
recipientUserId: project.submitterUserId,
dingtalkUserId: project.submitterEmployee?.dingtalkUserId || null,
recipientName: project.submitterName,
eventType: 'work_review.version.completed',
businessKey: `work-review-version:${version.id}:completed`,
title: '作品过稿看板',
content: [
`### ${approved ? '✓ 作品已通过' : '◆ 评审已完成'}`,
'',
`**${escapeDingtalkMarkdown(project.title)}**`,
'',
`**版本:** V${version.version} `,
`**评审结果:** ${approved ? '直属主管与元泽均已通过' : '请按评审建议修改后再次提交'} `,
'',
approved ? '> 本轮作品已完成过稿。' : '> 点击下方按钮查看每张图的修改建议。',
].join('\n'),
actionUrl: getWorkReviewActionUrl(project.id),
})
}
async function queueNotificationsSafely(task: () => Promise<unknown>) {
try {
await task()
} catch (error) {
console.error('[work-review] 业务已保存,但通知排队失败,将由运维检查补发:', error)
}
}
function ensureCanSubmit(ctx: Awaited<ReturnType<typeof getContext>>) {
if (!ctx.employee) throw new WorkReviewApiError('当前账号尚未匹配考核人员档案,无法提交作品', 403)
if (!ctx.canSubmit) throw new WorkReviewApiError('只有 P 级人员可以提交作品', 403)
}
export async function createWorkReviewProject(session: Session | null, body: any) {
const ctx = await getContext(session)
ensureCanSubmit(ctx)
const title = clean(body?.title)
if (!title) throw new WorkReviewApiError('请填写作品名称')
if (title.length > 120) throw new WorkReviewApiError('作品名称不能超过 120 个字')
const description = clean(body?.description)
const submissionNote = clean(body?.submissionNote)
const workDate = parseWorkDate(body?.workDate)
const manager = await resolveDirectManager(ctx.employee)
const finalReviewer = await resolveFinalReviewer()
const projectId = randomUUID()
const versionId = randomUUID()
const created = await withStoredImages({
userId: ctx.user.id,
projectId,
versionId,
processedImages: body?.processedImages,
task: async (imageGroups) => prisma.workReviewProject.create({
data: {
id: projectId,
title,
description,
workDate,
submitterUserId: ctx.user.id,
submitterEmployeeId: ctx.employee.id,
submitterName: getEmployeeName(ctx.employee),
submitterSnapshot: getEmployeeSnapshot(ctx.employee),
managerEmployeeId: manager.employee.id,
managerUserId: manager.userId,
managerDingtalkUserId: manager.dingtalkUserId,
managerName: manager.name,
finalReviewerEmployeeId: finalReviewer.employee.id,
finalReviewerUserId: finalReviewer.userId,
finalReviewerDingtalkUserId: finalReviewer.dingtalkUserId,
finalReviewerName: finalReviewer.name,
versions: {
create: {
id: versionId,
version: 1,
submissionNote,
imageUrls: imageGroups as any,
},
},
},
include: {
submitterEmployee: true,
versions: { include: { reviews: true } },
},
}),
})
await queueNotificationsSafely(() => queueSubmittedNotifications(created, created.versions[0]))
return serializeProject(ctx, created)
}
export async function createWorkReviewVersion(session: Session | null, projectId: string, body: any) {
const ctx = await getContext(session)
ensureCanSubmit(ctx)
const existing = await prisma.workReviewProject.findUnique({
where: { id: projectId },
include: { versions: { orderBy: { version: 'desc' }, take: 1 } },
})
if (!existing) throw new WorkReviewApiError('作品不存在', 404)
if (existing.submitterUserId !== ctx.user.id) throw new WorkReviewApiError('只能重新提交自己的作品', 403)
if (existing.status !== VERSION_STATUS.REVISION_REQUIRED) {
throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409)
}
const versionId = randomUUID()
const submissionNote = clean(body?.submissionNote)
const updated = await withStoredImages({
userId: ctx.user.id,
projectId,
versionId,
processedImages: body?.processedImages,
task: async (imageGroups) => prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw<Array<{ status: string; latest_version: number }>>`
SELECT "status", "latest_version"
FROM "work_review_projects"
WHERE "id" = ${projectId}
FOR UPDATE
`
if (!locked[0]) throw new WorkReviewApiError('作品不存在', 404)
if (locked[0].status !== VERSION_STATUS.REVISION_REQUIRED) {
throw new WorkReviewApiError('当前版本尚未完成评审,不能再次提交', 409)
}
const version = locked[0].latest_version + 1
await tx.workReviewVersion.create({
data: {
id: versionId,
projectId,
version,
submissionNote,
imageUrls: imageGroups as any,
},
})
return tx.workReviewProject.update({
where: { id: projectId },
data: {
status: VERSION_STATUS.PENDING_REVIEW,
latestVersion: version,
},
include: {
submitterEmployee: true,
versions: {
include: { reviews: true },
orderBy: { version: 'desc' },
},
},
})
}),
})
const version = updated.versions.find((item) => item.id === versionId)
if (!version) throw new WorkReviewApiError('新版本创建失败', 500)
await queueNotificationsSafely(() => queueSubmittedNotifications(updated, version))
return serializeProject(ctx, updated)
}
function readImageComments(value: unknown, imageGroups: PerformanceSurveyStoredImageGroup[]) {
if (!Array.isArray(value)) throw new WorkReviewApiError('请填写每张图的评审建议')
const byIndex = new Map<number, any>()
for (const item of value) {
const imageIndex = Number(item?.imageIndex)
if (!Number.isInteger(imageIndex) || imageIndex < 0 || imageIndex >= imageGroups.length || byIndex.has(imageIndex)) {
throw new WorkReviewApiError('评审图片序号无效')
}
byIndex.set(imageIndex, item)
}
return imageGroups.map((group, imageIndex): ImageCommentInput => {
const item = byIndex.get(imageIndex)
if (!item) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`)
const decision = clean(item.decision)
if (decision !== VERSION_STATUS.APPROVED && decision !== VERSION_STATUS.REVISION_REQUIRED) {
throw new WorkReviewApiError(`请选择图片「${group.sourceName}」的评审结论`)
}
const comment = clean(item.comment)
if (!comment) throw new WorkReviewApiError(`请填写图片「${group.sourceName}」的评审建议`)
if (comment.length > 2000) throw new WorkReviewApiError('单张图片的评审建议不能超过 2000 个字')
return {
imageId: group.imageId || `image-${imageIndex + 1}`,
imageIndex,
sourceName: group.sourceName,
decision,
comment,
}
})
}
export async function submitWorkReview(session: Session | null, versionId: string, body: any) {
const ctx = await getContext(session)
if (!ctx.employee) throw new WorkReviewApiError('当前账号尚未匹配考核人员档案', 403)
const role = normalizeReviewRole(body?.reviewerRole)
const version = await prisma.workReviewVersion.findUnique({
where: { id: versionId },
include: {
project: {
include: { submitterEmployee: true },
},
reviews: true,
},
})
if (!version) throw new WorkReviewApiError('作品版本不存在', 404)
if (version.status !== VERSION_STATUS.PENDING_REVIEW) {
throw new WorkReviewApiError('该版本评审已经结束', 409)
}
if (!canReviewRole(ctx, version.project, role)) {
throw new WorkReviewApiError(role === REVIEW_ROLES.MANAGER ? '无权填写直属主管评审' : '无权填写元泽评审', 403)
}
const imageGroups = serializePerformanceSurveyImagePayload(version.imageUrls).imageGroups
const imageComments = readImageComments(body?.imageComments, imageGroups)
const generalComment = clean(body?.generalComment)
if (generalComment && generalComment.length > 4000) {
throw new WorkReviewApiError('总体意见不能超过 4000 个字')
}
const decision = deriveWorkReviewDecision(imageComments)
const result = await prisma.$transaction(async (tx) => {
const locked = await tx.$queryRaw<Array<{ status: string }>>`
SELECT "status"
FROM "work_review_versions"
WHERE "id" = ${versionId}
FOR UPDATE
`
if (!locked[0]) throw new WorkReviewApiError('作品版本不存在', 404)
if (locked[0].status !== VERSION_STATUS.PENDING_REVIEW) {
throw new WorkReviewApiError('该版本评审已经结束', 409)
}
const duplicate = await tx.workReviewReview.findUnique({
where: { versionId_reviewerRole: { versionId, reviewerRole: role } },
})
if (duplicate) throw new WorkReviewApiError('该评审区域已经提交,不能重复提交', 409)
await tx.workReviewReview.create({
data: {
id: randomUUID(),
versionId,
reviewerRole: role,
reviewerEmployeeId: ctx.employee.id,
reviewerUserId: ctx.user.id,
reviewerName: getEmployeeName(ctx.employee),
reviewerSnapshot: getEmployeeSnapshot(ctx.employee),
decision,
imageComments: imageComments as any,
generalComment,
},
})
const reviews = await tx.workReviewReview.findMany({ where: { versionId } })
const finalStatus = deriveCompletedWorkReviewStatus(reviews as Array<{
reviewerRole: string
decision: 'approved' | 'revision_required'
}>)
if (!finalStatus) return { completed: false, status: VERSION_STATUS.PENDING_REVIEW }
const completedAt = new Date()
await tx.workReviewVersion.update({
where: { id: versionId },
data: { status: finalStatus, completedAt },
})
await tx.workReviewProject.update({
where: { id: version.projectId },
data: { status: finalStatus },
})
return { completed: true, status: finalStatus, completedAt }
})
if (result.completed) {
await queueNotificationsSafely(() => queueCompletedNotification(version.project, {
...version,
status: result.status,
completedAt: result.completedAt,
}))
}
const refreshed = await prisma.workReviewProject.findUnique({
where: { id: version.projectId },
include: {
submitterEmployee: true,
versions: {
include: { reviews: true },
orderBy: { version: 'desc' },
},
},
})
if (!refreshed) throw new WorkReviewApiError('作品不存在', 404)
return serializeProject(ctx, refreshed)
}

View File

@ -0,0 +1,158 @@
CREATE TABLE IF NOT EXISTS "work_review_projects" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"work_date" DATE NOT NULL,
"submitter_user_id" TEXT NOT NULL,
"submitter_employee_id" TEXT NOT NULL,
"submitter_name" TEXT NOT NULL,
"submitter_snapshot" JSONB NOT NULL,
"manager_employee_id" TEXT NOT NULL,
"manager_user_id" TEXT,
"manager_dingtalk_user_id" TEXT,
"manager_name" TEXT NOT NULL,
"final_reviewer_employee_id" TEXT NOT NULL,
"final_reviewer_user_id" TEXT,
"final_reviewer_dingtalk_user_id" TEXT,
"final_reviewer_name" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending_review',
"latest_version" INTEGER NOT NULL DEFAULT 1,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "work_review_projects_pkey" PRIMARY KEY ("id"),
CONSTRAINT "work_review_projects_status_check"
CHECK ("status" IN ('pending_review', 'revision_required', 'approved')),
CONSTRAINT "work_review_projects_latest_version_check"
CHECK ("latest_version" > 0),
CONSTRAINT "work_review_projects_submitter_employee_id_fkey"
FOREIGN KEY ("submitter_employee_id") REFERENCES "performance_survey_employee_profiles"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "work_review_projects_manager_employee_id_fkey"
FOREIGN KEY ("manager_employee_id") REFERENCES "performance_survey_employee_profiles"("id")
ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "work_review_projects_final_reviewer_employee_id_fkey"
FOREIGN KEY ("final_reviewer_employee_id") REFERENCES "performance_survey_employee_profiles"("id")
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "work_review_versions" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"submission_note" TEXT,
"image_urls" JSONB NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending_review',
"submitted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "work_review_versions_pkey" PRIMARY KEY ("id"),
CONSTRAINT "work_review_versions_version_check" CHECK ("version" > 0),
CONSTRAINT "work_review_versions_status_check"
CHECK ("status" IN ('pending_review', 'revision_required', 'approved')),
CONSTRAINT "work_review_versions_project_id_fkey"
FOREIGN KEY ("project_id") REFERENCES "work_review_projects"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "work_review_reviews" (
"id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"reviewer_role" TEXT NOT NULL,
"reviewer_employee_id" TEXT NOT NULL,
"reviewer_user_id" TEXT,
"reviewer_name" TEXT NOT NULL,
"reviewer_snapshot" JSONB NOT NULL,
"decision" TEXT NOT NULL,
"image_comments" JSONB NOT NULL,
"general_comment" TEXT,
"reviewed_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "work_review_reviews_pkey" PRIMARY KEY ("id"),
CONSTRAINT "work_review_reviews_role_check"
CHECK ("reviewer_role" IN ('manager', 'final_reviewer')),
CONSTRAINT "work_review_reviews_decision_check"
CHECK ("decision" IN ('revision_required', 'approved')),
CONSTRAINT "work_review_reviews_version_id_fkey"
FOREIGN KEY ("version_id") REFERENCES "work_review_versions"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "work_review_reviews_reviewer_employee_id_fkey"
FOREIGN KEY ("reviewer_employee_id") REFERENCES "performance_survey_employee_profiles"("id")
ON DELETE RESTRICT ON UPDATE CASCADE
);
CREATE TABLE IF NOT EXISTS "work_review_notifications" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"version_id" TEXT NOT NULL,
"recipient_employee_id" TEXT,
"recipient_user_id" TEXT,
"dingtalk_user_id" TEXT,
"recipient_name" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"business_key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"action_url" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"attempt_count" INTEGER NOT NULL DEFAULT 0,
"max_attempts" INTEGER NOT NULL DEFAULT 5,
"next_retry_at" TIMESTAMP(3),
"provider_type" TEXT NOT NULL DEFAULT 'work_notice',
"provider_message_id" TEXT,
"last_error" TEXT,
"sent_at" TIMESTAMP(3),
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "work_review_notifications_pkey" PRIMARY KEY ("id"),
CONSTRAINT "work_review_notifications_status_check"
CHECK ("status" IN ('pending', 'processing', 'retrying', 'succeeded', 'exhausted', 'skipped')),
CONSTRAINT "work_review_notifications_project_id_fkey"
FOREIGN KEY ("project_id") REFERENCES "work_review_projects"("id")
ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "work_review_notifications_version_id_fkey"
FOREIGN KEY ("version_id") REFERENCES "work_review_versions"("id")
ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "work_review_versions_project_version_key"
ON "work_review_versions"("project_id", "version");
CREATE UNIQUE INDEX IF NOT EXISTS "work_review_reviews_version_role_key"
ON "work_review_reviews"("version_id", "reviewer_role");
CREATE UNIQUE INDEX IF NOT EXISTS "work_review_notifications_event_key"
ON "work_review_notifications"("event_type", "business_key");
CREATE INDEX IF NOT EXISTS "work_review_projects_submitter_date_idx"
ON "work_review_projects"("submitter_user_id", "work_date");
CREATE INDEX IF NOT EXISTS "work_review_projects_submitter_employee_date_idx"
ON "work_review_projects"("submitter_employee_id", "work_date");
CREATE INDEX IF NOT EXISTS "work_review_projects_manager_status_idx"
ON "work_review_projects"("manager_employee_id", "status");
CREATE INDEX IF NOT EXISTS "work_review_projects_final_reviewer_status_idx"
ON "work_review_projects"("final_reviewer_employee_id", "status");
CREATE INDEX IF NOT EXISTS "work_review_projects_status_date_idx"
ON "work_review_projects"("status", "work_date");
CREATE INDEX IF NOT EXISTS "work_review_projects_updated_at_idx"
ON "work_review_projects"("updated_at");
CREATE INDEX IF NOT EXISTS "work_review_versions_project_submitted_idx"
ON "work_review_versions"("project_id", "submitted_at");
CREATE INDEX IF NOT EXISTS "work_review_versions_status_submitted_idx"
ON "work_review_versions"("status", "submitted_at");
CREATE INDEX IF NOT EXISTS "work_review_reviews_reviewer_reviewed_idx"
ON "work_review_reviews"("reviewer_employee_id", "reviewed_at");
CREATE INDEX IF NOT EXISTS "work_review_reviews_decision_idx"
ON "work_review_reviews"("decision");
CREATE INDEX IF NOT EXISTS "work_review_notifications_project_created_idx"
ON "work_review_notifications"("project_id", "created_at");
CREATE INDEX IF NOT EXISTS "work_review_notifications_version_idx"
ON "work_review_notifications"("version_id");
CREATE INDEX IF NOT EXISTS "work_review_notifications_recipient_employee_idx"
ON "work_review_notifications"("recipient_employee_id");
CREATE INDEX IF NOT EXISTS "work_review_notifications_recipient_user_idx"
ON "work_review_notifications"("recipient_user_id");
CREATE INDEX IF NOT EXISTS "work_review_notifications_dingtalk_user_idx"
ON "work_review_notifications"("dingtalk_user_id");
CREATE INDEX IF NOT EXISTS "work_review_notifications_status_retry_idx"
ON "work_review_notifications"("status", "next_retry_at");

View File

@ -526,6 +526,10 @@ model PerformanceSurveyEmployeeProfile {
notifications PerformanceSurveyNotification[] notifications PerformanceSurveyNotification[]
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[] manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[] departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
submittedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewSubmitter")
managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager")
finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer")
workReviews WorkReviewReview[] @relation("WorkReviewReviewer")
@@index([displayName]) @@index([displayName])
@@index([dingEmail]) @@index([dingEmail])
@ -981,6 +985,123 @@ model PerformanceSurveyConfigAuditLog {
@@map("performance_survey_config_audit_logs") @@map("performance_survey_config_audit_logs")
} }
model WorkReviewProject {
id String @id @default(uuid())
title String
description String?
workDate DateTime @db.Date @map("work_date")
submitterUserId String @map("submitter_user_id")
submitterEmployeeId String @map("submitter_employee_id")
submitterName String @map("submitter_name")
submitterSnapshot Json @map("submitter_snapshot")
managerEmployeeId String @map("manager_employee_id")
managerUserId String? @map("manager_user_id")
managerDingtalkUserId String? @map("manager_dingtalk_user_id")
managerName String @map("manager_name")
finalReviewerEmployeeId String @map("final_reviewer_employee_id")
finalReviewerUserId String? @map("final_reviewer_user_id")
finalReviewerDingtalkUserId String? @map("final_reviewer_dingtalk_user_id")
finalReviewerName String @map("final_reviewer_name")
status String @default("pending_review")
latestVersion Int @default(1) @map("latest_version")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
submitterEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewSubmitter", fields: [submitterEmployeeId], references: [id], onDelete: Restrict)
managerEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewManager", fields: [managerEmployeeId], references: [id], onDelete: Restrict)
finalReviewerEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewFinalReviewer", fields: [finalReviewerEmployeeId], references: [id], onDelete: Restrict)
versions WorkReviewVersion[]
notifications WorkReviewNotification[]
@@index([submitterUserId, workDate], map: "work_review_projects_submitter_date_idx")
@@index([submitterEmployeeId, workDate], map: "work_review_projects_submitter_employee_date_idx")
@@index([managerEmployeeId, status], map: "work_review_projects_manager_status_idx")
@@index([finalReviewerEmployeeId, status], map: "work_review_projects_final_reviewer_status_idx")
@@index([status, workDate], map: "work_review_projects_status_date_idx")
@@index([updatedAt], map: "work_review_projects_updated_at_idx")
@@map("work_review_projects")
}
model WorkReviewVersion {
id String @id @default(uuid())
projectId String @map("project_id")
version Int
submissionNote String? @map("submission_note")
imageUrls Json @map("image_urls")
status String @default("pending_review")
submittedAt DateTime @default(now()) @map("submitted_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project WorkReviewProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
reviews WorkReviewReview[]
notifications WorkReviewNotification[]
@@unique([projectId, version], map: "work_review_versions_project_version_key")
@@index([projectId, submittedAt], map: "work_review_versions_project_submitted_idx")
@@index([status, submittedAt], map: "work_review_versions_status_submitted_idx")
@@map("work_review_versions")
}
model WorkReviewReview {
id String @id @default(uuid())
versionId String @map("version_id")
reviewerRole String @map("reviewer_role")
reviewerEmployeeId String @map("reviewer_employee_id")
reviewerUserId String? @map("reviewer_user_id")
reviewerName String @map("reviewer_name")
reviewerSnapshot Json @map("reviewer_snapshot")
decision String
imageComments Json @map("image_comments")
generalComment String? @map("general_comment")
reviewedAt DateTime @default(now()) @map("reviewed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
version WorkReviewVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
reviewerEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewReviewer", fields: [reviewerEmployeeId], references: [id], onDelete: Restrict)
@@unique([versionId, reviewerRole], map: "work_review_reviews_version_role_key")
@@index([reviewerEmployeeId, reviewedAt], map: "work_review_reviews_reviewer_reviewed_idx")
@@index([decision], map: "work_review_reviews_decision_idx")
@@map("work_review_reviews")
}
model WorkReviewNotification {
id String @id @default(uuid())
projectId String @map("project_id")
versionId String @map("version_id")
recipientEmployeeId String? @map("recipient_employee_id")
recipientUserId String? @map("recipient_user_id")
dingtalkUserId String? @map("dingtalk_user_id")
recipientName String @map("recipient_name")
eventType String @map("event_type")
businessKey String @map("business_key")
title String
content String
actionUrl String? @map("action_url")
status String @default("pending")
attemptCount Int @default(0) @map("attempt_count")
maxAttempts Int @default(5) @map("max_attempts")
nextRetryAt DateTime? @map("next_retry_at")
providerType String @default("work_notice") @map("provider_type")
providerMessageId String? @map("provider_message_id")
lastError String? @map("last_error")
sentAt DateTime? @map("sent_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
project WorkReviewProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
version WorkReviewVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
@@unique([eventType, businessKey], map: "work_review_notifications_event_key")
@@index([projectId, createdAt], map: "work_review_notifications_project_created_idx")
@@index([versionId], map: "work_review_notifications_version_idx")
@@index([recipientEmployeeId], map: "work_review_notifications_recipient_employee_idx")
@@index([recipientUserId], map: "work_review_notifications_recipient_user_idx")
@@index([dingtalkUserId], map: "work_review_notifications_dingtalk_user_idx")
@@index([status, nextRetryAt], map: "work_review_notifications_status_retry_idx")
@@map("work_review_notifications")
}
model VideoTranscodeProgress { model VideoTranscodeProgress {
id String @id @default(uuid()) id String @id @default(uuid())
materialId String @map("material_id") materialId String @map("material_id")

View File

@ -0,0 +1,150 @@
import bcrypt from 'bcryptjs'
import { PrismaClient, Role } from '@prisma/client'
const prisma = new PrismaClient()
const password = String(process.env.LOCAL_TEST_PASSWORD || '').trim()
if (!password) {
throw new Error('缺少 LOCAL_TEST_PASSWORD')
}
const users = [
{
key: 'submitter',
email: 'submitter@local.test',
username: '测试提交者',
realName: '测试提交者',
dingtalkUserId: 'local-submit-ding',
role: Role.USER,
positionLevel: 'P3',
levelType: 'P',
positionTitle: '视觉设计师',
jobNumber: 'LOCAL-P001',
},
{
key: 'manager',
email: 'manager@local.test',
username: '测试主管',
realName: '测试主管',
dingtalkUserId: 'local-manager-ding',
role: Role.USER,
positionLevel: 'M2',
levelType: 'M',
positionTitle: '视觉设计主管',
jobNumber: 'LOCAL-M001',
},
{
key: 'finalReviewer',
email: 'yuanze@local.test',
username: '元泽',
realName: '元泽',
dingtalkUserId: 'local-yuanze-ding',
role: Role.SYSTEM_ADMIN,
positionLevel: 'M3',
levelType: 'M',
positionTitle: '终审负责人',
jobNumber: 'LOCAL-YZ001',
},
] as const
async function main() {
const passwordHash = await bcrypt.hash(password, 10)
const createdUsers = new Map<string, { id: string }>()
for (const item of users) {
const user = await prisma.user.upsert({
where: { email: item.email },
update: {
username: item.username,
password: passwordHash,
role: item.role,
isActive: true,
status: 'active',
realName: item.realName,
dingtalkUserId: item.dingtalkUserId,
department: '测试设计部',
employmentStatus: 'active',
},
create: {
email: item.email,
username: item.username,
password: passwordHash,
role: item.role,
isActive: true,
status: 'active',
realName: item.realName,
dingtalkUserId: item.dingtalkUserId,
department: '测试设计部',
employmentStatus: 'active',
},
select: { id: true },
})
createdUsers.set(item.key, user)
}
for (const item of users) {
const user = createdUsers.get(item.key)
if (!user) throw new Error(`本地测试账号创建失败: ${item.key}`)
await prisma.performanceSurveyEmployeeProfile.upsert({
where: { dingtalkUserId: item.dingtalkUserId },
update: {
dingEmail: item.email,
jobNumber: item.jobNumber,
flowerName: item.realName,
realName: item.realName,
displayName: item.realName,
departmentName: '测试设计部',
departmentPath: '测试设计部',
positionTitle: item.positionTitle,
positionLevel: item.positionLevel,
levelType: item.levelType,
isLeadership: item.levelType === 'M',
managerDingtalkUserId: item.key === 'submitter' ? 'local-manager-ding' : null,
managerJobNumber: item.key === 'submitter' ? 'LOCAL-M001' : null,
managerName: item.key === 'submitter' ? '测试主管' : null,
employmentStatus: 'active',
sourcePayload: {
materialUserId: user.id,
localTest: true,
},
syncedAt: new Date(),
},
create: {
dingtalkUserId: item.dingtalkUserId,
dingEmail: item.email,
jobNumber: item.jobNumber,
flowerName: item.realName,
realName: item.realName,
displayName: item.realName,
departmentName: '测试设计部',
departmentPath: '测试设计部',
positionTitle: item.positionTitle,
positionLevel: item.positionLevel,
levelType: item.levelType,
isLeadership: item.levelType === 'M',
managerDingtalkUserId: item.key === 'submitter' ? 'local-manager-ding' : null,
managerJobNumber: item.key === 'submitter' ? 'LOCAL-M001' : null,
managerName: item.key === 'submitter' ? '测试主管' : null,
employmentStatus: 'active',
sourcePayload: {
materialUserId: user.id,
localTest: true,
},
syncedAt: new Date(),
},
})
}
console.log('本地作品过稿测试账号已准备完成')
}
main()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(async () => {
await prisma.$disconnect()
})

1
types/jest-dom.d.ts vendored Normal file
View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

147
types/work-review.ts Normal file
View File

@ -0,0 +1,147 @@
export type WorkReviewStatus = 'pending_review' | 'revision_required' | 'approved'
export type WorkReviewRole = 'manager' | 'final_reviewer'
export type WorkReviewDecision = 'approved' | 'revision_required'
export type WorkReviewImageSlice = {
url: string
width: number
height: number
}
export type WorkReviewImageGroup = {
version?: number
imageId?: string
sourceName: string
sourceWidth?: number
sourceHeight?: number
previewUrl: string
previewWidth: number
previewHeight: number
width: number
height: number
slices: WorkReviewImageSlice[]
}
export type WorkReviewImageComment = {
imageId?: string
imageIndex: number
sourceName?: string
decision: WorkReviewDecision
comment: string
}
export type WorkReviewReview = {
id: string
reviewerRole: WorkReviewRole
reviewerName: string
decision: WorkReviewDecision
imageComments: WorkReviewImageComment[]
generalComment: string | null
reviewedAt: string
}
export type WorkReviewVersion = {
id: string
version: number
submissionNote: string | null
status: WorkReviewStatus
submittedAt: string
completedAt: string | null
imageUrls: string[]
imageGroups: WorkReviewImageGroup[]
reviews: WorkReviewReview[]
}
export type WorkReviewPerson = {
id: string
name: string
}
export type WorkReviewProject = {
id: string
title: string
description: string | null
workDate: string
status: WorkReviewStatus
latestVersion: number
submitter: WorkReviewPerson & {
snapshot?: Record<string, unknown> | null
}
manager: WorkReviewPerson
finalReviewer: WorkReviewPerson
createdAt: string
updatedAt: string
versions: WorkReviewVersion[]
permissions: {
canResubmit: boolean
pendingReviewRoles: WorkReviewRole[]
}
}
export type WorkReviewMe = {
user: {
id: string
name: string
avatar: string | null
role: string
}
employee: {
id: string
displayName: string
flowerName?: string | null
realName?: string | null
departmentName?: string | null
positionTitle?: string | null
positionLevel?: string | null
levelType?: string | null
} | null
permissions: {
canSubmit: boolean
isSystemAdmin: boolean
isManager: boolean
isFinalReviewer: boolean
}
}
export type WorkReviewBoardData = {
me: WorkReviewMe
items: WorkReviewProject[]
pagination: {
page: number
pageSize: number
total: number
totalPages: number
}
}
export type WorkReviewProcessedImage = {
uploadId: string
mimeType: 'image/webp'
width: number
height: number
kind: 'slice' | 'preview'
sourceName: string
sourceIndex: number
sliceIndex: number
sliceCount: number
}
export type WorkReviewSubmissionInput = {
title?: string
description?: string
workDate?: string
submissionNote?: string
processedImages: WorkReviewProcessedImage[]
}
export type WorkReviewReviewInput = {
reviewerRole: WorkReviewRole
imageComments: Array<{
imageIndex: number
decision: WorkReviewDecision
comment: string
}>
generalComment: string
}