feat(review): 重构项目级单方案验收协作
This commit is contained in:
@@ -1,92 +1,68 @@
|
||||
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 { 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 from '@/components/AnnotatableText';
|
||||
import AnnotatableText, { type TextSelectionDraft } from '@/components/AnnotatableText';
|
||||
import CollaborationDrawer, { type DrawerTextDraft } from '@/components/CollaborationDrawer';
|
||||
import ImageReviewModal from '@/components/ImageReviewModal';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
|
||||
import CandidateStatusBadge from '@/components/CandidateStatusBadge';
|
||||
|
||||
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string };
|
||||
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 selectedVersion = search.get('version');
|
||||
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 [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]);
|
||||
|
||||
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); } };
|
||||
|
||||
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 || '正在加载作品…'}/>;
|
||||
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 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 }) {
|
||||
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>;
|
||||
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 [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)}};
|
||||
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 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>;
|
||||
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 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> }
|
||||
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>; }
|
||||
|
||||
Reference in New Issue
Block a user