feat: 新增作品过稿看板及本地测试环境
This commit is contained in:
parent
1970c0f644
commit
417a3b0243
@ -304,7 +304,7 @@ function createH5LoginErrorPage(message: string) {
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>无法打开问卷</h1>
|
||||
<h1>无法打开页面</h1>
|
||||
<p id="message"></p>
|
||||
</main>
|
||||
<script>
|
||||
@ -362,7 +362,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
|
||||
})
|
||||
|
||||
if (!code) {
|
||||
return createH5LoginErrorPage('暂时无法打开问卷,请从最新通知重新进入。')
|
||||
return createH5LoginErrorPage('暂时无法打开页面,请从最新通知重新进入。')
|
||||
}
|
||||
|
||||
try {
|
||||
@ -390,7 +390,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
|
||||
redirectPath,
|
||||
message: data?.message || '',
|
||||
})
|
||||
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
|
||||
return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
|
||||
}
|
||||
|
||||
if (data.needCompleteProfile) {
|
||||
@ -406,7 +406,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
|
||||
return createSessionTicketTransferPage({ request, redirectPath, loginResponse })
|
||||
} catch (error: any) {
|
||||
console.error('钉钉H5免登页面跳转失败:', error)
|
||||
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
|
||||
return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
72
app/api/work-review/[[...path]]/route.ts
Normal file
72
app/api/work-review/[[...path]]/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -124,7 +124,7 @@ async function hasActiveSession() {
|
||||
|
||||
export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEntryClientProps) {
|
||||
const startedRef = useRef(false)
|
||||
const [message, setMessage] = useState('正在打开问卷...')
|
||||
const [message, setMessage] = useState('正在打开页面...')
|
||||
|
||||
useEffect(() => {
|
||||
if (startedRef.current) return
|
||||
@ -132,7 +132,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
||||
|
||||
const run = async () => {
|
||||
if (!corpId) {
|
||||
setMessage('请返回钉钉,从最新通知重新打开问卷。')
|
||||
setMessage('请返回钉钉,从最新通知重新打开。')
|
||||
return
|
||||
}
|
||||
|
||||
@ -142,7 +142,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
||||
}
|
||||
|
||||
try {
|
||||
setMessage('正在打开问卷...')
|
||||
setMessage('正在打开页面...')
|
||||
if (await hasActiveSession()) {
|
||||
window.location.replace(redirectPath)
|
||||
return
|
||||
@ -154,8 +154,8 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
||||
|
||||
navigateDingtalkH5Login(authCode, redirectPath)
|
||||
} catch (error) {
|
||||
console.error('[Dingtalk Entry] 打开问卷失败:', error)
|
||||
setMessage('请返回钉钉,从最新通知重新打开问卷。')
|
||||
console.error('[Dingtalk Entry] 打开页面失败:', error)
|
||||
setMessage('请返回钉钉,从最新通知重新打开。')
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,13 +174,13 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
||||
|
||||
<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="text-[14px] font-semibold text-[#1677ff]">考核调研</div>
|
||||
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1>
|
||||
<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>
|
||||
<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="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>
|
||||
</div>
|
||||
<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 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-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
|
||||
<div className="mt-8 space-y-5">
|
||||
|
||||
22
app/work-review/page.tsx
Normal file
22
app/work-review/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -587,6 +587,18 @@ export default function Header({ session }: HeaderProps) {
|
||||
<span>考核调研</span>
|
||||
</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
|
||||
onClick={() => {
|
||||
|
||||
461
components/work-review/WorkReviewBoard.tsx
Normal file
461
components/work-review/WorkReviewBoard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
529
components/work-review/WorkReviewDialogs.tsx
Normal file
529
components/work-review/WorkReviewDialogs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
62
components/work-review/WorkReviewHeader.tsx
Normal file
62
components/work-review/WorkReviewHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
154
components/work-review/WorkReviewImageDropzone.tsx
Normal file
154
components/work-review/WorkReviewImageDropzone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
248
components/work-review/WorkReviewImageGallery.tsx
Normal file
248
components/work-review/WorkReviewImageGallery.tsx
Normal 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,
|
||||
)
|
||||
}
|
||||
334
components/work-review/WorkReviewProjectCard.tsx
Normal file
334
components/work-review/WorkReviewProjectCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
44
components/work-review/WorkReviewStatusBadge.tsx
Normal file
44
components/work-review/WorkReviewStatusBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
docker-compose.local-test.yml
Normal file
139
docker-compose.local-test.yml
Normal 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:
|
||||
61
lib/__tests__/work-review-image-profile.test.ts
Normal file
61
lib/__tests__/work-review-image-profile.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
35
lib/__tests__/work-review-policy.test.ts
Normal file
35
lib/__tests__/work-review-policy.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -44,7 +44,10 @@ function assertPerformanceSurveyBrowserImageSupport() {
|
||||
}
|
||||
}
|
||||
|
||||
function runImageWorker(files: File[]): Promise<WorkerProcessedImage[]> {
|
||||
function runImageWorker(
|
||||
files: File[],
|
||||
profile: 'performance_survey' | 'work_review',
|
||||
): Promise<WorkerProcessedImage[]> {
|
||||
assertPerformanceSurveyBrowserImageSupport()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -77,13 +80,16 @@ function runImageWorker(files: File[]): Promise<WorkerProcessedImage[]> {
|
||||
worker.onerror = () => {
|
||||
finish(() => reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试')))
|
||||
}
|
||||
worker.postMessage({ id: requestId, files })
|
||||
worker.postMessage({ id: requestId, files, profile })
|
||||
})
|
||||
}
|
||||
|
||||
export async function preparePerformanceSurveyImagesInBrowser(
|
||||
files: File[],
|
||||
onProgress?: (label: string) => void,
|
||||
options?: {
|
||||
profile?: 'performance_survey' | 'work_review'
|
||||
},
|
||||
): Promise<PerformanceSurveyBrowserProcessedImage[]> {
|
||||
if (files.length === 0) throw new Error('请至少选择 1 张图片')
|
||||
|
||||
@ -93,7 +99,7 @@ export async function preparePerformanceSurveyImagesInBrowser(
|
||||
? '正在浏览器后台生成预览和 WebP 切片'
|
||||
: `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`,
|
||||
)
|
||||
const processed = await runImageWorker(orderedFiles)
|
||||
const processed = await runImageWorker(orderedFiles, options?.profile || 'performance_survey')
|
||||
|
||||
return processed.map((item) => ({
|
||||
file: new File([item.blob], item.fileName, {
|
||||
|
||||
@ -62,6 +62,7 @@ export type PerformanceSurveyStoredImageSlice = {
|
||||
|
||||
export type PerformanceSurveyStoredImageGroup = {
|
||||
version: 1
|
||||
imageId?: string
|
||||
sourceName: string
|
||||
sourceWidth: number
|
||||
sourceHeight: number
|
||||
@ -411,6 +412,7 @@ export function serializePerformanceSurveyImagePayload(value: unknown): Performa
|
||||
const previewUrl = String(item.previewUrl || slices[0].url).trim()
|
||||
return [{
|
||||
version: 1,
|
||||
imageId: String(item.imageId || '').trim() || undefined,
|
||||
sourceName: String(item.sourceName || '案例图片').trim() || '案例图片',
|
||||
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)),
|
||||
|
||||
@ -11,6 +11,7 @@ export {}
|
||||
type WorkerRequest = {
|
||||
id: string
|
||||
files: File[]
|
||||
profile?: 'performance_survey' | 'work_review'
|
||||
}
|
||||
|
||||
type WorkerProcessedImage = {
|
||||
@ -26,14 +27,14 @@ type WorkerProcessedImage = {
|
||||
sliceCount: number
|
||||
}
|
||||
|
||||
const OUTPUT_WIDTH = 1_600
|
||||
const SLICE_HEIGHT = 1_500
|
||||
const PREVIEW_WIDTH = 720
|
||||
const PREVIEW_HEIGHT = 560
|
||||
const MAX_SLICES_PER_SOURCE = 60
|
||||
const MAX_ORDERED_SLICE_COUNT = 60
|
||||
const SLICE_QUALITY = 0.76
|
||||
const PREVIEW_QUALITY = 0.68
|
||||
let outputWidthLimit = 1_600
|
||||
let sliceQuality = 0.76
|
||||
const workerScope = self as unknown as DedicatedWorkerGlobalScope
|
||||
|
||||
function removeFileExtension(fileName: string) {
|
||||
@ -88,7 +89,7 @@ async function renderSlice(
|
||||
outputWidth,
|
||||
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 }
|
||||
}
|
||||
|
||||
@ -125,7 +126,7 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
|
||||
const bitmap = await decodeImage(file)
|
||||
try {
|
||||
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 outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
||||
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)
|
||||
try {
|
||||
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 outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
||||
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>) => {
|
||||
const { id, files } = event.data || {}
|
||||
const { id, files, profile } = event.data || {}
|
||||
try {
|
||||
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') {
|
||||
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
|
||||
}
|
||||
|
||||
@ -50,7 +50,12 @@ function readInteger(value: unknown, label: string, min: number, max: number) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDeclaration {
|
||||
function normalizeDeclaration(
|
||||
value: unknown,
|
||||
options?: {
|
||||
maxSliceWidth?: number
|
||||
},
|
||||
): PerformanceSurveyProcessedImageDeclaration {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new PerformanceSurveyProcessedImageError('预处理图片参数无效')
|
||||
}
|
||||
@ -67,7 +72,7 @@ function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDe
|
||||
|
||||
const maxWidth = kind === 'preview'
|
||||
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewWidth
|
||||
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth
|
||||
: options?.maxSliceWidth || PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth
|
||||
const maxHeight = kind === 'preview'
|
||||
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewHeight
|
||||
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceHeight
|
||||
@ -102,6 +107,9 @@ function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDe
|
||||
|
||||
export function validatePerformanceSurveyProcessedImages(
|
||||
value: unknown,
|
||||
options?: {
|
||||
maxSliceWidth?: number
|
||||
},
|
||||
): PerformanceSurveyProcessedImageGroup[] {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new PerformanceSurveyProcessedImageError('每个案例至少上传 1 张预处理图片')
|
||||
@ -110,7 +118,7 @@ export function validatePerformanceSurveyProcessedImages(
|
||||
throw new PerformanceSurveyProcessedImageError('预处理图片数量过多')
|
||||
}
|
||||
|
||||
const declarations = value.map(normalizeDeclaration)
|
||||
const declarations = value.map((item) => normalizeDeclaration(item, options))
|
||||
const uploadIds = new Set<string>()
|
||||
const groups = new Map<number, PerformanceSurveyProcessedImageDeclaration[]>()
|
||||
for (const declaration of declarations) {
|
||||
|
||||
@ -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/')) {
|
||||
return {
|
||||
key: 'admin-console',
|
||||
|
||||
144
lib/work-review-client.ts
Normal file
144
lib/work-review-client.ts
Normal 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
|
||||
}
|
||||
275
lib/work-review-notifications.ts
Normal file
275
lib/work-review-notifications.ts
Normal 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
22
lib/work-review-policy.ts
Normal 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
917
lib/work-review-service.ts
Normal 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)
|
||||
}
|
||||
158
prisma/migrations/20260728_add_work_review_board/migration.sql
Normal file
158
prisma/migrations/20260728_add_work_review_board/migration.sql
Normal 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");
|
||||
@ -526,6 +526,10 @@ model PerformanceSurveyEmployeeProfile {
|
||||
notifications PerformanceSurveyNotification[]
|
||||
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
|
||||
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
|
||||
submittedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewSubmitter")
|
||||
managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager")
|
||||
finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer")
|
||||
workReviews WorkReviewReview[] @relation("WorkReviewReviewer")
|
||||
|
||||
@@index([displayName])
|
||||
@@index([dingEmail])
|
||||
@ -981,6 +985,123 @@ model PerformanceSurveyConfigAuditLog {
|
||||
@@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 {
|
||||
id String @id @default(uuid())
|
||||
materialId String @map("material_id")
|
||||
|
||||
150
scripts/seed-work-review-local-test.ts
Normal file
150
scripts/seed-work-review-local-test.ts
Normal 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
1
types/jest-dom.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
147
types/work-review.ts
Normal file
147
types/work-review.ts
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user