feat(review): 重构项目级单方案验收协作
This commit is contained in:
@@ -1,19 +1,20 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
|
||||
import type { Project, WorkCollection } from '@shared/types';
|
||||
import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
|
||||
import type { Note, Project, ReviewStatus } from '@shared/types';
|
||||
import { api } from '@/api/client';
|
||||
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
|
||||
import StatusBadge from '@/components/StatusBadge';
|
||||
|
||||
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
|
||||
|
||||
export default function ProjectPage() {
|
||||
const id = Number(useParams().projectId);
|
||||
const [project, setProject] = useState<Project | null>(null);
|
||||
const [collections, setCollections] = useState<WorkCollection[]>([]);
|
||||
const [collectionOpen, setCollectionOpen] = useState(false);
|
||||
const [works, setWorks] = useState<Note[]>([]);
|
||||
const [q, setQ] = useState('');
|
||||
const [status, setStatus] = useState<ReviewStatus | ''>('');
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [accessOpen, setAccessOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectDesc, setProjectDesc] = useState('');
|
||||
const [accessEnabled, setAccessEnabled] = useState(false);
|
||||
@@ -22,44 +23,30 @@ export default function ProjectPage() {
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [nextProject, nextCollections] = await Promise.all([api.getProject(id), api.listCollections(id)]);
|
||||
setProject(nextProject);
|
||||
setCollections(nextCollections);
|
||||
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
|
||||
setProject(nextProject); setWorks(nextWorks);
|
||||
}, [id]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
|
||||
if (!project) return null;
|
||||
const filtered = useMemo(() => works.filter((work) => (!q || work.title.toLowerCase().includes(q.toLowerCase())) && (!status || work.review_status === status)), [works, q, status]);
|
||||
if (!project) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35">正在打开项目…</main>;
|
||||
const reviewUrl = `${window.location.origin}/review/${project.slug}`;
|
||||
|
||||
const createCollection = async () => {
|
||||
await api.createCollection(id, { name, client_description: desc });
|
||||
setCollectionOpen(false); setName(''); setDesc(''); await load();
|
||||
};
|
||||
const editProject = async () => {
|
||||
setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc }));
|
||||
setEditOpen(false);
|
||||
};
|
||||
const openAccess = () => {
|
||||
setAccessEnabled(Boolean(project.customer_access_enabled));
|
||||
setAccessPassword('');
|
||||
setExpiresAt(project.access_expires_at?.slice(0, 16) || '');
|
||||
setMessage(''); setAccessOpen(true);
|
||||
};
|
||||
const saveAccess = async () => {
|
||||
try {
|
||||
const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null });
|
||||
setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword('');
|
||||
} catch (reason) { setMessage(reason instanceof Error ? reason.message : '保存失败'); }
|
||||
};
|
||||
const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setAccessOpen(true); };
|
||||
|
||||
return <main>
|
||||
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-20"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/>返回项目</Link><div className="mt-10 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Client Project / {project.slug}</p><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="flex gap-8"><Metric n={project.collection_count} label="作品交付集"/><Metric n={project.work_count} label="作品"/></div><div className="mt-6 flex flex-wrap gap-2"><button onClick={()=>{setProjectName(project.name);setProjectDesc(project.client_description);setEditOpen(true)}} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><Pencil size={13}/>编辑项目信息</button><button onClick={openAccess} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><KeyRound size={13}/>客户访问</button></div></div></div></div></section>
|
||||
<section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="mb-6 flex items-center justify-between"><div><p className="font-mono text-[10px] tracking-[.25em] text-black/35">COLLECTIONS</p><h2 className="mt-1 font-display text-3xl">作品交付集</h2></div><button onClick={()=>setCollectionOpen(true)} className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Plus size={16}/>新建作品交付集</button></div><div className="space-y-3">{collections.map((item,index)=><Link key={item.id} to={`/projects/${id}/collections/${item.id}`} className="group grid gap-5 rounded-2xl border border-black/10 bg-white p-5 transition hover:border-black/30 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#f1efe9] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><div className="flex flex-wrap items-center gap-3"><h3 className="font-display text-2xl">{item.name}</h3><CollectionStatusBadge status={item.status}/></div><p className="mt-1 text-sm text-black/45">{item.client_description || '暂无说明'}</p></div><div className="flex items-center gap-6"><div className="text-right text-xs text-black/45"><b className="block text-lg text-black">{item.approved_count}/{item.work_count}</b>{item.work_count ? '已通过' : '尚无待验收作品'}</div><ArrowRight className="text-black/25 transition group-hover:translate-x-1 group-hover:text-black"/></div></Link>)}</div></section>
|
||||
{collectionOpen&&<Modal close={()=>setCollectionOpen(false)} icon={<CalendarRange/>} title="新建作品交付集"><input className="mt-7 w-full rounded-xl border border-black/10 p-3" placeholder="例如:2026 年 8 月任务" value={name} onChange={(e)=>setName(e.target.value)}/><textarea className="mt-3 w-full rounded-xl border border-black/10 p-3" rows={3} placeholder="客户可见的作品交付集说明" value={desc} onChange={(e)=>setDesc(e.target.value)}/><button disabled={!name} onClick={()=>void createCollection()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30">创建作品交付集</button></Modal>}
|
||||
{editOpen&&<Modal close={()=>setEditOpen(false)} icon={<Pencil/>} title="编辑项目"><label className="mt-7 block text-xs text-black/45">项目名称<input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(e)=>setProjectName(e.target.value)}/></label><label className="mt-4 block text-xs text-black/45">客户页简介<textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(e)=>setProjectDesc(e.target.value)}/></label><p className="mt-3 text-[11px] text-black/35">项目标识 {project.slug} 保持不变,现有链接不会失效。</p><button disabled={!projectName.trim()} onClick={()=>void editProject()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30">保存修改</button></Modal>}
|
||||
{accessOpen&&<Modal close={()=>setAccessOpen(false)} icon={<KeyRound/>} title="客户访问"><div className="mt-7 rounded-2xl border border-black/10 bg-white p-4"><p className="break-all text-xs leading-5 text-black/50">{reviewUrl}</p><button onClick={()=>void navigator.clipboard.writeText(reviewUrl).then(()=>setMessage('链接已复制'))} className="mt-3 inline-flex items-center gap-2 text-xs text-[#aa4f2e]"><Copy size={13}/>复制验收链接</button></div><label className="mt-5 flex items-center justify-between rounded-xl border border-black/10 bg-white p-4 text-sm">开放客户访问<input type="checkbox" checked={accessEnabled} onChange={(e)=>setAccessEnabled(e.target.checked)} className="h-4 w-4 accent-black"/></label><label className="mt-4 block text-xs text-black/45">{project.has_access_password?'重置访问密码(不修改可留空)':'设置访问密码'}<input type="password" value={accessPassword} onChange={(e)=>setAccessPassword(e.target.value)} placeholder="至少 6 位" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label><label className="mt-4 block text-xs text-black/45">到期时间(可选)<input type="datetime-local" value={expiresAt} onChange={(e)=>setExpiresAt(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label>{message&&<p className="mt-3 text-xs text-black/50">{message}</p>}<button onClick={()=>void saveAccess()} className="mt-5 w-full rounded-full bg-black py-3 text-white">保存访问设置</button></Modal>}
|
||||
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-16"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/>返回项目</Link><div className="mt-9 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Project / {project.slug}</p><span className="rounded-full border border-white/15 px-3 py-1 text-[10px] text-white/60">{projectStatus[project.review_status]}</span></div><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="grid grid-cols-4 gap-5"><Metric n={project.work_count} label="全部"/><Metric n={project.pending_count} label="待验收"/><Metric n={project.changes_requested_count} label="需修改"/><Metric n={project.approved_count} label="已通过"/></div><div className="mt-6 flex flex-wrap justify-end gap-2"><button onClick={() => { setProjectName(project.name); setProjectDesc(project.client_description); setEditOpen(true); }} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><Pencil className="mr-2 inline" size={13}/>编辑项目</button><button onClick={openAccess} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><KeyRound className="mr-2 inline" size={13}/>客户访问</button></div></div></div></div></section>
|
||||
|
||||
<section className="mx-auto max-w-[1500px] px-5 pb-16 lg:px-10"><div className="sticky top-[72px] z-30 -mx-5 flex flex-col gap-3 border-b border-black/10 bg-[#f7f6f2]/92 px-5 py-5 backdrop-blur-xl md:flex-row md:items-center md:justify-between lg:-mx-10 lg:px-10"><div className="flex gap-2 overflow-auto">{([['','全部作品'],['pending','待验收'],['changes_requested','需修改'],['approved','已通过']] as [ReviewStatus|'',string][]).map(([value,label]) => <button key={label} onClick={() => setStatus(value)} className={`whitespace-nowrap rounded-full px-4 py-2 text-xs ${status === value ? 'bg-black text-white' : 'border border-black/10 bg-white text-black/55'}`}>{label}</button>)}</div><div className="flex gap-2"><label className="relative"><Search className="absolute left-3 top-2.5 text-black/35" size={15}/><input value={q} onChange={(event) => setQ(event.target.value)} className="w-full rounded-full border border-black/10 bg-white py-2 pl-9 pr-4 text-sm md:w-64" placeholder="搜索作品标题"/></label>{project.status === 'active' && <Link to={`/projects/${id}/upload`} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full bg-[#ef4b2f] px-5 py-2 text-xs text-white"><Plus size={14}/>上传作品</Link>}</div></div>
|
||||
{project.review_status === 'completed' && <div className="mt-7 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-9 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{filtered.map((work) => <Link to={`/works/${work.id}`} key={work.id} className="group"><article><div className="relative aspect-[4/5] overflow-hidden rounded-[22px] bg-[#ebe9e3]">{work.cover_image ? <img src={work.cover_image} alt={work.title} className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/> : <div className="grid h-full place-items-center text-black/20"><ImageIcon/></div>}<div className="absolute left-2 top-2"><StatusBadge status={work.review_status}/></div>{work.image_count > 1 && <span className="absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] text-white">{work.image_count} 图</span>}</div><h2 className="mt-3 line-clamp-2 font-display text-[22px] leading-6">{work.title}</h2><div className="mt-2 flex items-center gap-3 text-[11px] text-black/40"><span className="flex items-center gap-1"><MessageCircle size={12}/>{work.annotation_count + work.comment_count}</span><span>第 {work.version_number} 轮</span></div></article></Link>)}</div>
|
||||
{!filtered.length && <div className="py-24 text-center text-sm text-black/35">{works.length ? '没有符合筛选条件的作品' : '项目还没有作品,上传第一件作品开始验收。'}</div>}
|
||||
</section>
|
||||
|
||||
{editOpen && <Modal title="编辑项目" close={() => setEditOpen(false)}><label className="block text-xs text-black/45">项目名称<input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(event) => setProjectName(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45">客户简介<textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(event) => setProjectDesc(event.target.value)}/></label><button onClick={async () => { setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc })); setEditOpen(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white">保存修改</button></Modal>}
|
||||
{accessOpen && <Modal title="客户访问" close={() => setAccessOpen(false)}><div className="rounded-xl bg-black/[.04] p-3 text-xs break-all">{reviewUrl}</div><button onClick={() => void navigator.clipboard.writeText(reviewUrl)} className="mt-2 inline-flex items-center gap-2 text-xs text-black/50"><Copy size={12}/>复制链接</button><label className="mt-5 flex items-center gap-3 text-sm"><input type="checkbox" checked={accessEnabled} onChange={(event) => setAccessEnabled(event.target.checked)}/>启用客户访问</label><label className="mt-4 block text-xs text-black/45">新访问密码(留空表示不修改)<input type="password" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={accessPassword} onChange={(event) => setAccessPassword(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45">到期时间<input type="datetime-local" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)}/></label><button onClick={async () => { try { const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null }); setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword(''); } catch (error) { setMessage(error instanceof Error ? error.message : '保存失败'); } }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white">保存设置</button>{message && <p className="mt-3 text-center text-xs text-black/50">{message}</p>}</Modal>}
|
||||
</main>;
|
||||
}
|
||||
|
||||
function Metric({n,label}:{n:number;label:string}) { return <div><strong className="font-display text-4xl">{String(n).padStart(2,'0')}</strong><span className="ml-2 text-xs text-white/40">{label}</span></div> }
|
||||
function Modal({close,icon,title,children}:{close:()=>void;icon:React.ReactNode;title:string;children:React.ReactNode}) { return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4"><div className="w-full max-w-md rounded-3xl bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X/></button></div>{children}</div></div> }
|
||||
function Metric({ n, label }: { n: number; label: string }) { return <div className="text-right"><b className="font-display text-3xl">{n}</b><span className="mt-1 block text-[10px] text-white/40">{label}</span></div>; }
|
||||
function Modal({ title, close, children }: { title: string; close: () => void; children: React.ReactNode }) { return <div className="fixed inset-0 z-[90] grid place-items-center bg-black/50 p-4"><div className="w-full max-w-md rounded-[28px] bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><h3 className="font-display text-3xl">{title}</h3><button onClick={close}><X/></button></div><div className="mt-6">{children}</div></div></div>; }
|
||||
|
||||
Reference in New Issue
Block a user