69 lines
15 KiB
TypeScript
69 lines
15 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom';
|
||
import { ArrowLeft, ArrowRight, Check, KeyRound } from 'lucide-react';
|
||
import type { CustomerAccessState, Note, NoteDetail, Project } from '@shared/types';
|
||
import { api, ApiError } from '@/api/client';
|
||
import AnnotatableImage from '@/components/AnnotatableImage';
|
||
import AnnotatableText, { type TextSelectionDraft } from '@/components/AnnotatableText';
|
||
import CollaborationDrawer, { type DrawerTextDraft } from '@/components/CollaborationDrawer';
|
||
import ImageReviewModal from '@/components/ImageReviewModal';
|
||
import StatusBadge from '@/components/StatusBadge';
|
||
|
||
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status' | 'review_status'>; works: Note[]; reviewer_name: string };
|
||
|
||
export default function CustomerReviewPage() {
|
||
const { slug = '', collectionId, noteId } = useParams();
|
||
const [search] = useSearchParams();
|
||
const selectedRound = search.get('round') ? Number(search.get('round')) : undefined;
|
||
const [access, setAccess] = useState<CustomerAccessState | null>(null);
|
||
const [projectData, setProjectData] = useState<ProjectPayload | null>(null);
|
||
const [work, setWork] = useState<NoteDetail | null>(null);
|
||
const [name, setName] = useState(''); const [password, setPassword] = useState('');
|
||
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
|
||
const load = useCallback(async () => { setError(''); try { const state = await api.getCustomerAccess(slug); setAccess(state); if (!state.authenticated) return; if (noteId) setWork(await api.getCustomerWorkRound(slug, Number(noteId), selectedRound)); else setProjectData(await api.getCustomerProject(slug)); } catch (reason) { setError(reason instanceof ApiError ? reason.message : '页面加载失败'); } }, [slug, noteId, selectedRound]);
|
||
useEffect(() => { void load(); }, [load]);
|
||
const login = async (event: React.FormEvent) => { event.preventDefault(); setBusy(true); setError(''); try { await api.customerLogin(slug, { reviewer_name: name, password }); await load(); } catch (reason) { setError(reason instanceof ApiError ? reason.message : '验证失败'); } finally { setBusy(false); } };
|
||
|
||
if (collectionId) return <Navigate to={`/review/${slug}`} replace/>;
|
||
if (!access && !error) return <Centered text="正在打开验收空间…"/>;
|
||
if (!access) return <Centered text={error || '项目不存在'}/>;
|
||
if (!access.enabled || access.expired) return <Centered text={access.expired ? '此项目的访问链接已到期' : '此项目暂未开放客户访问'}/>;
|
||
if (!access.authenticated) return <AccessGate access={access} name={name} password={password} error={error} busy={busy} setName={setName} setPassword={setPassword} submit={login}/>;
|
||
if (noteId) return work ? <WorkReview slug={slug} work={work} reviewer={access.reviewer_name || '客户'} reload={load}/> : <Centered text={error || '正在加载作品…'}/>;
|
||
return projectData ? <ProjectReview slug={slug} data={projectData}/> : <Centered text={error || '正在加载项目…'}/>;
|
||
}
|
||
|
||
function AccessGate({ access, name, password, error, busy, setName, setPassword, submit }: { access: CustomerAccessState; name: string; password: string; error: string; busy: boolean; setName: (value: string) => void; setPassword: (value: string) => void; submit: (event: React.FormEvent) => void }) {
|
||
return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[32px] border border-black/10 bg-white p-7 shadow-2xl shadow-black/5 sm:p-10"><div className="grid h-12 w-12 place-items-center rounded-full bg-[#171714] text-[#f3bd69]"><KeyRound size={18}/></div><p className="mt-8 font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Private review</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]">{access.project_name}</h1><p className="mt-4 text-sm leading-7 text-black/45">{access.client_description || '请输入姓名与项目密码,进入作品验收。'}</p><label className="mt-8 block text-xs text-black/50">你的姓名<input autoFocus value={name} onChange={(event) => setName(event.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">项目访问密码<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm"/></label>{error && <p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy || name.trim().length < 2 || !password} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-4 text-sm text-white disabled:opacity-30">{busy ? '正在验证…' : <>进入验收空间<ArrowRight size={15}/></>}</button></form></main>;
|
||
}
|
||
|
||
function ProjectReview({ slug, data }: { slug: string; data: ProjectPayload }) {
|
||
const counts = { pending: data.works.filter((item) => item.review_status === 'pending').length, changes: data.works.filter((item) => item.review_status === 'changes_requested').length, approved: data.works.filter((item) => item.review_status === 'approved').length };
|
||
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="flex flex-col gap-6 border-b border-black/10 pb-8 md:flex-row md:items-end md:justify-between"><div><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><span className="mt-4 inline-block rounded-full bg-black px-3 py-1.5 text-[10px] text-white">{data.project.review_status === 'completed' ? '验收完毕' : data.project.review_status === 'reviewing' ? '验收中' : '待提交'}</span></div><div className="flex gap-7 text-right"><Metric n={counts.pending} label="待验收"/><Metric n={counts.changes} label="需修改"/><Metric n={counts.approved} label="已通过"/></div></div>{data.project.review_status === 'completed' && <div className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800">本项目已全部通过验收,当前内容为只读状态。</div>}<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{data.works.map((item) => <Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group"><div className="relative aspect-[4/5] overflow-hidden rounded-[22px] bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/><div className="absolute left-2 top-2"><StatusBadge status={item.review_status}/></div></div><h2 className="mt-3 font-display text-[22px] leading-6">{item.title}</h2><p className="mt-1 text-[11px] text-black/35">第 {item.version_number} 轮</p></Link>)}</div></section></main>;
|
||
}
|
||
|
||
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
|
||
const [drawerOpen, setDrawerOpen] = useState(false); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState('');
|
||
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
|
||
const [focusImageId, setFocusImageId] = useState<number>();
|
||
const [textDraft, setTextDraft] = useState<(DrawerTextDraft & { target: 'title' | 'description' | 'tags' }) | null>(null);
|
||
const [focusFeedback, setFocusFeedback] = useState<{ type: 'text_annotation' | 'image_annotation'; id: number } | null>(null);
|
||
const viewed = work.rounds.find((round) => round.version_number === work.version_number);
|
||
const current = Boolean(viewed && Number(viewed.review_round_id) === Number(work.active_round_id));
|
||
const readOnly = work.project.status !== 'active' || work.project.review_status === 'completed' || !current || viewed?.round_status !== 'reviewing';
|
||
const tagsText = work.tags.join(' ');
|
||
const selectText = (target: 'title' | 'description' | 'tags', label: string, selection: TextSelectionDraft) => { setTextDraft({ target, label, ...selection }); setFocusImageId(undefined); setFocusFeedback(null); setDrawerOpen(true); };
|
||
const openTextAnnotation = (annotationId: number) => { setTextDraft(null); setFocusImageId(undefined); setFocusFeedback({ type: 'text_annotation', id: annotationId }); setDrawerOpen(true); };
|
||
const openImage = (imageId: number) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setTextDraft(null); setFocusFeedback(null); };
|
||
const openImageAnnotation = (imageId: number, annotationId: number) => { setFocusImageId(imageId); setTextDraft(null); setFocusFeedback({ type: 'image_annotation', id: annotationId }); setDrawerOpen(true); };
|
||
const openFeedback = work.images.flatMap((image) => image.annotations).filter((item) => item.status === 'open').length + work.text_annotations.filter((item) => item.status === 'open').length + work.comments.filter((item) => item.status === 'open').length;
|
||
const decide = async (decision: 'approved' | 'changes_requested') => { if (!viewed || decision === 'changes_requested' && !reason.trim()) return; if (decision === 'approved' && !window.confirm(openFeedback ? `当前还有 ${openFeedback} 条未处理反馈。确认通过并将其标记为“随本轮通过关闭”吗?` : '确认通过当前轮次吗?')) return; setBusy(true); setActionError(''); try { await api.submitCustomerRoundDecision(slug, work.id, viewed.round_number, decision, reason.trim()); setReason(''); await reload(); } catch (error) { setActionError(error instanceof Error ? error.message : '验收提交失败'); } finally { setBusy(false); } };
|
||
const footer = !readOnly ? <div className="mt-3"><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 bg-white p-3 text-xs" placeholder="要求修改时,请填写原因"/><div className="mt-2 grid grid-cols-2 gap-2"><button disabled={busy || !reason.trim()} onClick={() => void decide('changes_requested')} className="rounded-full border border-[#ba623c]/30 bg-white py-3 text-xs text-[#a94e2c] disabled:opacity-30">要求修改</button><button disabled={busy} onClick={() => void decide('approved')} className="flex items-center justify-center gap-2 rounded-full bg-emerald-600 py-3 text-xs text-white"><Check size={14}/>通过本轮</button></div>{actionError && <p className="mt-3 rounded-xl bg-red-50 p-3 text-xs text-red-700">{actionError}</p>}</div> : null;
|
||
|
||
return <main onClick={() => { if (drawerOpen) setDrawerOpen(false); }} className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · 第 ${viewed?.round_number ?? '-'} 轮`}/><article className="mx-auto max-w-[1280px] px-5 py-9 lg:px-10 lg:py-12"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/>返回项目</Link><div className="flex flex-wrap gap-2">{work.rounds.map((round) => <Link key={round.round_number} to={`/review/${slug}/works/${work.id}?round=${round.round_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${round.round_number === viewed?.round_number ? 'bg-black text-white' : 'bg-black/5'}`}>第 {round.round_number} 轮</Link>)}</div></div>{!current && <div className="mt-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55">这是历史验收轮次,仅供查看。</div>}<p className="mb-5 mt-8 text-xs text-black/45">{work.project.name}<span className="mx-2 text-black/20">/</span>Work {String(work.id).padStart(3, '0')}<span className="mx-2 text-black/20">/</span>第 {viewed?.round_number} 轮</p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly onAdd={async () => undefined} onOpen={() => openImage(image.id)} onAnnotationOpen={(annotationId) => openImageAnnotation(image.id, annotationId)}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText text={work.title} label="标题" variant="title" annotations={work.text_annotations.filter((item) => item.target === 'title')} readOnly={readOnly} onSelect={(selection) => selectText('title', '标题', selection)} onOpenAnnotation={openTextAnnotation}/>{work.description && <div className="mt-9"><AnnotatableText text={work.description} label="正文" annotations={work.text_annotations.filter((item) => item.target === 'description')} readOnly={readOnly} onSelect={(selection) => selectText('description', '正文', selection)} onOpenAnnotation={openTextAnnotation}/></div>}{tagsText && <div className="mt-8"><AnnotatableText text={tagsText} label="Tag" annotations={work.text_annotations.filter((item) => item.target === 'tags')} readOnly={readOnly} onSelect={(selection) => selectText('tags', 'Tag', selection)} onOpenAnnotation={openTextAnnotation}/></div>}</div></article><CollaborationDrawer work={work} open={drawerOpen} setOpen={setDrawerOpen} readOnly={readOnly} actorName={reviewer} actorRole="client" focusImageId={focusImageId} textDraft={textDraft} focusFeedback={focusFeedback} onCancelTextAnnotation={() => setTextDraft(null)} onTextAnnotation={async (content) => { if (!textDraft || !viewed) return; await api.addCustomerTextSelectionAnnotation(slug, work.id, { round_number: viewed.round_number, target: textDraft.target, start_offset: textDraft.start, end_offset: textDraft.end, selected_text: textDraft.text, content }); setTextDraft(null); await reload(); }} onComment={async (content) => { await api.addCustomerComment(slug, work.id, content); await reload(); }} onReply={async (type, feedbackId, content) => { await api.replyToCustomerFeedback(slug, work.id, type, feedbackId, content); await reload(); }} onWithdraw={async (type, feedbackId) => { await api.withdrawCustomerFeedback(slug, work.id, type, feedbackId); await reload(); }} footer={footer}/>{selectedImage && <ImageReviewModal work={work} initialImageId={selectedImage.imageId} readOnly={readOnly} slug={slug} onClose={() => { setSelectedImage(null); setDrawerOpen(false); }} onReload={reload} onImageChange={(imageId) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setFocusFeedback(null); }} onOpenAnnotation={openImageAnnotation}/>}</main>;
|
||
}
|
||
|
||
function ReviewHeader({ title, meta }: { title: string; meta: string }) { return <header className="border-b border-white/10 bg-[#171714] text-white"><div className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-5 lg:px-10"><div><p className="font-mono text-[9px] uppercase tracking-[.25em] text-[#f3bd69]">Delivery Desk</p><h1 className="mt-1 font-display text-2xl">{title}</h1></div><span className="rounded-full border border-white/15 px-3 py-1.5 text-[10px] text-white/55">{meta}</span></div></header>; }
|
||
function Metric({ n, label }: { n: number; label: string }) { return <div><b className="font-display text-3xl">{n}</b><span className="mt-1 block text-[10px] text-black/35">{label}</span></div>; }
|
||
function Centered({ text }: { text: string }) { return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 text-center text-sm text-black/45">{text}</main>; }
|