feat(review): 支持多候选稿验收轮次
- 支持每轮提交 1–5 个候选稿并按指定稿验收 - 保留历史轮次只读并兼容单候选稿版本接口 - 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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">1–30 张,选择顺序即展示顺序</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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user