2026-07-21 15:28:55 +08:00
|
|
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
|
|
import { Link, useParams, useSearchParams } from 'react-router-dom';
|
|
|
|
|
|
import { ArrowLeft, ArrowRight, Check, KeyRound, MessageSquareText, Send } from 'lucide-react';
|
|
|
|
|
|
import type { CustomerAccessState, Note, NoteDetail, Project, WorkCollection } from '@shared/types';
|
|
|
|
|
|
import { api, ApiError } from '@/api/client';
|
|
|
|
|
|
import AnnotatableImage from '@/components/AnnotatableImage';
|
|
|
|
|
|
import AnnotatableText from '@/components/AnnotatableText';
|
|
|
|
|
|
import StatusBadge from '@/components/StatusBadge';
|
2026-07-21 19:23:58 +08:00
|
|
|
|
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
|
2026-07-21 20:25:52 +08:00
|
|
|
|
import CandidateStatusBadge from '@/components/CandidateStatusBadge';
|
2026-07-21 15:28:55 +08:00
|
|
|
|
|
|
|
|
|
|
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string };
|
|
|
|
|
|
|
|
|
|
|
|
export default function CustomerReviewPage() {
|
|
|
|
|
|
const { slug = '', collectionId, noteId } = useParams();
|
|
|
|
|
|
const [search] = useSearchParams();
|
|
|
|
|
|
const selectedVersion = search.get('version');
|
|
|
|
|
|
const [access, setAccess] = useState<CustomerAccessState | null>(null);
|
|
|
|
|
|
const [projectData, setProjectData] = useState<ProjectPayload | null>(null);
|
|
|
|
|
|
const [collectionData, setCollectionData] = useState<{ collection: WorkCollection; works: Note[] } | 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) {
|
|
|
|
|
|
const result = await api.getCustomerWork(slug, Number(noteId), selectedVersion ? Number(selectedVersion) : undefined);
|
|
|
|
|
|
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
|
|
|
|
|
|
}
|
|
|
|
|
|
else if (collectionId) setCollectionData(await api.getCustomerCollection(slug, Number(collectionId)));
|
|
|
|
|
|
else setProjectData(await api.getCustomerProject(slug));
|
|
|
|
|
|
} catch (reason) {
|
|
|
|
|
|
setError(reason instanceof ApiError ? reason.message : '页面加载失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [slug, collectionId, noteId, selectedVersion]);
|
|
|
|
|
|
|
|
|
|
|
|
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 (!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 || '正在加载作品…'}/>;
|
|
|
|
|
|
if (collectionId) return collectionData ? <CollectionReview slug={slug} data={collectionData}/> : <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: (v: string) => void; setPassword: (v: string) => void; submit: (e: 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={(e)=>setName(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label><label className="mt-5 block text-xs text-black/50">项目访问密码<input type="password" value={password} onChange={(e)=>setPassword(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></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><p className="mt-5 text-center text-[11px] text-black/30">身份将在此设备保留 7 天</p></form></main>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ProjectReview({ slug, data }: { slug: string; data: ProjectPayload }) {
|
2026-07-21 19:23:58 +08:00
|
|
|
|
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-6xl px-5 py-12 lg:px-10"><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><div className="mt-10 space-y-3">{data.collections.map((item, index)=><Link key={item.id} to={`/review/${slug}/collections/${item.id}`} className="group grid gap-4 rounded-2xl border border-black/10 bg-white p-5 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#eeeae1] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><div className="flex flex-wrap items-center gap-3"><h2 className="font-display text-2xl">{item.name}</h2><CollectionStatusBadge status={item.status}/></div><p className="mt-1 text-sm text-black/40">{item.client_description || '作品交付集验收'}</p></div><div className="flex items-center gap-5 text-xs text-black/45"><span><b className="text-lg text-black">{item.approved_count}/{item.work_count}</b> 已通过</span><ArrowRight className="transition group-hover:translate-x-1"/></div></Link>)}</div></section></main>;
|
2026-07-21 15:28:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CollectionReview({ slug, data }: { slug: string; data: { collection: WorkCollection; works: Note[] } }) {
|
2026-07-21 19:23:58 +08:00
|
|
|
|
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.collection.name} meta={`${data.works.length} 件作品`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><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><CollectionStatusBadge status={data.collection.status}/></div>{data.collection.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 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{data.works.map((item)=><Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group overflow-hidden rounded-[24px] border border-black/10 bg-white"><div className="aspect-[4/5] overflow-hidden bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.025]"/></div><div className="p-5"><div className="flex items-center justify-between gap-3"><h2 className="font-display text-2xl leading-tight">{item.title}</h2><StatusBadge status={item.review_status}/></div></div></Link>)}</div></section></main>;
|
2026-07-21 15:28:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
|
2026-07-21 20:25:52 +08:00
|
|
|
|
const [comment,setComment]=useState(''); const [reason,setReason]=useState(''); const [busy,setBusy]=useState(false); const [actionError,setActionError]=useState('');
|
|
|
|
|
|
const viewed=work.versions.find((version)=>version.version_number===work.version_number);
|
|
|
|
|
|
const activeCandidate=Boolean(viewed&&Number(viewed.review_round_id)===Number(work.active_round_id)&&viewed.round_status==='reviewing');
|
|
|
|
|
|
const readOnly=work.collection.status==='completed'||!activeCandidate;
|
|
|
|
|
|
const canDecide=Boolean(viewed&&activeCandidate&&['pending','changes_requested'].includes(viewed.candidate_status));
|
|
|
|
|
|
const send=async()=>{if(!comment.trim())return;setBusy(true);setActionError('');try{await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload()}catch(reason){setActionError(reason instanceof Error?reason.message:'反馈提交失败')}finally{setBusy(false)}};
|
|
|
|
|
|
const decide=async(decision:'approved'|'changes_requested')=>{if(!viewed||decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm(`确认选择并通过“${viewed.candidate_name}”吗?同轮其他候选稿将标记为未选用。`))return;setBusy(true);setActionError('');try{await api.submitCustomerDecision(slug,work.id,viewed.version_number,decision,reason.trim());setReason('');await reload()}catch(error){setActionError(error instanceof Error?error.message:'验收提交失败')}finally{setBusy(false)}};
|
|
|
|
|
|
|
|
|
|
|
|
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · 第 ${viewed?.round_number??'-'} 轮`}/><div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]"><article className="min-w-0 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}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/>{work.collection.name}</Link><CollectionStatusBadge status={work.collection.status}/></div>
|
|
|
|
|
|
<div className="mt-7 rounded-[24px] border border-black/10 bg-white p-4"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-[#ba623c]">Review candidates</p><h2 className="mt-1 font-display text-2xl">选择候选稿</h2></div><span className="text-[10px] text-black/35">当前第 {work.versions[0]?.round_number??1} 轮</span></div><div className="mt-4 flex flex-wrap gap-2">{work.versions.map((item)=><Link key={item.version_number} to={`/review/${slug}/works/${work.id}?version=${item.version_number}`} className={`flex items-center gap-2 rounded-full border px-3 py-2 text-[11px] transition ${item.version_number===work.version_number?'border-black bg-black text-white':'border-black/10 bg-[#f7f5ef] text-black/55'}`}><span>第 {item.round_number} 轮 · {item.candidate_name}</span><CandidateStatusBadge status={item.candidate_status}/></Link>)}</div></div>
|
|
|
|
|
|
{readOnly&&<div className="mt-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55">{work.collection.status==='completed'?'本轮已完成,所有候选稿与历史批注均为只读。':'这是历史验收轮次,仅供查看。切换到当前轮次可继续批注和验收。'}</div>}
|
|
|
|
|
|
<p className="mb-5 mt-8 text-xs text-black/45">{work.project.name} <span className="mx-2 text-black/20">/</span> {work.collection.name} <span className="mx-2 text-black/20">/</span> <b className="font-mono text-[#ba623c]">{viewed?.candidate_name??`V${work.version_number}`}</b></p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((img)=><AnnotatableImage key={img.id} image={img} annotations={img.annotations} readOnly={readOnly} onAdd={async(x,y,content)=>{await api.addCustomerAnnotation(slug,img.id,{x,y,content});await reload()}}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation)=>annotation.target==='title')} readOnly={readOnly} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'title',content});await reload()}}><h1 className="font-display text-4xl leading-[1.08] tracking-[-.04em] md:text-5xl">{work.title}</h1></AnnotatableText>{work.description&&<div className="mt-7"><AnnotatableText label="正文" annotations={work.text_annotations.filter((annotation)=>annotation.target==='description')} readOnly={readOnly} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'description',content});await reload()}}><p className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.description}</p></AnnotatableText></div>}{work.tags.length>0&&<p className="mt-7 max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.tags.join(' ')}</p>}</div></article>
|
|
|
|
|
|
<aside className="border-t border-black/10 bg-[#efebe3] lg:sticky lg:top-0 lg:h-screen lg:border-l lg:border-t-0"><div className="flex h-full flex-col"><div className="border-b border-black/10 p-5"><p className="font-mono text-[10px] uppercase tracking-[.25em] text-black/35">Review</p><div className="mt-2 flex items-center justify-between gap-3"><h2 className="font-display text-3xl">验收反馈</h2>{viewed&&<CandidateStatusBadge status={viewed.candidate_status}/>}</div></div><div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length===0&&<div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/>还没有总体反馈</div>}{work.comments.map((item)=><div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role==='operator'?'ml-5 bg-black text-white':'mr-5 bg-white'}`}><div className="mb-2 text-[10px] opacity-45">{item.author_name}</div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}</div>{readOnly?<div className="border-t border-black/10 p-5 text-center text-xs leading-5 text-black/40">该候选稿当前为只读状态</div>:<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(event)=>setComment(event.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="针对整个作品留下意见…"/><button disabled={busy||!comment.trim()} onClick={()=>void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{canDecide&&<><textarea rows={2} value={reason} onChange={(event)=>setReason(event.target.value)} className="mt-3 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>}</div></aside></div></main>;
|
2026-07-21 15:28:55 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 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> }
|