Files
delivery-desk/src/pages/CustomerReview.tsx

84 lines
15 KiB
TypeScript
Raw Normal View History

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';
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
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 }) {
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>;
}
function CollectionReview({ slug, data }: { slug: string; data: { collection: WorkCollection; works: Note[] } }) {
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>;
}
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
const [comment,setComment]=useState(''); const [reason,setReason]=useState(''); const [busy,setBusy]=useState(false);
const readOnly=work.collection.status==='completed';
const send=async()=>{if(!comment.trim())return;setBusy(true);await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload();setBusy(false)};
const decide=async(decision:'approved'|'changes_requested')=>{if(decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm('确认通过这个版本吗?通过后将记录你的验收决定。'))return;setBusy(true);await api.submitCustomerDecision(slug,work.id,decision,reason.trim());setReason('');await reload();setBusy(false)};
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · V${work.version_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><div className="flex items-center gap-2"><CollectionStatusBadge status={work.collection.status}/>{work.versions.map((item)=><Link key={item.version_number} to={`/review/${slug}/works/${work.id}?version=${item.version_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${item.version_number===work.version_number?'bg-black text-white':'bg-black/5'}`}>V{item.version_number}</Link>)}</div></div>{readOnly&&<div className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800"></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]">WORK {String(work.id).padStart(3,'0')}</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><h2 className="mt-2 font-display text-3xl"></h2></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"><br/></div>:<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e)=>setComment(e.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="针对整个作品留下意见…"
}
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> }