feat(review): 支持多候选稿验收轮次

- 支持每轮提交 1–5 个候选稿并按指定稿验收
- 保留历史轮次只读并兼容单候选稿版本接口
- 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
This commit is contained in:
yuzhe
2026-07-21 20:25:52 +08:00
parent 721e971dd8
commit 6091d61612
26 changed files with 586 additions and 97 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,47 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowUp, ImagePlus, X } from 'lucide-react';
import { ArrowLeft, GripVertical, ImagePlus, Layers3, Plus, Trash2 } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
type Item = { file: File; url: string };
type ImageItem = { file: File; url: string };
type CandidateDraft = { key: string; candidate_name: string; title: string; description: string; tags: string; images: ImageItem[] };
const candidateName = (index: number) => `方案 ${String.fromCharCode(65 + index)}`;
export default function NewVersionPage() {
const id = Number(useParams().noteId); const navigate = useNavigate();
const [work,setWork]=useState<NoteDetail|null>(null); const [title,setTitle]=useState(''); const [description,setDescription]=useState(''); const [tags,setTags]=useState(''); const [items,setItems]=useState<Item[]>([]); const [busy,setBusy]=useState(false); const [error,setError]=useState('');
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);setTitle(item.title);setDescription(item.description);setTags(item.tags.join(', '))})},[id]);
useEffect(()=>()=>items.forEach((item)=>URL.revokeObjectURL(item.url)),[items]);
const add=(files:FileList|null)=>{if(!files)return;setItems((current)=>[...current,...Array.from(files).slice(0,30-current.length).map((file)=>({file,url:URL.createObjectURL(file)}))])};
const move=(index:number,direction:-1|1)=>setItems((current)=>{const target=index+direction;if(target<0||target>=current.length)return current;const copy=[...current];[copy[index],copy[target]]=[copy[target],copy[index]];return copy});
const submit=async()=>{if(!title.trim()||!items.length)return;setBusy(true);setError('');try{await api.createWorkVersion(id,{title:title.trim(),description,tags:tags?[tags]:[],images:items.map((item)=>item.file)});navigate(`/works/${id}`)}catch(reason){setError(reason instanceof Error?reason.message:'新版本上传失败');setBusy(false)}};
if(!work)return null;
return <main className="mx-auto max-w-6xl px-5 py-9 lg:px-10 lg:py-12"><Link to={`/works/${id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.75fr_1.25fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">New revision / V{work.version_number+1}</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><p className="mt-4 text-sm leading-6 text-black/45"></p><label className="mt-8 block text-xs text-black/50"><input value={title} onChange={(e)=>setTitle(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50"><textarea rows={7} value={description} onChange={(e)=>setDescription(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">Tag<input value={tags} onChange={(e)=>setTags(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-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||!title.trim()||!items.length} onClick={()=>void submit()} className="mt-7 w-full rounded-full bg-black py-4 text-sm text-white disabled:opacity-30">{busy?'正在上传…':`创建 V${work.version_number+1}`}</button></section><section><label className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif" multiple className="hidden" onChange={(e)=>add(e.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-3 block text-sm"></b><small className="mt-1 block text-black/35">130 </small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{items.map((item,index)=><div key={item.url} className="group relative overflow-hidden rounded-2xl border border-black/10 bg-white"><img src={item.url} alt="" className="aspect-[4/5] w-full object-cover"/><div className="absolute inset-x-2 bottom-2 flex justify-between"><button onClick={()=>move(index,-1)} className="rounded-full bg-white/90 p-2 disabled:opacity-30" disabled={index===0}><ArrowUp size={13}/></button><button onClick={()=>setItems((current)=>current.filter((_,i)=>i!==index))} className="rounded-full bg-white/90 p-2"><X size={13}/></button></div>{index===0&&<span className="absolute left-2 top-2 rounded-full bg-black px-2 py-1 text-[9px] text-white"></span>}</div>)}</div></section></div></main>;
const [work,setWork]=useState<NoteDetail|null>(null); const [candidates,setCandidates]=useState<CandidateDraft[]>([]);
const [activeKey,setActiveKey]=useState(''); const [busy,setBusy]=useState(false); const [error,setError]=useState('');
const [dragIndex,setDragIndex]=useState<number|null>(null);
const candidatesRef=useRef<CandidateDraft[]>([]);
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);const first={key:crypto.randomUUID(),candidate_name:'方案 A',title:item.title,description:item.description,tags:item.tags.join(', '),images:[]};setCandidates([first]);setActiveKey(first.key)}).catch((reason)=>setError(reason instanceof Error?reason.message:'作品加载失败'))},[id]);
useEffect(()=>{candidatesRef.current=candidates},[candidates]);
useEffect(()=>()=>{candidatesRef.current.forEach((candidate)=>candidate.images.forEach((image)=>URL.revokeObjectURL(image.url)))},[]);
const active=candidates.find((candidate)=>candidate.key===activeKey)??candidates[0];
const totalImages=useMemo(()=>candidates.reduce((sum,candidate)=>sum+candidate.images.length,0),[candidates]);
const update=(key:string,changes:Partial<CandidateDraft>)=>setCandidates((current)=>current.map((candidate)=>candidate.key===key?{...candidate,...changes}:candidate));
const addCandidate=()=>{if(candidates.length>=5)return;const next={key:crypto.randomUUID(),candidate_name:candidateName(candidates.length),title:work?.title??'',description:work?.description??'',tags:work?.tags.join(', ')??'',images:[]};setCandidates([...candidates,next]);setActiveKey(next.key)};
const removeCandidate=(key:string)=>{if(candidates.length===1)return;const removed=candidates.find((candidate)=>candidate.key===key);removed?.images.forEach((image)=>URL.revokeObjectURL(image.url));const next=candidates.filter((candidate)=>candidate.key!==key);setCandidates(next);if(activeKey===key)setActiveKey(next[0].key)};
const addImages=(files:FileList|null)=>{if(!files||!active)return;const allowance=Math.max(0,30-totalImages);const next=Array.from(files).slice(0,allowance).map((file)=>({file,url:URL.createObjectURL(file)}));update(active.key,{images:[...active.images,...next]})};
const removeImage=(index:number)=>{if(!active)return;URL.revokeObjectURL(active.images[index].url);update(active.key,{images:active.images.filter((_,itemIndex)=>itemIndex!==index)})};
const dropImage=(target:number)=>{if(!active||dragIndex===null||dragIndex===target){setDragIndex(null);return}const images=[...active.images];const[moved]=images.splice(dragIndex,1);images.splice(target,0,moved);update(active.key,{images});setDragIndex(null)};
const valid=candidates.every((candidate)=>candidate.candidate_name.trim()&&candidate.title.trim()&&candidate.images.length>0)&&totalImages<=30;
const submit=async()=>{if(!valid)return;setBusy(true);setError('');try{await api.createReviewRound(id,candidates.map((candidate)=>({candidate_name:candidate.candidate_name.trim(),title:candidate.title.trim(),description:candidate.description,tags:candidate.tags.trim()?[candidate.tags.trim()]:[],images:candidate.images.map((image)=>image.file)})));navigate(`/works/${id}`)}catch(reason){setError(reason instanceof Error?reason.message:'验收轮次提交失败');setBusy(false)}};
if(!work||!active)return <main className="grid min-h-[60vh] place-items-center px-5 text-sm text-black/45">{error||'正在加载作品…'}</main>;
return <main className="mx-auto max-w-[1400px] px-5 py-9 lg:px-10 lg:py-12">
<Link to={`/works/${id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link>
<header className="mt-8 flex flex-col gap-6 border-b border-black/10 pb-8 lg:flex-row lg:items-end lg:justify-between"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">New review round</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl"></h1><p className="mt-4 max-w-xl text-sm leading-7 text-black/45">稿稿</p></div><button disabled={busy||!valid} onClick={()=>void submit()} className="rounded-full bg-black px-7 py-3.5 text-sm text-white disabled:opacity-30">{busy?'正在提交…':`提交 ${candidates.length} 个候选稿`}</button></header>
<div className="mt-8 grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside><div className="rounded-[24px] border border-black/10 bg-white p-3"><div className="mb-3 flex items-center justify-between px-2"><span className="flex items-center gap-2 text-xs text-black/45"><Layers3 size={14}/></span><span className="font-mono text-[10px] text-black/30">{candidates.length}/5</span></div><div className="space-y-2">{candidates.map((candidate,index)=><button key={candidate.key} onClick={()=>setActiveKey(candidate.key)} className={`w-full rounded-2xl border p-4 text-left transition ${active.key===candidate.key?'border-black bg-[#171714] text-white':'border-transparent bg-[#f4f1ea] text-black'}`}><span className="text-[10px] opacity-45">{String(index+1).padStart(2,'0')}</span><b className="mt-1 block truncate text-sm">{candidate.candidate_name||'未命名方案'}</b><small className="mt-1 block opacity-45">{candidate.images.length} </small></button>)}</div><button disabled={candidates.length>=5} onClick={addCandidate} className="mt-3 flex w-full items-center justify-center gap-2 rounded-full border border-dashed border-black/15 py-3 text-xs text-black/50 disabled:opacity-30"><Plus size={13}/>稿</button></div><p className="mt-4 px-2 text-[11px] leading-5 text-black/35"> 5 稿 30 稿</p></aside>
<section className="rounded-[30px] border border-black/10 bg-white p-5 md:p-8"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.24em] text-[#ba623c]">Candidate {candidates.findIndex((candidate)=>candidate.key===active.key)+1}</p><h2 className="mt-2 font-display text-3xl">稿</h2></div>{candidates.length>1&&<button onClick={()=>removeCandidate(active.key)} className="inline-flex items-center gap-2 rounded-full border border-red-100 px-4 py-2 text-xs text-red-600"><Trash2 size={13}/>稿</button>}</div>
<div className="mt-7 grid gap-5 md:grid-cols-2"><label className="text-xs text-black/50">稿<input value={active.candidate_name} onChange={(event)=>update(active.key,{candidate_name:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label><label className="text-xs text-black/50"><input value={active.title} onChange={(event)=>update(active.key,{title:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label></div><label className="mt-5 block text-xs text-black/50"><textarea rows={5} value={active.description} onChange={(event)=>update(active.key,{description:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">Tag<input value={active.tags} onChange={(event)=>update(active.key,{tags:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label>
<label className="mt-7 grid min-h-36 cursor-pointer place-items-center rounded-[24px] border border-dashed border-black/20 bg-[#f8f6f1] text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/avif" multiple className="hidden" onChange={(event)=>addImages(event.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-2 block text-sm">稿</b><small className="mt-1 block text-black/35"></small></span></label>
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">{active.images.map((image,index)=><div key={image.url} data-image-index={index} draggable onDragStart={()=>setDragIndex(index)} onDragOver={(event)=>event.preventDefault()} onDrop={()=>dropImage(index)} onPointerDown={(event)=>{if(event.pointerType!=='mouse'){setDragIndex(index);event.currentTarget.setPointerCapture(event.pointerId)}}} onPointerUp={(event)=>{if(event.pointerType==='mouse')return;const target=document.elementFromPoint(event.clientX,event.clientY)?.closest<HTMLElement>('[data-image-index]');dropImage(Number(target?.dataset.imageIndex??index))}} onPointerCancel={()=>setDragIndex(null)} className="group"><div className="relative cursor-grab overflow-hidden rounded-2xl border border-black/10 bg-[#eeece6] active:cursor-grabbing"><img src={image.url} alt="" className="aspect-[4/5] w-full select-none object-cover" draggable={false}/><span className="absolute left-2 top-2 grid h-6 min-w-6 place-items-center rounded-full bg-black/70 px-1.5 font-mono text-[9px] text-white">{index+1}</span><GripVertical className="absolute bottom-2 right-2 text-white drop-shadow" size={16}/></div><button onClick={()=>removeImage(index)} className="mt-2 w-full text-center text-[10px] text-black/35 transition hover:text-red-600"></button></div>)}</div>
{error&&<p className="mt-5 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}
</section>
</div>
</main>;
}

View File

@@ -6,6 +6,7 @@ import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import StatusBadge from '@/components/StatusBadge';
import CandidateStatusBadge from '@/components/CandidateStatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function NoteDetailPage() {
@@ -26,9 +27,10 @@ export default function NoteDetailPage() {
useEffect(() => { void load(); }, [load]);
if (!work) return <div className="p-20 text-center text-black/35"></div>;
const latestVersion = Math.max(...work.versions.map((item) => item.version_number));
const viewingLatest = work.version_number === latestVersion;
const canReopen = viewingLatest && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
const viewingCurrent = version === undefined;
const viewed = work.versions.find((item) => item.version_number === work.version_number);
const annotationsReadOnly = work.collection.status === 'completed' || !viewed || viewed.round_status !== 'reviewing' || Number(viewed.review_round_id) !== Number(work.active_round_id);
const canReopen = viewingCurrent && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
const send = async () => {
if (!comment.trim()) return;
@@ -47,13 +49,13 @@ export default function NoteDetailPage() {
return <main>
<header className="border-b border-black/10 bg-white"><div className="mx-auto max-w-[1500px] px-5 py-5 lg:px-10"><div className="flex flex-wrap items-center justify-between gap-3">
<Link to={`/projects/${work.project.id}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{work.collection.name}</Link>
<div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.versions.map((item) => <Link key={item.version_number} to={`/works/${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>)}{viewingLatest && <Link to={`/works/${id}/new-version`} className="inline-flex items-center gap-2 rounded-full bg-black px-4 py-2 text-xs text-white"><UploadCloud size={13}/></Link>}</div>
<div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.versions.map((item) => <Link key={item.version_number} to={`/works/${id}?version=${item.version_number}`} className={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] ${item.version_number === work.version_number ? 'bg-black text-white' : 'bg-black/5'}`}><span> {item.round_number} · {item.candidate_name}</span><CandidateStatusBadge status={item.candidate_status}/></Link>)}{viewingCurrent && <Link to={`/works/${id}/new-version`} className="inline-flex items-center gap-2 rounded-full bg-black px-4 py-2 text-xs text-white"><UploadCloud size={13}/></Link>}</div>
</div></div></header>
<div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]">
<article className="min-w-0 px-5 py-10 lg:px-10 lg:py-14"><div className="mx-auto max-w-5xl">
<p className="mb-5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-medium text-black/50"><span>{work.project.name}</span><span className="text-black/20">/</span><span>{work.collection.name}</span><span className="text-black/20">/</span><span className="font-mono uppercase tracking-[.16em] text-[#ba623c]">Work {String(work.id).padStart(3,'0')}</span></p>
<div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} onAdd={async (x,y,text) => { await api.addAnnotation(image.id, { x,y,content:text }); await load(); }}/>)}</div>
<div className="mt-12 border-t border-black/10 pt-10"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation) => annotation.target === 'title')} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'title', content }); await load(); }}><h1 className="max-w-4xl 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')} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'description', content }); await load(); }}><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>
<div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly={annotationsReadOnly} onAdd={async (x,y,text) => { await api.addAnnotation(image.id, { x,y,content:text }); await load(); }}/>)}</div>
<div className="mt-12 border-t border-black/10 pt-10"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation) => annotation.target === 'title')} readOnly={annotationsReadOnly} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'title', content }); await load(); }}><h1 className="max-w-4xl 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={annotationsReadOnly} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'description', content }); await load(); }}><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>
</div></article>
<aside className="border-t border-black/10 bg-[#efede7] lg:sticky lg:top-[66px] lg:h-[calc(100vh-66px)] 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 conversation</p><h2 className="mt-2 font-display text-3xl"></h2><p className="mt-2 text-xs leading-5 text-black/45"></p></div>