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

53 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
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 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 [works, setWorks] = useState<Note[]>([]);
const [q, setQ] = useState('');
const [status, setStatus] = useState<ReviewStatus | ''>('');
const [editOpen, setEditOpen] = useState(false);
const [accessOpen, setAccessOpen] = useState(false);
const [projectName, setProjectName] = useState('');
const [projectDesc, setProjectDesc] = useState('');
const [accessEnabled, setAccessEnabled] = useState(false);
const [accessPassword, setAccessPassword] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
setProject(nextProject); setWorks(nextWorks);
}, [id]);
useEffect(() => { void load(); }, [load]);
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 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-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 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>; }