feat: 新增作品过稿看板及本地测试环境
This commit is contained in:
parent
1970c0f644
commit
417a3b0243
@ -304,7 +304,7 @@ function createH5LoginErrorPage(message: string) {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>无法打开问卷</h1>
|
<h1>无法打开页面</h1>
|
||||||
<p id="message"></p>
|
<p id="message"></p>
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
@ -362,7 +362,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
return createH5LoginErrorPage('暂时无法打开问卷,请从最新通知重新进入。')
|
return createH5LoginErrorPage('暂时无法打开页面,请从最新通知重新进入。')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -390,7 +390,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
|
|||||||
redirectPath,
|
redirectPath,
|
||||||
message: data?.message || '',
|
message: data?.message || '',
|
||||||
})
|
})
|
||||||
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
|
return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.needCompleteProfile) {
|
if (data.needCompleteProfile) {
|
||||||
@ -406,7 +406,7 @@ async function handleH5LoginPage(request: NextRequest, code: string) {
|
|||||||
return createSessionTicketTransferPage({ request, redirectPath, loginResponse })
|
return createSessionTicketTransferPage({ request, redirectPath, loginResponse })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('钉钉H5免登页面跳转失败:', error)
|
console.error('钉钉H5免登页面跳转失败:', error)
|
||||||
return createH5LoginErrorPage('暂时无法打开问卷,请稍后重试或联系管理员。')
|
return createH5LoginErrorPage('暂时无法打开页面,请稍后重试或联系管理员。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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) {
|
export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEntryClientProps) {
|
||||||
const startedRef = useRef(false)
|
const startedRef = useRef(false)
|
||||||
const [message, setMessage] = useState('正在打开问卷...')
|
const [message, setMessage] = useState('正在打开页面...')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedRef.current) return
|
if (startedRef.current) return
|
||||||
@ -132,7 +132,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
|||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
if (!corpId) {
|
if (!corpId) {
|
||||||
setMessage('请返回钉钉,从最新通知重新打开问卷。')
|
setMessage('请返回钉钉,从最新通知重新打开。')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,7 +142,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setMessage('正在打开问卷...')
|
setMessage('正在打开页面...')
|
||||||
if (await hasActiveSession()) {
|
if (await hasActiveSession()) {
|
||||||
window.location.replace(redirectPath)
|
window.location.replace(redirectPath)
|
||||||
return
|
return
|
||||||
@ -154,8 +154,8 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
|||||||
|
|
||||||
navigateDingtalkH5Login(authCode, redirectPath)
|
navigateDingtalkH5Login(authCode, redirectPath)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Dingtalk Entry] 打开问卷失败:', error)
|
console.error('[Dingtalk Entry] 打开页面失败:', error)
|
||||||
setMessage('请返回钉钉,从最新通知重新打开问卷。')
|
setMessage('请返回钉钉,从最新通知重新打开。')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,13 +174,13 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
|||||||
|
|
||||||
<section className="mx-auto max-w-[430px] px-5 py-8">
|
<section className="mx-auto max-w-[430px] px-5 py-8">
|
||||||
<div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
|
<div className="overflow-hidden rounded-[26px] border border-[#e3eaf3] bg-white p-6 shadow-[0_18px_48px_rgba(15,23,42,0.10)]">
|
||||||
<div className="text-[14px] font-semibold text-[#1677ff]">考核调研</div>
|
<div className="text-[14px] font-semibold text-[#1677ff]">由壹点工作台</div>
|
||||||
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开问卷</h1>
|
<h1 className="mt-3 text-[28px] font-semibold leading-tight tracking-[-0.01em] text-[#101828]">正在打开页面</h1>
|
||||||
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">{message}</p>
|
<p className="mt-3 text-[14px] font-medium leading-6 text-[#667085]">{message}</p>
|
||||||
|
|
||||||
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
|
<div className="mt-6 rounded-[22px] bg-[#f6f8fb] p-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<span className="text-[15px] font-semibold text-[#101828]">准备问卷内容</span>
|
<span className="text-[15px] font-semibold text-[#101828]">准备页面内容</span>
|
||||||
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
|
<span className="text-[14px] font-semibold text-[#1677ff]">马上进入</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
|
<div className="mt-4 h-1.5 overflow-hidden rounded-full bg-[#e8f1ff]">
|
||||||
@ -189,7 +189,7 @@ export default function DingtalkEntryClient({ corpId, redirectPath }: DingtalkEn
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5">
|
<div className="mt-7 rounded-[18px] border border-[#eef2f7] bg-white p-5">
|
||||||
<div className="text-[15px] font-semibold text-[#7b8494]">绩效考核问卷</div>
|
<div className="text-[15px] font-semibold text-[#7b8494]">业务工作台</div>
|
||||||
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
|
<div className="mt-6 h-3 rounded-full bg-[#eef3f9]" />
|
||||||
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
|
<div className="mt-3 h-3 w-3/4 rounded-full bg-[#eef3f9]" />
|
||||||
<div className="mt-8 space-y-5">
|
<div className="mt-8 space-y-5">
|
||||||
|
|||||||
22
app/work-review/page.tsx
Normal file
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>
|
<span>考核调研</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* 作品过稿看板 */}
|
||||||
|
<Link
|
||||||
|
href="/work-review"
|
||||||
|
onClick={() => setShowDropdown(false)}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm4 4h8M8 13h5m-5 4h3" />
|
||||||
|
</svg>
|
||||||
|
<span>作品过稿看板</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* 更新记录 */}
|
{/* 更新记录 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
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()
|
assertPerformanceSurveyBrowserImageSupport()
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -77,13 +80,16 @@ function runImageWorker(files: File[]): Promise<WorkerProcessedImage[]> {
|
|||||||
worker.onerror = () => {
|
worker.onerror = () => {
|
||||||
finish(() => reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试')))
|
finish(() => reject(new Error('浏览器图片处理线程启动失败,请使用桌面版 Chrome 或 Edge 重试')))
|
||||||
}
|
}
|
||||||
worker.postMessage({ id: requestId, files })
|
worker.postMessage({ id: requestId, files, profile })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function preparePerformanceSurveyImagesInBrowser(
|
export async function preparePerformanceSurveyImagesInBrowser(
|
||||||
files: File[],
|
files: File[],
|
||||||
onProgress?: (label: string) => void,
|
onProgress?: (label: string) => void,
|
||||||
|
options?: {
|
||||||
|
profile?: 'performance_survey' | 'work_review'
|
||||||
|
},
|
||||||
): Promise<PerformanceSurveyBrowserProcessedImage[]> {
|
): Promise<PerformanceSurveyBrowserProcessedImage[]> {
|
||||||
if (files.length === 0) throw new Error('请至少选择 1 张图片')
|
if (files.length === 0) throw new Error('请至少选择 1 张图片')
|
||||||
|
|
||||||
@ -93,7 +99,7 @@ export async function preparePerformanceSurveyImagesInBrowser(
|
|||||||
? '正在浏览器后台生成预览和 WebP 切片'
|
? '正在浏览器后台生成预览和 WebP 切片'
|
||||||
: `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`,
|
: `正在浏览器后台转换 ${orderedFiles.length} 张有序切片为 WebP`,
|
||||||
)
|
)
|
||||||
const processed = await runImageWorker(orderedFiles)
|
const processed = await runImageWorker(orderedFiles, options?.profile || 'performance_survey')
|
||||||
|
|
||||||
return processed.map((item) => ({
|
return processed.map((item) => ({
|
||||||
file: new File([item.blob], item.fileName, {
|
file: new File([item.blob], item.fileName, {
|
||||||
|
|||||||
@ -62,6 +62,7 @@ export type PerformanceSurveyStoredImageSlice = {
|
|||||||
|
|
||||||
export type PerformanceSurveyStoredImageGroup = {
|
export type PerformanceSurveyStoredImageGroup = {
|
||||||
version: 1
|
version: 1
|
||||||
|
imageId?: string
|
||||||
sourceName: string
|
sourceName: string
|
||||||
sourceWidth: number
|
sourceWidth: number
|
||||||
sourceHeight: number
|
sourceHeight: number
|
||||||
@ -411,6 +412,7 @@ export function serializePerformanceSurveyImagePayload(value: unknown): Performa
|
|||||||
const previewUrl = String(item.previewUrl || slices[0].url).trim()
|
const previewUrl = String(item.previewUrl || slices[0].url).trim()
|
||||||
return [{
|
return [{
|
||||||
version: 1,
|
version: 1,
|
||||||
|
imageId: String(item.imageId || '').trim() || undefined,
|
||||||
sourceName: String(item.sourceName || '案例图片').trim() || '案例图片',
|
sourceName: String(item.sourceName || '案例图片').trim() || '案例图片',
|
||||||
sourceWidth: Math.max(1, Number(item.sourceWidth) || width),
|
sourceWidth: Math.max(1, Number(item.sourceWidth) || width),
|
||||||
sourceHeight: Math.max(1, Number(item.sourceHeight) || Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
|
sourceHeight: Math.max(1, Number(item.sourceHeight) || Number(item.height) || slices.reduce((sum, slice) => sum + slice.height, 0)),
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export {}
|
|||||||
type WorkerRequest = {
|
type WorkerRequest = {
|
||||||
id: string
|
id: string
|
||||||
files: File[]
|
files: File[]
|
||||||
|
profile?: 'performance_survey' | 'work_review'
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerProcessedImage = {
|
type WorkerProcessedImage = {
|
||||||
@ -26,14 +27,14 @@ type WorkerProcessedImage = {
|
|||||||
sliceCount: number
|
sliceCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const OUTPUT_WIDTH = 1_600
|
|
||||||
const SLICE_HEIGHT = 1_500
|
const SLICE_HEIGHT = 1_500
|
||||||
const PREVIEW_WIDTH = 720
|
const PREVIEW_WIDTH = 720
|
||||||
const PREVIEW_HEIGHT = 560
|
const PREVIEW_HEIGHT = 560
|
||||||
const MAX_SLICES_PER_SOURCE = 60
|
const MAX_SLICES_PER_SOURCE = 60
|
||||||
const MAX_ORDERED_SLICE_COUNT = 60
|
const MAX_ORDERED_SLICE_COUNT = 60
|
||||||
const SLICE_QUALITY = 0.76
|
|
||||||
const PREVIEW_QUALITY = 0.68
|
const PREVIEW_QUALITY = 0.68
|
||||||
|
let outputWidthLimit = 1_600
|
||||||
|
let sliceQuality = 0.76
|
||||||
const workerScope = self as unknown as DedicatedWorkerGlobalScope
|
const workerScope = self as unknown as DedicatedWorkerGlobalScope
|
||||||
|
|
||||||
function removeFileExtension(fileName: string) {
|
function removeFileExtension(fileName: string) {
|
||||||
@ -88,7 +89,7 @@ async function renderSlice(
|
|||||||
outputWidth,
|
outputWidth,
|
||||||
sliceHeight,
|
sliceHeight,
|
||||||
)
|
)
|
||||||
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: SLICE_QUALITY })
|
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: sliceQuality })
|
||||||
return { blob, width: outputWidth, height: sliceHeight }
|
return { blob, width: outputWidth, height: sliceHeight }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +126,7 @@ async function processSingleOriginal(file: File): Promise<WorkerProcessedImage[]
|
|||||||
const bitmap = await decodeImage(file)
|
const bitmap = await decodeImage(file)
|
||||||
try {
|
try {
|
||||||
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
|
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
|
||||||
const scale = Math.min(1, OUTPUT_WIDTH / bitmap.width)
|
const scale = Math.min(1, outputWidthLimit / bitmap.width)
|
||||||
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
|
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
|
||||||
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
||||||
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
|
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
|
||||||
@ -187,7 +188,7 @@ async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage
|
|||||||
const bitmap = await decodeImage(file)
|
const bitmap = await decodeImage(file)
|
||||||
try {
|
try {
|
||||||
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
|
if (!bitmap.width || !bitmap.height) throw new Error(`图片「${file.name}」尺寸无效`)
|
||||||
const scale = Math.min(1, OUTPUT_WIDTH / bitmap.width)
|
const scale = Math.min(1, outputWidthLimit / bitmap.width)
|
||||||
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
|
const outputWidth = Math.max(1, Math.round(bitmap.width * scale))
|
||||||
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
const outputHeight = Math.max(1, Math.round(bitmap.height * scale))
|
||||||
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
|
const sliceCount = Math.max(1, Math.ceil(outputHeight / SLICE_HEIGHT))
|
||||||
@ -240,9 +241,11 @@ async function processOrderedSlices(files: File[]): Promise<WorkerProcessedImage
|
|||||||
}
|
}
|
||||||
|
|
||||||
workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
workerScope.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
||||||
const { id, files } = event.data || {}
|
const { id, files, profile } = event.data || {}
|
||||||
try {
|
try {
|
||||||
if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片')
|
if (!id || !Array.isArray(files) || files.length === 0) throw new Error('没有可处理的图片')
|
||||||
|
outputWidthLimit = profile === 'work_review' ? 3_200 : 1_600
|
||||||
|
sliceQuality = profile === 'work_review' ? 0.9 : 0.76
|
||||||
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
|
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') {
|
||||||
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
|
throw new Error('图片需要在桌面版 Chrome 或 Edge 中处理,请更换浏览器后重新上传')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,7 +50,12 @@ function readInteger(value: unknown, label: string, min: number, max: number) {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDeclaration {
|
function normalizeDeclaration(
|
||||||
|
value: unknown,
|
||||||
|
options?: {
|
||||||
|
maxSliceWidth?: number
|
||||||
|
},
|
||||||
|
): PerformanceSurveyProcessedImageDeclaration {
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
throw new PerformanceSurveyProcessedImageError('预处理图片参数无效')
|
throw new PerformanceSurveyProcessedImageError('预处理图片参数无效')
|
||||||
}
|
}
|
||||||
@ -67,7 +72,7 @@ function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDe
|
|||||||
|
|
||||||
const maxWidth = kind === 'preview'
|
const maxWidth = kind === 'preview'
|
||||||
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewWidth
|
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewWidth
|
||||||
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth
|
: options?.maxSliceWidth || PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceWidth
|
||||||
const maxHeight = kind === 'preview'
|
const maxHeight = kind === 'preview'
|
||||||
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewHeight
|
? PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxPreviewHeight
|
||||||
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceHeight
|
: PERFORMANCE_SURVEY_PROCESSED_IMAGE_LIMITS.maxSliceHeight
|
||||||
@ -102,6 +107,9 @@ function normalizeDeclaration(value: unknown): PerformanceSurveyProcessedImageDe
|
|||||||
|
|
||||||
export function validatePerformanceSurveyProcessedImages(
|
export function validatePerformanceSurveyProcessedImages(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
|
options?: {
|
||||||
|
maxSliceWidth?: number
|
||||||
|
},
|
||||||
): PerformanceSurveyProcessedImageGroup[] {
|
): PerformanceSurveyProcessedImageGroup[] {
|
||||||
if (!Array.isArray(value) || value.length === 0) {
|
if (!Array.isArray(value) || value.length === 0) {
|
||||||
throw new PerformanceSurveyProcessedImageError('每个案例至少上传 1 张预处理图片')
|
throw new PerformanceSurveyProcessedImageError('每个案例至少上传 1 张预处理图片')
|
||||||
@ -110,7 +118,7 @@ export function validatePerformanceSurveyProcessedImages(
|
|||||||
throw new PerformanceSurveyProcessedImageError('预处理图片数量过多')
|
throw new PerformanceSurveyProcessedImageError('预处理图片数量过多')
|
||||||
}
|
}
|
||||||
|
|
||||||
const declarations = value.map(normalizeDeclaration)
|
const declarations = value.map((item) => normalizeDeclaration(item, options))
|
||||||
const uploadIds = new Set<string>()
|
const uploadIds = new Set<string>()
|
||||||
const groups = new Map<number, PerformanceSurveyProcessedImageDeclaration[]>()
|
const groups = new Map<number, PerformanceSurveyProcessedImageDeclaration[]>()
|
||||||
for (const declaration of declarations) {
|
for (const declaration of declarations) {
|
||||||
|
|||||||
@ -603,6 +603,15 @@ export function classifyPublicTrafficPurpose(domain: string, pathValue: string,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pathLower.startsWith('/api/work-review') || pathLower.startsWith('/work-review')) {
|
||||||
|
return {
|
||||||
|
key: 'work-review',
|
||||||
|
label: '作品过稿看板',
|
||||||
|
category: '素材库',
|
||||||
|
detail: '作品提交、版本留档、主管与元泽评审相关请求。',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pathLower.startsWith('/admin') || pathLower.startsWith('/api/admin/')) {
|
if (pathLower.startsWith('/admin') || pathLower.startsWith('/api/admin/')) {
|
||||||
return {
|
return {
|
||||||
key: 'admin-console',
|
key: 'admin-console',
|
||||||
|
|||||||
144
lib/work-review-client.ts
Normal file
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[]
|
notifications PerformanceSurveyNotification[]
|
||||||
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
|
manualPositionAssignments PerformanceSurveyEmployeePositionAssignment[]
|
||||||
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
|
departmentMemberships PerformanceSurveyEmployeeDepartmentMembership[]
|
||||||
|
submittedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewSubmitter")
|
||||||
|
managedWorkReviewProjects WorkReviewProject[] @relation("WorkReviewManager")
|
||||||
|
finalWorkReviewProjects WorkReviewProject[] @relation("WorkReviewFinalReviewer")
|
||||||
|
workReviews WorkReviewReview[] @relation("WorkReviewReviewer")
|
||||||
|
|
||||||
@@index([displayName])
|
@@index([displayName])
|
||||||
@@index([dingEmail])
|
@@index([dingEmail])
|
||||||
@ -981,6 +985,123 @@ model PerformanceSurveyConfigAuditLog {
|
|||||||
@@map("performance_survey_config_audit_logs")
|
@@map("performance_survey_config_audit_logs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model WorkReviewProject {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
workDate DateTime @db.Date @map("work_date")
|
||||||
|
submitterUserId String @map("submitter_user_id")
|
||||||
|
submitterEmployeeId String @map("submitter_employee_id")
|
||||||
|
submitterName String @map("submitter_name")
|
||||||
|
submitterSnapshot Json @map("submitter_snapshot")
|
||||||
|
managerEmployeeId String @map("manager_employee_id")
|
||||||
|
managerUserId String? @map("manager_user_id")
|
||||||
|
managerDingtalkUserId String? @map("manager_dingtalk_user_id")
|
||||||
|
managerName String @map("manager_name")
|
||||||
|
finalReviewerEmployeeId String @map("final_reviewer_employee_id")
|
||||||
|
finalReviewerUserId String? @map("final_reviewer_user_id")
|
||||||
|
finalReviewerDingtalkUserId String? @map("final_reviewer_dingtalk_user_id")
|
||||||
|
finalReviewerName String @map("final_reviewer_name")
|
||||||
|
status String @default("pending_review")
|
||||||
|
latestVersion Int @default(1) @map("latest_version")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
submitterEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewSubmitter", fields: [submitterEmployeeId], references: [id], onDelete: Restrict)
|
||||||
|
managerEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewManager", fields: [managerEmployeeId], references: [id], onDelete: Restrict)
|
||||||
|
finalReviewerEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewFinalReviewer", fields: [finalReviewerEmployeeId], references: [id], onDelete: Restrict)
|
||||||
|
versions WorkReviewVersion[]
|
||||||
|
notifications WorkReviewNotification[]
|
||||||
|
|
||||||
|
@@index([submitterUserId, workDate], map: "work_review_projects_submitter_date_idx")
|
||||||
|
@@index([submitterEmployeeId, workDate], map: "work_review_projects_submitter_employee_date_idx")
|
||||||
|
@@index([managerEmployeeId, status], map: "work_review_projects_manager_status_idx")
|
||||||
|
@@index([finalReviewerEmployeeId, status], map: "work_review_projects_final_reviewer_status_idx")
|
||||||
|
@@index([status, workDate], map: "work_review_projects_status_date_idx")
|
||||||
|
@@index([updatedAt], map: "work_review_projects_updated_at_idx")
|
||||||
|
@@map("work_review_projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkReviewVersion {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
projectId String @map("project_id")
|
||||||
|
version Int
|
||||||
|
submissionNote String? @map("submission_note")
|
||||||
|
imageUrls Json @map("image_urls")
|
||||||
|
status String @default("pending_review")
|
||||||
|
submittedAt DateTime @default(now()) @map("submitted_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
project WorkReviewProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
reviews WorkReviewReview[]
|
||||||
|
notifications WorkReviewNotification[]
|
||||||
|
|
||||||
|
@@unique([projectId, version], map: "work_review_versions_project_version_key")
|
||||||
|
@@index([projectId, submittedAt], map: "work_review_versions_project_submitted_idx")
|
||||||
|
@@index([status, submittedAt], map: "work_review_versions_status_submitted_idx")
|
||||||
|
@@map("work_review_versions")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkReviewReview {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
versionId String @map("version_id")
|
||||||
|
reviewerRole String @map("reviewer_role")
|
||||||
|
reviewerEmployeeId String @map("reviewer_employee_id")
|
||||||
|
reviewerUserId String? @map("reviewer_user_id")
|
||||||
|
reviewerName String @map("reviewer_name")
|
||||||
|
reviewerSnapshot Json @map("reviewer_snapshot")
|
||||||
|
decision String
|
||||||
|
imageComments Json @map("image_comments")
|
||||||
|
generalComment String? @map("general_comment")
|
||||||
|
reviewedAt DateTime @default(now()) @map("reviewed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
version WorkReviewVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||||
|
reviewerEmployee PerformanceSurveyEmployeeProfile @relation("WorkReviewReviewer", fields: [reviewerEmployeeId], references: [id], onDelete: Restrict)
|
||||||
|
|
||||||
|
@@unique([versionId, reviewerRole], map: "work_review_reviews_version_role_key")
|
||||||
|
@@index([reviewerEmployeeId, reviewedAt], map: "work_review_reviews_reviewer_reviewed_idx")
|
||||||
|
@@index([decision], map: "work_review_reviews_decision_idx")
|
||||||
|
@@map("work_review_reviews")
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkReviewNotification {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
projectId String @map("project_id")
|
||||||
|
versionId String @map("version_id")
|
||||||
|
recipientEmployeeId String? @map("recipient_employee_id")
|
||||||
|
recipientUserId String? @map("recipient_user_id")
|
||||||
|
dingtalkUserId String? @map("dingtalk_user_id")
|
||||||
|
recipientName String @map("recipient_name")
|
||||||
|
eventType String @map("event_type")
|
||||||
|
businessKey String @map("business_key")
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
actionUrl String? @map("action_url")
|
||||||
|
status String @default("pending")
|
||||||
|
attemptCount Int @default(0) @map("attempt_count")
|
||||||
|
maxAttempts Int @default(5) @map("max_attempts")
|
||||||
|
nextRetryAt DateTime? @map("next_retry_at")
|
||||||
|
providerType String @default("work_notice") @map("provider_type")
|
||||||
|
providerMessageId String? @map("provider_message_id")
|
||||||
|
lastError String? @map("last_error")
|
||||||
|
sentAt DateTime? @map("sent_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
project WorkReviewProject @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
|
version WorkReviewVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([eventType, businessKey], map: "work_review_notifications_event_key")
|
||||||
|
@@index([projectId, createdAt], map: "work_review_notifications_project_created_idx")
|
||||||
|
@@index([versionId], map: "work_review_notifications_version_idx")
|
||||||
|
@@index([recipientEmployeeId], map: "work_review_notifications_recipient_employee_idx")
|
||||||
|
@@index([recipientUserId], map: "work_review_notifications_recipient_user_idx")
|
||||||
|
@@index([dingtalkUserId], map: "work_review_notifications_dingtalk_user_idx")
|
||||||
|
@@index([status, nextRetryAt], map: "work_review_notifications_status_retry_idx")
|
||||||
|
@@map("work_review_notifications")
|
||||||
|
}
|
||||||
|
|
||||||
model VideoTranscodeProgress {
|
model VideoTranscodeProgress {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
materialId String @map("material_id")
|
materialId String @map("material_id")
|
||||||
|
|||||||
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