feat(auth): 添加认证模块和图片批注功能(项目初始化)

- 实现用户登录、登出、密码修改等认证功能
- 添加会话管理和权限控制中间件
- 创建图片批注组件和相关API路由
- 实现批注的增删改查功能
- 添加Docker和Git忽略配置文件
- 创建系统架构文档和开发约定说明
- 集成认证模块到前端应用路由中
This commit is contained in:
yuzhe
2026-07-21 15:28:55 +08:00
commit b0c498fbb6
81 changed files with 10826 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { KeyRound } from 'lucide-react';
import { api } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
export default function ChangePasswordPage(){
const [current,setCurrent]=useState('');const [next,setNext]=useState('');const [confirm,setConfirm]=useState('');const [error,setError]=useState('');const [busy,setBusy]=useState(false);const initialize=useAuthStore(s=>s.initialize);const nav=useNavigate();
const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(next!==confirm){setError('两次输入的新密码不一致');return}setBusy(true);try{await api.changePassword(current,next);await initialize();nav('/')}catch(err){setError(err instanceof Error?err.message:'修改失败')}finally{setBusy(false)}};
return <main className="grid min-h-[calc(100vh-66px)] place-items-center px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[28px] border border-black/10 bg-white p-7 shadow-xl shadow-black/5"><div className="grid h-11 w-11 place-items-center rounded-full bg-[#ef4b2f] text-white"><KeyRound size={18}/></div><p className="mt-7 font-mono text-[10px] uppercase tracking-[.26em] text-[#ef4b2f]">First sign in</p><h1 className="mt-2 font-display text-4xl"></h1><p className="mt-3 text-sm leading-6 text-black/45"> 8 </p>{[['当前密码',current,setCurrent],['新密码',next,setNext],['确认新密码',confirm,setConfirm]].map(([label,value,setter])=><label key={label as string} className="mt-5 block text-xs text-black/50">{label as string}<input type="password" value={value as string} onChange={e=>(setter as React.Dispatch<React.SetStateAction<string>>)(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 p-3"/></label>)}{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-600">{error}</p>}<button disabled={busy||!current||!next||!confirm} className="mt-6 w-full rounded-full bg-black py-3.5 text-sm text-white disabled:opacity-30">{busy?'正在保存…':'保存并进入工作台'}</button></form></main>
}

21
src/pages/Collection.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, ImageIcon, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
import type { Note, Project, ReviewStatus, WorkCollection } from '@shared/types';
import { api } from '@/api/client';
import StatusBadge from '@/components/StatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function CollectionPage(){
const {projectId,collectionId}=useParams(); const pid=Number(projectId),cid=Number(collectionId); const [project,setProject]=useState<Project|null>(null); const [collection,setCollection]=useState<WorkCollection|null>(null); const [works,setWorks]=useState<Note[]>([]); const [q,setQ]=useState(''); const [status,setStatus]=useState<ReviewStatus|''>('');
const [editing,setEditing]=useState(false); const [editName,setEditName]=useState(''); const [editDesc,setEditDesc]=useState('');
const user=useAuthStore(state=>state.user);
useEffect(()=>{Promise.all([api.getProject(pid),api.listCollections(pid),api.listNotes({collectionId:cid})]).then(([p,cs,w])=>{setProject(p);setCollection(cs.find(x=>x.id===cid)||null);setWorks(w)})},[pid,cid]);
const filtered=useMemo(()=>works.filter(w=>(!q||w.title.toLowerCase().includes(q.toLowerCase()))&&(!status||w.review_status===status)),[works,q,status]); if(!project||!collection)return null;
return <main className="mx-auto max-w-[1500px] px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{project.name}</Link>
<section className="mt-8 flex flex-col gap-7 border-b border-black/10 pb-9 md:flex-row md:items-end md:justify-between"><div><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#ef4b2f]">Collection / Review Board</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl">{collection.name}</h1><p className="mt-3 text-sm text-black/50">{collection.client_description}</p></div>{user&&<div className="flex flex-col gap-2 sm:flex-row"><button onClick={()=>{setEditName(collection.name);setEditDesc(collection.client_description);setEditing(true)}} className="inline-flex items-center justify-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Pencil size={14}/></button><Link to={`/projects/${pid}/collections/${cid}/upload`} className="inline-flex items-center justify-center gap-2 rounded-full bg-black px-6 py-3 text-sm text-white"><Plus size={16}/> </Link></div>}</section>
<section className="sticky top-[66px] z-30 -mx-5 mt-0 flex flex-col gap-3 border-b border-black/10 bg-[#f7f6f2]/90 px-5 py-4 backdrop-blur 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(([v,l])=><button key={l} onClick={()=>setStatus(v)} className={`whitespace-nowrap rounded-full px-4 py-2 text-xs ${status===v?'bg-black text-white':'bg-white text-black/55'}`}>{l}</button>)}</div><label className="relative"><Search className="absolute left-3 top-2.5 text-black/35" size={15}/><input value={q} onChange={e=>setQ(e.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></section>
<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">{filtered.map((work,i)=><Link to={`/works/${work.id}`} key={work.id} className="group"><article><div className={`relative overflow-hidden rounded-2xl bg-[#ebe9e3] ${i%5===0?'aspect-[4/5]':'aspect-square'}`}>{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"><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><h3 className="mt-3 line-clamp-2 text-[15px] font-semibold leading-5">{work.title}</h3><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>{work.tags.slice(0,2).map(t=><span key={t}>#{t}</span>)}</div></article></Link>)}</div>
{editing&&<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><Pencil/><h3 className="mt-3 font-display text-3xl"></h3></div><button onClick={()=>setEditing(false)}><X/></button></div><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={editName} onChange={e=>setEditName(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={editDesc} onChange={e=>setEditDesc(e.target.value)}/></label><button disabled={!editName.trim()} onClick={async()=>{const updated=await api.updateCollection(pid,cid,{name:editName,client_description:editDesc});setCollection(updated);setEditing(false)}} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></div></div>}
</main>
}

View File

@@ -0,0 +1,81 @@
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 { api, ApiError } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import StatusBadge from '@/components/StatusBadge';
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string };
export default function CustomerReviewPage() {
const { slug = '', collectionId, noteId } = useParams();
const [search] = useSearchParams();
const selectedVersion = search.get('version');
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]);
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); }
};
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 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><h2 className="font-display text-2xl">{item.name}</h2><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"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/></Link><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>;
}
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 send=async()=>{if(!comment.trim())return;setBusy(true);await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload();setBusy(false)};
const decide=async(decision:'approved'|'changes_requested')=>{if(decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm('确认通过这个版本吗?通过后将记录你的验收决定。'))return;setBusy(true);await api.submitCustomerDecision(slug,work.id,decision,reason.trim());setReason('');await reload();setBusy(false)};
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · V${work.version_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><div className="flex gap-2">{work.versions.map((item)=><Link key={item.version_number} to={`/review/${slug}/works/${work.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>)}</div></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]">WORK {String(work.id).padStart(3,'0')}</b></p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((img)=><AnnotatableImage key={img.id} image={img} annotations={img.annotations} 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')} 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')} 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><h2 className="mt-2 font-display text-3xl"></h2></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><div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e)=>setComment(e.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>{work.review_status!=='approved'&&<><textarea rows={2} value={reason} onChange={(e)=>setReason(e.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></>}</div></div></aside></div></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> }

34
src/pages/Dashboard.tsx Normal file
View File

@@ -0,0 +1,34 @@
import { useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { Activity, ArrowUpRight, Database, FolderKanban, Pencil, Plus, ShieldCheck, Users, X } from 'lucide-react';
import type { AuditLogEntry, ManagedUser, OperationGroup, Project, StorageConfig } from '@shared/types';
import { api } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
export default function Dashboard() {
const [projects,setProjects]=useState<Project[]>([]); const [groups,setGroups]=useState<OperationGroup[]>([]); const [accounts,setAccounts]=useState<ManagedUser[]>([]); const [storage,setStorage]=useState<StorageConfig[]>([]); const [logs,setLogs]=useState<AuditLogEntry[]>([]);
const [creating,setCreating]=useState(false); const [editingGroup,setEditingGroup]=useState(false); const [groupName,setGroupName]=useState(''); const [form,setForm]=useState({name:'',slug:'',client_description:'',groupId:''});
const {user,initialize}=useAuthStore();
const load=()=>api.listProjects().then(setProjects);
useEffect(()=>{void load();if(user?.role==='platform_admin')void Promise.all([api.listGroups(),api.listManagedUsers(),api.listStorageConfigs(),api.listAuditLogs()]).then(([g,a,s,l])=>{setGroups(g);setAccounts(a);setStorage(s);setLogs(l)});else if(user?.role==='group_admin')void api.listGroups().then(setGroups)},[user?.role]);
const activeStorage=storage.find((item)=>item.status==='active');
const metrics=useMemo(()=>[{label:'运营组',value:groups.filter((item)=>item.status==='active').length,icon:<Users size={18}/>},{label:'有效账号',value:accounts.filter((item)=>item.status==='active').length,icon:<ShieldCheck size={18}/>},{label:'进行中项目',value:projects.filter((item)=>item.status==='active').length,icon:<FolderKanban size={18}/>},{label:'对象存储',value:activeStorage?'已连接':'未配置',icon:<Database size={18}/>}],[groups,accounts,projects,activeStorage]);
const create=async()=>{await api.createProject({...form,groupId:form.groupId?Number(form.groupId):undefined});setCreating(false);setForm({name:'',slug:'',client_description:'',groupId:''});await load()};
return <main className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10 lg:py-14">
{user?.role==='platform_admin'?<>
<section className="grid gap-8 border-b border-black/10 pb-10 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Platform / Governance</p><h1 className="mt-4 max-w-4xl font-display text-5xl leading-[.96] tracking-[-.055em] sm:text-7xl"><br/><em className="font-light text-black/35"></em></h1></div><div className="flex flex-wrap gap-2"><Link to="/management" className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><ShieldCheck size={15}/></Link><button onClick={()=>setCreating(true)} className="inline-flex items-center gap-2 rounded-full bg-black px-5 py-3 text-sm text-white"><Plus size={15}/></button></div></section>
<section className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{metrics.map((item)=><div key={item.label} className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center justify-between text-black/35"><span className="grid h-9 w-9 place-items-center rounded-full bg-[#f0ede6]">{item.icon}</span><span className="font-mono text-[9px] uppercase tracking-[.2em]">Live</span></div><strong className="mt-7 block font-display text-4xl">{item.value}</strong><span className="mt-1 block text-xs text-black/40">{item.label}</span></div>)}</section>
<section className="mt-8 grid gap-5 xl:grid-cols-[1.25fr_.75fr]"><div className="rounded-[28px] border border-black/[.08] bg-white p-6"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-black/35">Projects</p><h2 className="mt-2 font-display text-3xl"></h2></div><span className="text-xs text-black/35">{projects.length} </span></div><div className="mt-6 grid gap-3 sm:grid-cols-2">{projects.slice(0,6).map((project)=><Link key={project.id} to={`/projects/${project.id}`} className="group rounded-2xl border border-black/10 p-4 transition hover:border-black/30"><div className="flex justify-between"><FolderKanban size={16}/><ArrowUpRight size={15} className="text-black/25 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5"/></div><div className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-[#f3ece5] px-2.5 py-1 text-[10px] text-[#9b5236]"><Users size={11}/>{project.group_name}</div><h3 className="mt-3 font-display text-2xl">{project.name}</h3><p className="mt-2 text-xs text-black/40">{project.collection_count} · {project.work_count} </p></Link>)}</div></div><div className="rounded-[28px] bg-[#171714] p-6 text-white"><div className="flex items-center gap-2 text-white/40"><Activity size={15}/><span className="font-mono text-[9px] uppercase tracking-[.22em]">Recent activity</span></div><h2 className="mt-3 font-display text-3xl"></h2><div className="mt-6 space-y-4">{logs.slice(0,6).map((log)=><div key={log.id} className="border-b border-white/10 pb-3"><p className="text-xs text-white/75">{log.action}</p><p className="mt-1 text-[10px] text-white/30">{log.user_name||'系统'} · {new Date(log.created_at).toLocaleString('zh-CN')}</p></div>)}{logs.length===0&&<p className="py-10 text-center text-xs text-white/30"></p>}</div></div></section>
</>:<>
<section className="grid gap-10 border-b border-black/10 pb-12 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-4 flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Operations / {user?.group_name}</p>{user?.role==='group_admin'&&<button onClick={()=>{setGroupName(user.group_name||'');setEditingGroup(true)}} className="inline-flex items-center gap-1 text-[10px] text-black/35 hover:text-black"><Pencil size={10}/></button>}</div><h1 className="max-w-4xl font-display text-5xl leading-[.95] tracking-[-.055em] sm:text-7xl lg:text-[92px]"><br/><em className="font-light text-black/35"></em></h1></div><button onClick={()=>setCreating(true)} className="flex items-center justify-center gap-2 rounded-full bg-[#171714] px-6 py-3.5 text-sm text-white hover:bg-[#ba623c]"><Plus size={17}/></button></section>
<ProjectGrid projects={projects}/>
</>}
{creating&&<Modal close={()=>setCreating(false)} title="创建项目" icon={<FolderKanban/>}><div className="mt-7 space-y-5">{user?.role==='platform_admin'&&<Field label="所属运营组"><select value={form.groupId} onChange={(e)=>setForm({...form,groupId:e.target.value})}><option value=""></option>{groups.filter((item)=>item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>}<Field label="项目名称"><input value={form.name} onChange={(e)=>setForm({...form,name:e.target.value})}/></Field><Field label="项目标识"><input placeholder="project-slug" value={form.slug} onChange={(e)=>setForm({...form,slug:e.target.value})}/></Field><Field label="客户页简介"><textarea rows={3} value={form.client_description} onChange={(e)=>setForm({...form,client_description:e.target.value})}/></Field></div><button disabled={!form.name||!form.slug||(user?.role==='platform_admin'&&!form.groupId)} onClick={()=>void create()} className="mt-7 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editingGroup&&<Modal close={()=>setEditingGroup(false)} title="修改运营组名称" icon={<Pencil/>}><p className="mt-3 text-sm leading-6 text-black/45"></p><input autoFocus value={groupName} onChange={(e)=>setGroupName(e.target.value)} className="mt-6 w-full rounded-xl border border-black/10 bg-white p-3" placeholder="请输入运营组名称"/><button disabled={groupName.trim().length<2} onClick={async()=>{await api.updateCurrentGroup(groupName.trim());await initialize();setEditingGroup(false)}} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
</main>;
}
function ProjectGrid({projects}:{projects:Project[]}){return <><div className="mb-6 mt-10 flex items-center justify-between"><h2 className="font-display text-3xl">项目</h2><span className="font-mono text-xs text-black/40">{projects.length} ACTIVE</span></div><section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{projects.map((project,index)=><Link key={project.id} to={`/projects/${project.id}`} className="group relative min-h-64 overflow-hidden rounded-[28px] border border-black/10 bg-white p-7 transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/10"><div className="absolute right-0 top-0 h-36 w-36 translate-x-12 -translate-y-12 rounded-full" style={{background:index%3===0?'#ffddd2':index%3===1?'#dcece6':'#f6e8b9'}}/><div className="relative flex h-full flex-col"><div className="flex items-center justify-between"><FolderKanban size={20}/><ArrowUpRight className="opacity-30 transition group-hover:translate-x-1 group-hover:-translate-y-1 group-hover:opacity-100"/></div><div className="mt-auto"><h3 className="font-display text-3xl tracking-tight">{project.name}</h3><p className="mt-2 line-clamp-2 text-sm leading-6 text-black/50">{project.client_description||'暂无项目说明'}</p><div className="mt-6 flex gap-5 border-t border-black/10 pt-4 text-xs text-black/50"><span><b className="text-black">{project.collection_count}</b> </span><span><b className="text-black">{project.work_count}</b> </span></div></div></div></Link>)}</section></>}
function Modal({close,title,icon,children}:{close:()=>void;title:string;icon:React.ReactNode;children:React.ReactNode}){return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4 backdrop-blur-sm"><div className="w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-7 shadow-2xl"><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 Field({label,children}:{label:string;children:React.ReactNode}){return <label className="block text-xs font-medium text-black/55">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3">{children}</div></label>}

183
src/pages/Gallery.tsx Normal file
View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from 'react';
import { useNotesStore } from '@/store/useNotesStore';
import NoteCard from '@/components/NoteCard';
import { Search, ArrowUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
export default function Gallery() {
const { notes, loading, error, query, fetchNotes, setQuery } = useNotesStore();
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
// 搜索防抖
useEffect(() => {
const t = setTimeout(() => {
if (searchInput !== (query.q ?? '')) {
setQuery({ q: searchInput || undefined });
}
}, 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const stats = useMemo(() => {
const total = notes.length;
const images = notes.reduce((s, n) => s + n.image_count, 0);
const ann = notes.reduce((s, n) => s + n.annotation_count, 0);
return { total, images, ann };
}, [notes]);
return (
<div>
{/* Hero */}
<section className="border-b border-stone-200/60">
<div className="mx-auto max-w-[1400px] px-6 lg:px-10 pt-20 pb-16">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-stone-500 mb-6 animate-fade-in">
01 Volume One · 2026
</div>
<h1 className="font-display text-[clamp(3.5rem,9vw,8.5rem)] leading-[0.92] tracking-tighter-2 text-ink animate-fade-up">
<span className="italic text-ochre"></span>
<br />
<span className="italic text-sage"></span>
</h1>
<div className="mt-10 grid grid-cols-1 md:grid-cols-12 gap-6 animate-fade-up" style={{ animationDelay: '200ms' }}>
<p className="md:col-span-7 lg:col-span-6 text-stone-600 leading-relaxed text-base">
</p>
<div className="md:col-span-5 lg:col-start-9 lg:col-span-4 flex items-end justify-end gap-8">
<Stat label="Entries" value={stats.total} />
<Stat label="Plates" value={stats.images} />
<Stat label="Notes" value={stats.ann} accent />
</div>
</div>
<div className="mt-12 h-px bg-ink/15 origin-left animate-draw-line" />
</div>
</section>
{/* 工具栏 */}
<section className="sticky top-[73px] z-30 bg-paper/85 backdrop-blur border-b border-stone-200/60">
<div className="mx-auto max-w-[1400px] px-6 lg:px-10 py-4 flex flex-col sm:flex-row sm:items-center gap-4 justify-between">
<div className="flex items-center gap-4 font-mono text-[10px] uppercase tracking-[0.2em]">
<span className="text-stone-400"> 02 Index</span>
<div className="hidden sm:flex items-center gap-1">
{(['created_at', 'annotations'] as const).map((key) => (
<button
key={key}
onClick={() => setQuery({ sort: key })}
className={cn(
'px-2 py-1 transition-colors',
query.sort === key
? 'text-ink underline underline-offset-4 decoration-ochre decoration-1'
: 'text-stone-400 hover:text-ink',
)}
>
{key === 'created_at' ? 'By Date' : 'By Notes'}
</button>
))}
<span className="mx-2 text-stone-300">·</span>
<button
onClick={() => setQuery({ order: query.order === 'asc' ? 'desc' : 'asc' })}
className="inline-flex items-center gap-1 text-stone-500 hover:text-ink"
>
<ArrowUpDown className="w-3 h-3" strokeWidth={1.5} />
{query.order === 'asc' ? 'Asc' : 'Desc'}
</button>
</div>
</div>
<div className="relative w-full sm:w-72">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-stone-400"
strokeWidth={1.5}
/>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索标题或描述…"
className="w-full bg-transparent border border-stone-300 pl-9 pr-3 py-2 text-sm text-ink placeholder:text-stone-400 focus:outline-none focus:border-ink"
/>
</div>
</div>
</section>
{/* 笔记瀑布流 */}
<section className="mx-auto max-w-[1400px] px-6 lg:px-10 py-16">
{error && (
<div className="border border-ochre/30 bg-ochre/5 p-6 mb-12 font-mono text-sm text-ochre">
{error}
</div>
)}
{loading && notes.length === 0 ? (
<SkeletonGrid />
) : notes.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
{notes.map((note, i) => (
<NoteCard key={note.id} note={note} index={i} />
))}
</div>
)}
</section>
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div className="text-right">
<div
className={cn(
'font-display text-4xl leading-none tracking-tightest',
accent ? 'text-ochre' : 'text-ink',
)}
>
{String(value).padStart(2, '0')}
</div>
<div className="mt-1 font-mono text-[9px] uppercase tracking-[0.25em] text-stone-500">
{label}
</div>
</div>
);
}
function SkeletonGrid() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="flex justify-between mb-3">
<div className="h-3 w-12 bg-stone-200" />
<div className="h-3 w-16 bg-stone-200" />
</div>
<div className="aspect-[4/5] bg-stone-200" />
<div className="h-6 bg-stone-200 mt-4 w-3/4" />
<div className="h-4 bg-stone-200 mt-2 w-full" />
<div className="h-4 bg-stone-200 mt-1 w-2/3" />
</div>
))}
</div>
);
}
function EmptyState() {
return (
<div className="text-center py-24">
<div className="inline-flex items-center justify-center w-16 h-16 border border-stone-300 rounded-full mb-6">
<Inbox className="w-7 h-7 text-stone-400" strokeWidth={1.2} />
</div>
<h3 className="font-display text-3xl text-ink tracking-tightest">
</h3>
<p className="mt-3 text-sm text-stone-500 max-w-md mx-auto leading-relaxed">
<span className="font-mono text-ochre">Compose</span>
<span className="font-mono text-ochre">POST /api/notes</span>
</p>
</div>
);
}

50
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ArrowRight, LockKeyhole, Sparkle } from 'lucide-react';
import { useAuthStore } from '@/store/useAuthStore';
export default function LoginPage() {
const { user, login, loading } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const location = useLocation();
if (user) return <Navigate to="/" replace />;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
try {
await login(username, password);
navigate((location.state as { from?: string } | null)?.from || '/');
} catch (reason) {
setError(reason instanceof Error ? reason.message : '登录失败');
}
};
return (
<main className="grid min-h-[calc(100vh-73px)] bg-[#faf9f5] lg:grid-cols-[1.2fr_.8fr]">
<section className="relative hidden overflow-hidden bg-[#181817] p-14 text-[#f8f4ec] lg:flex lg:flex-col">
<div className="absolute -left-44 -top-44 h-[540px] w-[540px] rounded-full border border-[#f7c45c]/20" />
<div className="absolute -bottom-64 -right-44 h-[620px] w-[620px] rounded-full bg-[#b56a2d]/15 blur-3xl" />
<div className="relative"><p className="font-mono text-[10px] uppercase tracking-[.34em] text-[#f7c45c]">A quiet place for good work</p></div>
<div className="relative my-auto max-w-2xl"><h1 className="font-display text-7xl leading-[.93] tracking-[-.06em]"><br/><em className="font-light text-white/40"></em></h1><p className="mt-9 max-w-md text-sm leading-7 text-white/45"></p></div>
<div className="relative flex items-center gap-3 text-[11px] text-white/35"><Sparkle size={14} className="text-[#f7c45c]"/> </div>
</section>
<section className="grid place-items-center px-5 py-14 sm:px-10">
<form onSubmit={submit} className="w-full max-w-sm">
<div className="grid h-12 w-12 place-items-center rounded-full border border-black/10 bg-white text-[#b56a2d]"><LockKeyhole size={18}/></div>
<p className="mt-9 font-mono text-[10px] uppercase tracking-[.3em] text-[#b56a2d]">Private workspace</p>
<h1 className="mt-3 font-display text-5xl tracking-[-.05em] text-[#181817]"></h1>
<p className="mt-3 max-w-xs text-sm leading-6 text-black/45">使</p>
<label className="mt-9 block text-xs font-medium text-black/55"><input autoFocus autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} className="mt-2.5 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm outline-none transition focus:border-[#b56a2d]"/></label>
<label className="mt-5 block text-xs font-medium text-black/55"><input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} className="mt-2.5 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm outline-none transition focus:border-[#b56a2d]"/></label>
{error && <p className="mt-4 rounded-xl border border-red-100 bg-red-50 p-3 text-xs text-red-700">{error}</p>}
<button disabled={loading || !username || !password} className="mt-8 flex w-full items-center justify-center gap-2 rounded-full bg-[#181817] py-4 text-sm text-white transition hover:bg-[#b56a2d] disabled:opacity-35">
{loading ? '正在验证身份…' : <> <ArrowRight size={15}/></>}
</button>
<p className="mt-5 text-center text-[11px] leading-5 text-black/35"></p>
</form>
</section>
</main>
);
}

170
src/pages/Management.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-react';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project } from '@shared/types';
import { api, ApiError } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
import StorageSettings from '@/components/StorageSettings';
type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit';
type Panel = 'group' | 'user' | 'key' | 'rename' | 'rename_group' | 'replace_admin' | 'reset' | null;
const roleLabel = { platform_admin: '平台管理员', group_admin: '组管理员', operator: '光影叙事' } as const;
const actionLabel: Record<string, string> = {
'group.create': '创建运营组', 'group.active': '启用运营组', 'group.disabled': '停用运营组',
'group.admin_replace':'更换组管理员','group.rename':'修改运营组名称','user.create': '创建账号', 'user.active': '启用账号', 'user.disabled': '停用账号', 'user.password_reset': '重置密码','user.name_update':'修改用户名',
'api_key.create': '创建 API Key', 'api_key.revoke': '吊销 API Key',
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '创建作品交付集',
'collection.update': '修改作品交付集', 'work.create': '上传作品',
};
export default function ManagementPage() {
const { user } = useAuthStore();
const isPlatform = user?.role === 'platform_admin';
const [tab, setTab] = useState<Tab>(isPlatform ? 'groups' : 'accounts');
const [panel, setPanel] = useState<Panel>(null);
const [groups, setGroups] = useState<OperationGroup[]>([]);
const [accounts, setAccounts] = useState<ManagedUser[]>([]);
const [keys, setKeys] = useState<ManagedApiKey[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [revealedKey, setRevealedKey] = useState('');
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
const [renameGroup, setRenameGroup] = useState<OperationGroup | null>(null);
const [renameGroupValue, setRenameGroupValue] = useState('');
const [replacementUserId, setReplacementUserId] = useState('');
const [previousAdminAction, setPreviousAdminAction] = useState<'demote' | 'disable'>('demote');
const [accountGroupFilter, setAccountGroupFilter] = useState('');
const [auditUserId, setAuditUserId] = useState<number | null>(null);
const [groupForm, setGroupForm] = useState({ name: '', username: '', display_name: '', password: '' });
const [userForm, setUserForm] = useState({ group_id: '', username: '', display_name: '', password: '', role: 'operator' as 'platform_admin' | 'group_admin' | 'operator' });
const [keyForm, setKeyForm] = useState({ name: '', project_id: '' });
const [resetPassword, setResetPassword] = useState('');
const [renameValue, setRenameValue] = useState('');
const load = useCallback(async () => {
if (!user || user.role === 'operator') return;
setLoading(true);
setError('');
try {
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups] = await Promise.all([
api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(),
isPlatform ? api.listGroups() : Promise.resolve([]),
]);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups);
} catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
}, [isPlatform, user]);
useEffect(() => { void load(); }, [load]);
const tabs = useMemo(() => [
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
{ id: 'accounts' as const, label: '账号', count: accounts.length },
{ id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').length },
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: 0 }] : []),
{ id: 'audit' as const, label: '审计日志', count: logs.length },
], [accounts.length, groups.length, isPlatform, keys, logs.length]);
if (user?.role === 'operator') {
return <main className="mx-auto max-w-3xl px-5 py-24 text-center"><ShieldCheck className="mx-auto text-black/25"/><h1 className="mt-5 font-display text-4xl"></h1><p className="mt-3 text-sm text-black/45"></p></main>;
}
const run = async (task: () => Promise<void>) => {
setSaving(true); setError('');
try { await task(); setPanel(null); await load(); }
catch (reason) { setError(messageOf(reason)); }
finally { setSaving(false); }
};
const renameOperationGroup = async () => {
if (!renameGroup) return;
setSaving(true); setError('');
try {
const updated = await api.updateGroupName(renameGroup.id, renameGroupValue.trim());
setGroups((items) => items.map((item) => item.id === updated.id ? { ...item, name: updated.name } : item));
setAccounts((items) => items.map((item) => item.group_id === updated.id ? { ...item, group_name: updated.name } : item));
setProjects((items) => items.map((item) => item.group_id === updated.id ? { ...item, group_name: updated.name } : item));
setPanel(null);
} catch (reason) { setError(messageOf(reason)); }
finally { setSaving(false); }
};
return (
<main className="mx-auto max-w-[1500px] px-5 py-9 lg:px-10 lg:py-14">
<section className="relative overflow-hidden rounded-[32px] bg-[#171714] px-6 py-8 text-white sm:px-9 lg:px-12 lg:py-11">
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full border border-white/10"/><div className="absolute right-12 top-8 h-24 w-24 rounded-full bg-[#d97045]/20 blur-2xl"/>
<div className="relative flex flex-col gap-8 md:flex-row md:items-end md:justify-between"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#f1a06f]">Governance / Delivery Desk</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] sm:text-6xl"></h1><p className="mt-4 max-w-xl text-sm leading-6 text-white/45"></p></div><button onClick={() => void load()} className="inline-flex items-center justify-center gap-2 rounded-full border border-white/15 px-4 py-2.5 text-xs text-white/60 transition hover:border-white/40 hover:text-white"><RefreshCw size={13} className={loading ? 'animate-spin' : ''}/></button></div>
</section>
<nav className="mt-7 flex gap-1 overflow-x-auto border-b border-black/10">
{tabs.map((item) => <button key={item.id} onClick={() => setTab(item.id)} className={`relative whitespace-nowrap px-4 py-4 text-sm transition ${tab === item.id ? 'text-black' : 'text-black/40 hover:text-black'}`}>{item.label}<span className="ml-2 font-mono text-[10px] text-black/30">{item.count}</span>{tab === item.id && <span className="absolute inset-x-3 bottom-0 h-0.5 bg-[#d15f37]"/>}</button>)}
</nav>
{error && <div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{loading ? <Loading /> : <>
{tab === 'groups' && <Groups groups={groups} accounts={accounts} onCreate={() => setPanel('group')} onRename={(item)=>{setRenameGroup(item);setRenameGroupValue(item.name);setPanel('rename_group')}} onShowAccounts={(item) => { setAccountGroupFilter(String(item.id)); setTab('accounts'); }} onReplace={(item) => { setReplaceGroup(item); setReplacementUserId(''); setPreviousAdminAction('demote'); setPanel('replace_admin'); }} onToggle={(item) => { if(item.status==='active'&&!window.confirm(`停用“${item.name}”将影响 ${item.active_user_count} 个启用账号、${item.project_count} 个项目和 ${item.customer_link_count} 个客户访问链接。确认继续吗?`))return;void run(() => api.setGroupStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined)); }}/>}
{tab === 'accounts' && <Accounts accounts={accounts} groups={groups} groupFilter={accountGroupFilter} onGroupFilter={setAccountGroupFilter} currentId={user?.id} currentRole={user?.role} onCreate={() => setPanel('user')} onAudit={(item) => { setAuditUserId(item.id); setTab('audit'); }} onRename={(item) => { setRenameTarget(item); setRenameValue(item.display_name); setPanel('rename'); }} onToggle={(item) => void run(() => api.setUserStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined))} onReset={(item) => { setResetTarget(item); setResetPassword(''); setPanel('reset'); }}/>}
{tab === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
{tab === 'storage' && isPlatform && <StorageSettings/>}
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
</>}
{panel === 'group' && <Modal title="创建运营组" subtitle="创建数据空间,并同时配置首位组管理员。" onClose={() => setPanel(null)}><FormField label="运营组名称"><input value={groupForm.name} onChange={(e) => setGroupForm({ ...groupForm, name: e.target.value })}/></FormField><div className="grid gap-4 sm:grid-cols-2"><FormField label="管理员登录账号"><input value={groupForm.username} onChange={(e) => setGroupForm({ ...groupForm, username: e.target.value })}/></FormField><FormField label="管理员姓名" hint="填写本人姓名"><input value={groupForm.display_name} onChange={(e) => setGroupForm({ ...groupForm, display_name: e.target.value })}/></FormField></div><FormField label="临时密码" hint="至少 8 位,包含字母和数字"><input type="password" value={groupForm.password} onChange={(e) => setGroupForm({ ...groupForm, password: e.target.value })}/></FormField><Submit saving={saving} disabled={!groupForm.name || !groupForm.username || !groupForm.display_name || !groupForm.password} onClick={() => void run(async () => { await api.createGroup(groupForm); setGroupForm({ name: '', username: '', display_name: '', password: '' }); })}></Submit></Modal>}
{panel === 'user' && <Modal title="创建工作台账号" subtitle={isPlatform ? '每组限一位组管理员;光影叙事和平台管理员均可创建多位。' : '为当前运营组添加拥有独立用户名的光影叙事账号。'} onClose={() => setPanel(null)}>{isPlatform && <div className="grid gap-4 sm:grid-cols-2">{userForm.role === 'platform_admin' ? <div className="rounded-xl border border-black/10 bg-white p-3 text-xs text-black/45"></div> : <FormField label="所属运营组"><select value={userForm.group_id} onChange={(e) => setUserForm({ ...userForm, group_id: e.target.value })}><option value=""></option>{groups.filter((item) => item.status === 'active').map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<FormField label="身份"><select value={userForm.role} onChange={(e) => setUserForm({ ...userForm, role: e.target.value as 'platform_admin' | 'group_admin' | 'operator', group_id: e.target.value === 'platform_admin' ? '' : userForm.group_id })}><option value="operator"></option><option value="group_admin"></option><option value="platform_admin"></option></select></FormField></div>}<div className="grid gap-4 sm:grid-cols-2"><FormField label="登录账号"><input value={userForm.username} onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}/></FormField><FormField label="用户名" hint="填写成员名称"><input value={userForm.display_name} onChange={(e) => setUserForm({ ...userForm, display_name: e.target.value })}/></FormField></div><FormField label="临时密码" hint="首次登录后必须修改"><input type="password" value={userForm.password} onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}/></FormField><Submit saving={saving} disabled={(isPlatform && userForm.role !== 'platform_admin' && !userForm.group_id) || !userForm.username || !userForm.display_name || !userForm.password} onClick={() => void run(async () => { await api.createManagedUser({ ...userForm, group_id: userForm.group_id ? Number(userForm.group_id) : undefined }); setUserForm({ group_id: '', username: '', display_name: '', password: '', role: 'operator' }); })}></Submit></Modal>}
{panel === 'reset' && resetTarget && <Modal title="重置临时密码" subtitle={`账号:${resetTarget.username} · ${resetTarget.display_name}`} onClose={() => setPanel(null)}><FormField label="新临时密码" hint="重置后原会话立即失效"><input autoFocus type="password" value={resetPassword} onChange={(e) => setResetPassword(e.target.value)}/></FormField><Submit saving={saving} disabled={!resetPassword} onClick={() => void run(() => api.resetUserPassword(resetTarget.id, resetPassword).then(() => undefined))}></Submit></Modal>}
{panel === 'rename' && renameTarget && <Modal title="修改用户名" subtitle={`登录账号:${renameTarget.username} · 身份:${roleLabel[renameTarget.role]}`} onClose={() => setPanel(null)}><FormField label="用户名" hint="仅修改页面展示用户名,不改变登录账号与权限"><input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameValue.trim().length < 2} onClick={() => void run(() => api.updateManagedUserName(renameTarget.id, renameValue.trim()).then(() => undefined))}></Submit></Modal>}
{panel === 'replace_admin' && replaceGroup && <Modal title="更换组管理员" subtitle={`${replaceGroup.name} 当前管理员:${replaceGroup.group_admin_name || '未设置'}`} onClose={() => setPanel(null)}><FormField label="新组管理员" hint="从已启用的光影叙事中选择"><select value={replacementUserId} onChange={(e)=>setReplacementUserId(e.target.value)}><option value=""></option>{accounts.filter((item)=>item.group_id===replaceGroup.id&&item.role==='operator'&&item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.display_name} · {item.username}</option>)}</select></FormField><FormField label="原管理员处理"><select value={previousAdminAction} onChange={(e)=>setPreviousAdminAction(e.target.value as 'demote'|'disable')}><option value="demote"></option><option value="disable"></option></select></FormField><div className="rounded-xl border border-[#d15f37]/15 bg-[#fff7f2] p-3 text-xs leading-5 text-[#91462e]"></div><Submit saving={saving} disabled={!replacementUserId} onClick={() => void run(() => api.replaceGroupAdmin(replaceGroup.id,Number(replacementUserId),previousAdminAction).then(()=>undefined))}></Submit></Modal>}
{panel === 'rename_group' && renameGroup && <Modal title="修改运营组名称" subtitle="只修改名称,不影响组内账号、项目、客户链接和权限。" onClose={()=>setPanel(null)}><FormField label="运营组名称" hint="240 个字符"><input autoFocus value={renameGroupValue} onChange={(e)=>setRenameGroupValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameGroupValue.trim().length<2||renameGroupValue.trim()===renameGroup.name} onClick={()=>void renameOperationGroup()}></Submit></Modal>}
{panel === 'key' && <Modal title={revealedKey ? '保存 API Key' : '创建 API Key'} subtitle={revealedKey ? '密钥只显示这一次。关闭前请复制到安全位置。' : isPlatform ? '平台级 Key 可用于外部系统创建项目。' : '项目级 Key 仅能操作绑定的项目。'} onClose={() => { setPanel(null); setRevealedKey(''); }}>{revealedKey ? <div><div className="break-all rounded-2xl bg-[#171714] p-5 font-mono text-xs leading-6 text-[#f2c38d]">{revealedKey}</div><button onClick={() => void navigator.clipboard.writeText(revealedKey)} className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-3 text-sm"><Copy size={14}/>复制密钥</button></div> : <><FormField label="Key 名称"><input value={keyForm.name} onChange={(e) => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/></FormField>{!isPlatform && <FormField label="绑定项目"><select value={keyForm.project_id} onChange={(e) => setKeyForm({ ...keyForm, project_id: e.target.value })}><option value="">请选择</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<Submit saving={saving} disabled={!keyForm.name || (!isPlatform && !keyForm.project_id)} onClick={() => { setSaving(true); setError(''); void api.createApiKey({ name: keyForm.name, project_id: keyForm.project_id ? Number(keyForm.project_id) : undefined }).then(({ token }) => { setRevealedKey(token); setKeyForm({ name: '', project_id: '' }); return load(); }).catch((reason) => setError(messageOf(reason))).finally(() => setSaving(false)); }}></Submit></>}</Modal>}
</main>
);
}
function Groups({ groups, accounts, onCreate, onRename, onShowAccounts, onReplace, onToggle }: { groups: OperationGroup[]; accounts: ManagedUser[]; onCreate: () => void;onRename:(item:OperationGroup)=>void; onShowAccounts: (item:OperationGroup)=>void; onReplace:(item:OperationGroup)=>void; onToggle: (item: OperationGroup) => void }) {
const [expandedId, setExpandedId] = useState<number | null>(null);
const [query,setQuery]=useState('');const[status,setStatus]=useState('all');
const visible=groups.filter((item)=>(status==='all'||item.status===status)&&item.name.toLowerCase().includes(query.trim().toLowerCase()));
return <Section title="运营组" intro="每个运营组拥有独立的账号与项目数据,展开可查看组内成员。" action="创建运营组" onAction={onCreate}><Filters><SearchBox value={query} onChange={setQuery} placeholder="搜索运营组"/><FilterSelect value={status} onChange={setStatus}><option value="all"></option><option value="active"></option><option value="disabled"></option></FilterSelect></Filters><div className="grid gap-3">{visible.map((item) => {
const expanded = expandedId === item.id;
const members = accounts.filter((account) => account.group_id === item.id).sort((a, b) => Number(b.role === 'group_admin') - Number(a.role === 'group_admin') || a.display_name.localeCompare(b.display_name, 'zh-CN'));
const groupAdmin=members.find((member)=>member.role==='group_admin');
return <div key={item.id} className="overflow-hidden rounded-2xl border border-black/[.08] bg-white">
<div className="grid gap-4 px-4 py-4 sm:grid-cols-[42px_1fr_auto] sm:items-center">
<div className="grid h-10 w-10 place-items-center rounded-full bg-[#f1eee7] text-black/55"><Users size={17}/></div>
<div><div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-medium">{item.name}</h3><span className={`rounded-full px-2 py-0.5 text-[9px] ${item.status === 'active' ? 'bg-emerald-50 text-emerald-700' : 'bg-black/5 text-black/35'}`}>{item.status === 'active' ? 'ACTIVE' : 'DISABLED'}</span>{item.status==='active'&&groupAdmin?.status!=='active'&&<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[9px] text-amber-700"></span>}</div><p className="mt-1 text-xs leading-5 text-black/40">{groupAdmin?.display_name || item.group_admin_name || '未设置'}{groupAdmin?.status==='disabled'?'(停用)':''} · {item.operator_count} · {item.disabled_user_count} </p><p className="text-[11px] leading-5 text-black/30">{item.project_count} · {item.customer_link_count} · {dateText(item.created_at)}</p></div>
<div className="flex flex-wrap items-center gap-3"><button aria-expanded={expanded} onClick={() => setExpandedId(expanded ? null : item.id)} className="inline-flex items-center gap-1.5 text-xs text-black/45 hover:text-black">{expanded ? '收起账号' : '查看账号'}<ChevronDown size={13} className={`transition-transform ${expanded ? 'rotate-180' : ''}`}/></button><button onClick={()=>onShowAccounts(item)} className="text-xs text-black/45 hover:text-black"></button><button onClick={()=>onRename(item)} className="text-xs text-black/45 hover:text-black"></button><button disabled={!members.some((member)=>member.role==='operator'&&member.status==='active')} onClick={()=>onReplace(item)} className="inline-flex items-center gap-1 text-xs text-black/45 hover:text-black disabled:opacity-25"><ArrowRightLeft size={12}/></button><button onClick={() => onToggle(item)} className="text-xs text-black/45 underline decoration-black/20 underline-offset-4 hover:text-black">{item.status === 'active' ? '停用' : '启用'}</button></div>
</div>
{expanded && <div className="border-t border-black/[.06] bg-[#faf9f5] px-4 py-2 sm:pl-[70px]">{members.length === 0 ? <p className="py-5 text-xs text-black/35"></p> : members.map((member) => <div key={member.id} className="grid gap-2 border-b border-black/[.06] py-3 last:border-0 sm:grid-cols-[minmax(120px,1fr)_minmax(140px,1fr)_auto] sm:items-center"><div className="flex items-center gap-2"><span className="text-sm font-medium">{member.display_name}</span><span className="rounded-full bg-[#f3ece5] px-2 py-0.5 text-[9px] text-[#a55534]">{roleLabel[member.role]}</span></div><span className="font-mono text-[11px] text-black/40">{member.username}</span><span className={`w-fit rounded-full px-2 py-0.5 text-[9px] ${member.status === 'active' ? 'bg-emerald-50 text-emerald-700' : 'bg-black/5 text-black/35'}`}>{member.status === 'active' ? '启用' : '停用'}</span></div>)}</div>}
</div>;
})}{visible.length===0&&<Empty text="没有符合条件的运营组"/>}</div></Section>;
}
function Accounts({ accounts, groups, groupFilter, onGroupFilter, currentId, currentRole, onCreate, onAudit, onRename, onToggle, onReset }: { accounts: ManagedUser[];groups:OperationGroup[];groupFilter:string;onGroupFilter:(value:string)=>void; currentId?: number; currentRole?: string; onCreate: () => void;onAudit:(item:ManagedUser)=>void; onRename: (item: ManagedUser) => void; onToggle: (item: ManagedUser) => void; onReset: (item: ManagedUser) => void }) {
const[query,setQuery]=useState('');const[role,setRole]=useState('all');const[status,setStatus]=useState('all');
const filtered=accounts.filter((item)=>(!groupFilter||(groupFilter==='platform'?item.group_id==null:String(item.group_id)===groupFilter))&&(role==='all'||item.role===role)&&(status==='all'||(status==='password'?item.must_change_password:item.status===status))&&`${item.display_name} ${item.username}`.toLowerCase().includes(query.trim().toLowerCase()));
const sections=[{title:'平台管理员',items:filtered.filter((item)=>item.role==='platform_admin')},{title:'运营组成员',items:filtered.filter((item)=>item.role!=='platform_admin')}].filter((section)=>section.items.length);
const render=(item:ManagedUser)=>{const canRename=currentRole==='platform_admin'||(currentRole==='group_admin'&&(item.id===currentId||item.role==='operator'));const activity=`最近登录:${item.last_login_at?dateText(item.last_login_at):'从未登录'} · 最近操作:${item.last_operation_at?dateText(item.last_operation_at):'暂无'} · 创建:${dateText(item.created_at)}`;return <Row key={item.id} icon={<Fingerprint size={17}/>} title={item.display_name} badge={roleLabel[item.role]} meta={`登录账号:${item.username} · 所属:${item.group_name||'平台'}${item.must_change_password?' · 待修改临时密码':''}\n${activity}`} status={item.status}><div className="flex flex-wrap gap-3"><button onClick={()=>onAudit(item)} className="inline-flex items-center gap-1 text-xs text-black/45 hover:text-black"><History size={12}/></button>{canRename&&<button onClick={()=>onRename(item)} className="text-xs text-black/45 hover:text-black"></button>}{item.id!==currentId&&<><button onClick={()=>onReset(item)} className="text-xs text-black/45 hover:text-black"></button><button onClick={()=>onToggle(item)} className="text-xs text-black/45 hover:text-black">{item.status==='active'?'停用':'启用'}</button></>}</div></Row>};
return <Section title="账号" intro="按运营组、身份与状态快速定位账号;平台管理员独立展示。" action="创建账号" onAction={onCreate}><Filters><SearchBox value={query} onChange={setQuery} placeholder="搜索用户名或登录账号"/><FilterSelect value={groupFilter} onChange={onGroupFilter}><option value=""></option><option value="platform"></option>{groups.map((item)=><option key={item.id} value={item.id}>{item.name}</option>)}</FilterSelect><FilterSelect value={role} onChange={setRole}><option value="all"></option><option value="platform_admin"></option><option value="group_admin"></option><option value="operator"></option></FilterSelect><FilterSelect value={status} onChange={setStatus}><option value="all"></option><option value="active"></option><option value="disabled"></option><option value="password"></option></FilterSelect></Filters><div className="space-y-7">{sections.map((section)=><div key={section.title}><h3 className="mb-3 font-mono text-[10px] uppercase tracking-[.2em] text-black/35">{section.title} · {section.items.length}</h3><div className="grid gap-3">{section.items.map(render)}</div></div>)}{!sections.length&&<Empty text="没有符合条件的账号"/>}</div></Section>;
}
function ApiKeys({ items, onCreate, onRevoke }: { items: ManagedApiKey[]; onCreate: () => void; onRevoke: (item: ManagedApiKey) => void }) { return <Section title="API Key" intro="明文仅创建时显示一次,数据库只保存不可逆哈希。" action="创建 API Key" onAction={onCreate}><div className="grid gap-3">{items.length === 0 ? <Empty text="尚未创建 API Key"/> : items.map((item) => <Row key={item.id} icon={<KeyRound size={17}/>} title={item.name} meta={`${item.key_prefix} · ${item.scope === 'platform' ? '平台级' : item.project_name || '项目级'} · ${item.last_used_at ? `最近使用 ${dateText(item.last_used_at)}` : '尚未使用'}`} status={item.status}><button disabled={item.status === 'revoked'} onClick={() => onRevoke(item)} className="text-xs text-black/45 hover:text-red-600 disabled:hidden"></button></Row>)}</div></Section>; }
function AuditLogs({ items,filteredUser,onClear }: { items: AuditLogEntry[];filteredUser?:ManagedUser;onClear:()=>void }) { const visible=filteredUser?items.filter((item)=>item.user_id===filteredUser.id):items;return <Section title="审计日志" intro={filteredUser?`仅查看 ${filteredUser.display_name}${filteredUser.username})的操作记录。`:'最近 200 条关键操作,按时间倒序保留。'}>{filteredUser&&<button onClick={onClear} className="mb-4 rounded-full border border-black/10 bg-white px-4 py-2 text-xs text-black/50 hover:text-black"></button>}<div className="overflow-hidden rounded-2xl border border-black/10 bg-white">{visible.length === 0 ? <Empty text="暂无审计记录"/> : visible.map((item) => <div key={item.id} className="grid gap-2 border-b border-black/[.06] px-4 py-4 last:border-0 sm:grid-cols-[170px_1fr_auto] sm:items-center"><time className="font-mono text-[10px] text-black/35">{dateText(item.created_at)}</time><div><p className="text-sm">{actionLabel[item.action] || item.action}</p><p className="mt-1 text-xs text-black/35">{item.user_name || 'API / 系统'} · {item.group_name || '平台'} · {item.entity_type} #{item.entity_id ?? '—'}</p></div><span className="font-mono text-[10px] text-black/25">LOG {String(item.id).padStart(4, '0')}</span></div>)}</div></Section>; }
function Section({ title, intro, action, onAction, children }: { title: string; intro: string; action?: string; onAction?: () => void; children: React.ReactNode }) { return <section className="py-8"><div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"><div><h2 className="font-display text-3xl">{title}</h2><p className="mt-1 text-sm text-black/40">{intro}</p></div>{action && <button onClick={onAction} className="inline-flex items-center justify-center gap-2 rounded-full bg-[#171714] px-5 py-3 text-sm text-white transition hover:bg-[#d15f37]"><Plus size={15}/>{action}</button>}</div>{children}</section>; }
function Row({ icon, title, meta, status, badge, children }: { icon: React.ReactNode; title: string; meta: string; status: string; badge?: string; children: React.ReactNode }) { const active = status === 'active'; return <div className="grid gap-4 rounded-2xl border border-black/[.08] bg-white px-4 py-4 sm:grid-cols-[42px_1fr_auto] sm:items-center"><div className="grid h-10 w-10 place-items-center rounded-full bg-[#f1eee7] text-black/55">{icon}</div><div><div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-medium">{title}</h3>{badge && <span className="rounded-full bg-[#f3ece5] px-2 py-0.5 text-[9px] text-[#a55534]"> · {badge}</span>}<span className={`rounded-full px-2 py-0.5 text-[9px] ${active ? 'bg-emerald-50 text-emerald-700' : 'bg-black/5 text-black/35'}`}>{active ? 'ACTIVE' : status.toUpperCase()}</span></div><p className="mt-1 whitespace-pre-line text-xs leading-5 text-black/40">{meta}</p></div><div>{children}</div></div>; }
function Filters({children}:{children:React.ReactNode}){return <div className="mb-6 flex flex-wrap gap-2 rounded-2xl border border-black/[.07] bg-white p-3">{children}</div>}
function SearchBox({value,onChange,placeholder}:{value:string;onChange:(value:string)=>void;placeholder:string}){return <label className="relative min-w-[220px] flex-1"><Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-black/25"/><input value={value} onChange={(e)=>onChange(e.target.value)} placeholder={placeholder} className="w-full rounded-xl border border-black/10 bg-[#faf9f5] py-2.5 pl-9 pr-3 text-xs outline-none focus:border-[#d15f37]"/></label>}
function FilterSelect({value,onChange,children}:{value:string;onChange:(value:string)=>void;children:React.ReactNode}){return <select value={value} onChange={(e)=>onChange(e.target.value)} className="rounded-xl border border-black/10 bg-[#faf9f5] px-3 py-2.5 text-xs text-black/55 outline-none focus:border-[#d15f37]">{children}</select>}
function Modal({ title, subtitle, onClose, children }: { title: string; subtitle: string; onClose: () => void; children: React.ReactNode }) { return <div className="fixed inset-0 z-[90] grid place-items-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"><div className="my-6 w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-6 shadow-2xl sm:p-8"><div className="flex items-start justify-between gap-4"><div><h3 className="font-display text-3xl">{title}</h3><p className="mt-2 text-sm leading-6 text-black/45">{subtitle}</p></div><button onClick={onClose} className="grid h-9 w-9 flex-none place-items-center rounded-full border border-black/10 bg-white"><X size={16}/></button></div><div className="mt-7 space-y-5">{children}</div></div></div>; }
function FormField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return <label className="block text-xs font-medium text-black/55"><span className="flex justify-between gap-3"><span>{label}</span>{hint && <span className="font-normal text-black/30">{hint}</span>}</span><div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_input]:outline-none [&_input]:focus:border-[#d15f37] [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_select]:outline-none">{children}</div></label>; }
function Submit({ saving, disabled, onClick, children }: { saving: boolean; disabled: boolean; onClick: () => void; children: React.ReactNode }) { return <button disabled={saving || disabled} onClick={onClick} className="w-full rounded-full bg-[#171714] py-3.5 text-sm text-white transition hover:bg-[#d15f37] disabled:opacity-30">{saving ? '正在保存…' : children}</button>; }
function Loading() { return <div className="grid gap-3 py-8">{[1, 2, 3].map((item) => <div key={item} className="h-20 animate-pulse rounded-2xl bg-black/5"/>)}</div>; }
function Empty({ text }: { text: string }) { return <div className="rounded-2xl border border-dashed border-black/15 px-5 py-14 text-center text-sm text-black/35">{text}</div>; }
function messageOf(reason: unknown) { return reason instanceof ApiError || reason instanceof Error ? reason.message : '操作失败'; }
function dateText(value: string) { const date = new Date(value.includes('T') ? value : `${value.replace(' ', 'T')}Z`); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }); }

19
src/pages/NewVersion.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowUp, ImagePlus, X } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
type Item = { file: File; url: string };
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>;
}

65
src/pages/NoteDetail.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, History, MessageSquareText, RotateCcw, Send, UploadCloud } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import StatusBadge from '@/components/StatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function NoteDetailPage() {
const id = Number(useParams().noteId);
const [search] = useSearchParams();
const selected = search.get('version');
const version = selected ? Number(selected) : undefined;
const user = useAuthStore((state) => state.user);
const [work, setWork] = useState<NoteDetail | null>(null);
const [comment, setComment] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const result = await api.getNote(id, version);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}, [id, version]);
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 send = async () => {
if (!comment.trim()) return;
setBusy(true);
await api.addComment(id, { content: comment.trim(), author_name: user?.display_name || '工作台', author_role: 'operator' });
setComment(''); await load(); setBusy(false);
};
const reopen = async () => {
if (!reason.trim()) return;
setBusy(true); setMessage('');
try { await api.reopenWork(id, reason.trim()); setReason(''); await load(); }
catch (error) { setMessage(error instanceof Error ? error.message : '操作失败'); }
finally { setBusy(false); }
};
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></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></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>
<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 flex items-center justify-between text-[10px] opacity-50"><span>{item.author_name}</span><span>{new Date(item.created_at).toLocaleString('zh-CN')}</span></div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}{work.review_events.length > 0 && <div className="mt-5 border-t border-black/10 pt-4"><div className="mb-3 flex items-center gap-2 text-xs text-black/45"><History size={13}/></div>{work.review_events.map((event) => <div key={event.id} className="mb-2 rounded-xl bg-white/60 p-3 text-[11px] leading-5 text-black/55">V{event.version_number} · {event.actor_name} · {event.to_status}{event.reason && <p className="mt-1 text-black/70">{event.reason}</p>}</div>)}</div>}</div>
<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e) => setComment(e.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>{canReopen && <div className="mt-3 rounded-2xl border border-black/10 bg-white p-3"><textarea rows={2} value={reason} onChange={(e) => setReason(e.target.value)} className="w-full resize-none text-xs outline-none" placeholder="填写重新打开验收的原因"/><button disabled={busy || !reason.trim()} onClick={() => void reopen()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-2.5 text-xs disabled:opacity-30"><RotateCcw size={13}/></button></div>}{message && <p className="mt-2 text-xs text-red-600">{message}</p>}</div>
</div></aside>
</div>
</main>;
}

64
src/pages/Project.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, 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 { api } from '@/api/client';
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 [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);
const [accessPassword, setAccessPassword] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const [nextProject, nextCollections] = await Promise.all([api.getProject(id), api.listCollections(id)]);
setProject(nextProject);
setCollections(nextCollections);
}, [id]);
useEffect(() => { void load(); }, [load]);
if (!project) return null;
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 : '保存失败'); }
};
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><span className="rounded-full bg-emerald-50 px-2 py-1 text-[10px] text-emerald-700"></span></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></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>}
</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> }

19
src/pages/Upload.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { useMemo, useRef, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { ArrowLeft, ImagePlus, Loader2, X } from 'lucide-react';
import { api } from '@/api/client';
type Item={file:File;url:string};
export default function UploadPage(){
const {projectId,collectionId}=useParams(); const pid=Number(projectId),cid=Number(collectionId); const nav=useNavigate(); const input=useRef<HTMLInputElement>(null);const draggedUrl=useRef<string|null>(null); const [title,setTitle]=useState('');const [content,setContent]=useState('');const [tags,setTags]=useState('');const [items,setItems]=useState<Item[]>([]);const [dragging,setDragging]=useState<string|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const can=useMemo(()=>title.trim()&&items.length>0&&!busy,[title,items,busy]); const add=(files:FileList|null)=>{if(!files)return;setItems(p=>[...p,...Array.from(files).filter(f=>f.type.startsWith('image/')).slice(0,30-p.length).map(file=>({file,url:URL.createObjectURL(file)}))])};
const startReorder=(event:React.PointerEvent<HTMLDivElement>,url:string)=>{event.preventDefault();event.currentTarget.setPointerCapture(event.pointerId);draggedUrl.current=url;setDragging(url)};
const moveReorder=(event:React.PointerEvent<HTMLDivElement>)=>{const source=draggedUrl.current;if(!source)return;const target=document.elementFromPoint(event.clientX,event.clientY)?.closest<HTMLElement>('[data-image-key]')?.dataset.imageKey;if(!target||target===source)return;setItems(current=>{const from=current.findIndex(item=>item.url===source),to=current.findIndex(item=>item.url===target);if(from<0||to<0||from===to)return current;const next=[...current];const [moved]=next.splice(from,1);next.splice(to,0,moved);return next})};
const finishReorder=(event:React.PointerEvent<HTMLDivElement>)=>{if(event.currentTarget.hasPointerCapture(event.pointerId))event.currentTarget.releasePointerCapture(event.pointerId);draggedUrl.current=null;setDragging(null)};
const submit=async()=>{if(!can)return;setBusy(true);setError('');try{const work=await api.createNote({collectionId:cid,title:title.trim(),description:content.trim(),tags:tags?[tags]:[],images:items.map(x=>x.file)});items.forEach(x=>URL.revokeObjectURL(x.url));nav(`/works/${work.id}`)}catch(e){setError(e instanceof Error?e.message:'上传失败')}finally{setBusy(false)}};
return <main className="mx-auto max-w-6xl px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}/collections/${cid}`} 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-[.8fr_1.2fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ef4b2f]">New work</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><div className="mt-8 space-y-6"><Field label="作品标题 *"><input value={title} onChange={e=>setTitle(e.target.value)} placeholder="一句清晰的作品标题"/></Field><Field label="正文"><textarea rows={7} value={content} onChange={e=>setContent(e.target.value)} placeholder="输入作品正文,支持换行与 Emoji"/></Field><Field label="Tag"><input value={tags} onChange={e=>setTags(e.target.value)} placeholder="品牌, 七月内容, 待发布"/><p className="mt-2 text-[11px] text-black/35">使</p></Field></div></section>
<section><div onClick={()=>input.current?.click()} onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();add(e.dataTransfer.files)}} className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white p-8 text-center hover:border-black"><div><ImagePlus className="mx-auto"/><h2 className="mt-4 font-display text-2xl"></h2><p className="mt-2 text-xs text-black/40"> 30 · </p></div><input ref={input} type="file" accept="image/*" multiple className="hidden" onChange={e=>add(e.target.files)}/></div>
{items.length>0&&<div className="mt-5 grid grid-cols-3 gap-x-3 gap-y-5 sm:grid-cols-4">{items.map((it,i)=><div key={it.url} data-image-key={it.url}><div role="listitem" aria-label={`${i===0?'封面':`${i+1}`},拖动调整顺序`} onPointerDown={event=>startReorder(event,it.url)} onPointerMove={moveReorder} onPointerUp={finishReorder} onPointerCancel={finishReorder} className={`aspect-square touch-none select-none overflow-hidden rounded-xl bg-black/5 transition duration-200 ${dragging===it.url?'scale-[.97] cursor-grabbing opacity-70 ring-2 ring-[#ef4b2f]':'cursor-grab hover:scale-[.99]'}`}><img src={it.url} draggable={false} className="pointer-events-none h-full w-full object-cover"/></div><div className="mt-2 flex items-center justify-between gap-2 px-0.5"><span className="text-[10px] text-black/35">{i===0?'封面':`${i+1}`}</span><button type="button" onClick={()=>setItems(p=>p.filter((_,j)=>j!==i))} className="inline-flex items-center gap-1 text-[10px] text-black/35 transition hover:text-red-600"><X size={11}/></button></div></div>)}</div>}
{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-600">{error}</p>}<button onClick={submit} disabled={!can} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#ef4b2f] py-4 text-sm font-medium text-white disabled:opacity-30">{busy?<><Loader2 className="animate-spin" size={17}/></>:'创建并提交验收'}</button></section></div></main>
}
function Field({label,children}:{label:string;children:React.ReactNode}){return <label className="block text-xs font-medium text-black/50">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3">{children}</div></label>}