feat(auth): 添加认证模块和图片批注功能(项目初始化)
- 实现用户登录、登出、密码修改等认证功能 - 添加会话管理和权限控制中间件 - 创建图片批注组件和相关API路由 - 实现批注的增删改查功能 - 添加Docker和Git忽略配置文件 - 创建系统架构文档和开发约定说明 - 集成认证模块到前端应用路由中
This commit is contained in:
48
src/App.tsx
Normal file
48
src/App.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import SiteHeader from '@/components/SiteHeader';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import ProjectPage from '@/pages/Project';
|
||||
import CollectionPage from '@/pages/Collection';
|
||||
import NoteDetailPage from '@/pages/NoteDetail';
|
||||
import UploadPage from '@/pages/Upload';
|
||||
import LoginPage from '@/pages/Login';
|
||||
import ProtectedRoute from '@/components/ProtectedRoute';
|
||||
import ChangePasswordPage from '@/pages/ChangePassword';
|
||||
import ManagementPage from '@/pages/Management';
|
||||
import CustomerReviewPage from '@/pages/CustomerReview';
|
||||
import NewVersionPage from '@/pages/NewVersion';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const location = useLocation();
|
||||
const customerView = location.pathname.startsWith('/review/');
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f7f6f2] text-[#171714]">
|
||||
{!customerView && <SiteHeader />}
|
||||
<Routes>
|
||||
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/change-password" element={<ProtectedRoute><ChangePasswordPage /></ProtectedRoute>} />
|
||||
<Route path="/management" element={<ProtectedRoute><ManagementPage /></ProtectedRoute>} />
|
||||
<Route path="/projects/:projectId" element={<ProtectedRoute><ProjectPage /></ProtectedRoute>} />
|
||||
<Route path="/projects/:projectId/collections/:collectionId" element={<ProtectedRoute><CollectionPage /></ProtectedRoute>} />
|
||||
<Route path="/works/:noteId" element={<ProtectedRoute><NoteDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/notes/:noteId" element={<ProtectedRoute><NoteDetailPage /></ProtectedRoute>} />
|
||||
<Route path="/review/:slug" element={<CustomerReviewPage />} />
|
||||
<Route path="/review/:slug/collections/:collectionId" element={<CustomerReviewPage />} />
|
||||
<Route path="/review/:slug/works/:noteId" element={<CustomerReviewPage />} />
|
||||
<Route path="/works/:noteId/new-version" element={<ProtectedRoute><NewVersionPage /></ProtectedRoute>} />
|
||||
<Route path="/projects/:projectId/collections/:collectionId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
|
||||
<Route path="/upload" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
src/api/client.ts
Normal file
86
src/api/client.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkCollection, WorkComment } from '@shared/types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; }
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) {
|
||||
let message = `请求失败 (${res.status})`;
|
||||
try { const data = await res.json(); message = data.error ?? data.message ?? message; } catch { /* noop */ }
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) => request<{ success: true }>('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }),
|
||||
logout: () => request<void>('/api/auth/logout', { method: 'POST' }),
|
||||
me: () => request<CurrentUser>('/api/auth/me'),
|
||||
changePassword: (currentPassword: string, newPassword: string) => request<{ success: true }>('/api/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) }),
|
||||
updateCurrentGroup: (name: string) => request<{ id: number; name: string }>('/api/groups/current', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }),
|
||||
listGroups: () => request<OperationGroup[]>('/api/management/groups'),
|
||||
createGroup: (data: { name: string; username: string; display_name: string; password: string }) => request<OperationGroup>('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
setGroupStatus: (id: number, status: 'active' | 'disabled') => request<{ success: true; status: string }>(`/api/management/groups/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
|
||||
updateGroupName: (id: number, name: string) => request<{ id: number; name: string }>(`/api/management/groups/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }),
|
||||
replaceGroupAdmin: (id: number, user_id: number, previous_action: 'demote' | 'disable') => request<{ success: true; previous_admin: string; next_admin: string }>(`/api/management/groups/${id}/replace-admin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id, previous_action }) }),
|
||||
listManagedUsers: (groupId?: number) => request<ManagedUser[]>(`/api/management/users${groupId ? `?groupId=${groupId}` : ''}`),
|
||||
createManagedUser: (data: { group_id?: number; username: string; display_name: string; password: string; role?: UserRole }) => request<ManagedUser>('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateManagedUserName: (id: number, display_name: string) => request<{ success: true; display_name: string }>(`/api/management/users/${id}/name`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name }) }),
|
||||
setUserStatus: (id: number, status: 'active' | 'disabled') => request<{ success: true; status: string }>(`/api/management/users/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
|
||||
resetUserPassword: (id: number, password: string) => request<{ success: true }>(`/api/management/users/${id}/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }),
|
||||
listApiKeys: () => request<ManagedApiKey[]>('/api/management/api-keys'),
|
||||
createApiKey: (data: { name: string; project_id?: number }) => request<{ token: string; item: ManagedApiKey }>('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
revokeApiKey: (id: number) => request<void>(`/api/management/api-keys/${id}`, { method: 'DELETE' }),
|
||||
listAuditLogs: (userId?: number) => request<AuditLogEntry[]>(`/api/management/audit-logs${userId ? `?userId=${userId}` : ''}`),
|
||||
listStorageConfigs: () => request<StorageConfig[]>('/api/management/storage-configs'),
|
||||
createStorageConfig: (data: { region: string; bucket: string; public_base_url: string; cdn_domain: string; path_prefix: string; secret_id: string; secret_key: string }) => request<StorageConfig>('/api/management/storage-configs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
testStorageConfig: (id: number) => request<{ success: true; test_status: 'passed'; test_message: string }>(`/api/management/storage-configs/${id}/test`, { method: 'POST' }),
|
||||
activateStorageConfig: (id: number) => request<{ success: true }>(`/api/management/storage-configs/${id}/activate`, { method: 'POST' }),
|
||||
listProjects: () => request<Project[]>('/api/projects'),
|
||||
getProject: (id: number) => request<Project>(`/api/projects/${id}`),
|
||||
createProject: (data: { name: string; slug: string; client_description: string; groupId?: number }) => request<Project>('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateProject: (id: number, data: { name: string; client_description: string }) => request<Project>(`/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateCustomerAccess: (id: number, data: { enabled: boolean; password?: string; expires_at?: string | null }) => request<Project>(`/api/projects/${id}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
listCollections: (projectId: number) => request<WorkCollection[]>(`/api/projects/${projectId}/collections`),
|
||||
createCollection: (projectId: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
updateCollection: (projectId: number, id: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
listNotes: (query: NoteListQuery = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
|
||||
return request<Note[]>(`/api/notes?${params}`);
|
||||
},
|
||||
getNote: (id: number, version?: number) => request<NoteDetail>(`/api/notes/${id}${version ? `?version=${version}` : ''}`),
|
||||
createNote: (payload: { collectionId: number; title: string; description: string; tags: string[]; images: File[] }) => {
|
||||
const form = new FormData();
|
||||
form.append('collectionId', String(payload.collectionId));
|
||||
form.append('title', payload.title);
|
||||
form.append('description', payload.description);
|
||||
form.append('tags', payload.tags.join(','));
|
||||
payload.images.forEach((file) => form.append('images', file));
|
||||
return request<Note>('/api/notes', { method: 'POST', body: form });
|
||||
},
|
||||
createWorkVersion: (noteId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
|
||||
const form = new FormData();
|
||||
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
|
||||
payload.images.forEach((file) => form.append('images', file));
|
||||
return request<Note>(`/api/notes/${noteId}/versions`, { method: 'POST', body: form });
|
||||
},
|
||||
setReviewStatus: (id: number, status: ReviewStatus) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
|
||||
reopenWork: (id: number, reason: string) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }),
|
||||
addAnnotation: (imageId: number, data: { x: number; y: number; content: string; author_name?: string }) => request<Annotation>(`/api/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
addTextAnnotation: (noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/notes/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
deleteAnnotation: (id: number) => request<void>(`/api/annotations/${id}`, { method: 'DELETE' }),
|
||||
addComment: (noteId: number, data: { content: string; author_name: string; author_role?: 'client' | 'operator' }) => request<WorkComment>(`/api/notes/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
getCustomerAccess: (slug: string) => request<CustomerAccessState>(`/api/review/${slug}/access`),
|
||||
customerLogin: (slug: string, data: { reviewer_name: string; password: string }) => request<{ success: true; reviewer_name: string }>(`/api/review/${slug}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string }>(`/api/review/${slug}/project`),
|
||||
getCustomerCollection: (slug: string, collectionId: number) => request<{ collection: WorkCollection; works: Note[] }>(`/api/review/${slug}/collections/${collectionId}/works`),
|
||||
getCustomerWork: (slug: string, noteId: number, version?: number) => request<NoteDetail>(`/api/review/${slug}/works/${noteId}${version ? `?version=${version}` : ''}`),
|
||||
addCustomerComment: (slug: string, noteId: number, content: string) => request<WorkComment>(`/api/review/${slug}/works/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
|
||||
addCustomerAnnotation: (slug: string, imageId: number, data: { x: number; y: number; content: string }) => request<Annotation>(`/api/review/${slug}/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
addCustomerTextAnnotation: (slug: string, noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
|
||||
submitCustomerDecision: (slug: string, noteId: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision, reason }) }),
|
||||
};
|
||||
13
src/components/AnnotatableImage.tsx
Normal file
13
src/components/AnnotatableImage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { Check, MessageCircle, X } from 'lucide-react';
|
||||
import type { Annotation, NoteImage } from '@shared/types';
|
||||
|
||||
export default function AnnotatableImage({image,annotations,onAdd}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>}){
|
||||
const ref=useRef<HTMLDivElement>(null);const [point,setPoint]=useState<{x:number;y:number}|null>(null);const [selectedId,setSelectedId]=useState<number|null>(null);const [text,setText]=useState('');
|
||||
const selected=annotations.find(annotation=>annotation.id===selectedId);
|
||||
const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(selectedId!==null){setSelectedId(null);return}const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')};
|
||||
const submit=async()=>{if(!point||!text.trim())return;await onAdd(point.x,point.y,text.trim());setPoint(null);setText('')};
|
||||
return <figure className="mb-5 break-inside-avoid"><div ref={ref} onClick={click} className="relative cursor-crosshair overflow-hidden rounded-2xl bg-[#e9e7e0]" style={{aspectRatio:image.width&&image.height?`${image.width}/${image.height}`:'4/3'}}><img src={image.url} className="h-full w-full object-contain" draggable={false}/>{annotations.map((annotation,index)=><button key={annotation.id} aria-label={`查看批注 ${index+1}`} onClick={event=>{event.stopPropagation();setPoint(null);setSelectedId(current=>current===annotation.id?null:annotation.id)}} className={`absolute z-10 grid h-7 w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-2 border-white text-[10px] font-bold text-white shadow-lg transition ${selectedId===annotation.id?'scale-110 bg-black':'bg-[#ef4b2f] hover:scale-110'}`} style={{left:`${annotation.x*100}%`,top:`${annotation.y*100}%`}}>{index+1}</button>)}
|
||||
{selected&&<div className="absolute z-30 w-64 max-w-[calc(100%-1rem)] rounded-2xl border border-black/10 bg-white p-4 shadow-2xl" style={{left:`${Math.min(.76,Math.max(.24,selected.x))*100}%`,top:`${selected.y*100}%`,transform:selected.y>.62?'translate(-50%, calc(-100% - 18px))':'translate(-50%, 18px)'}} onClick={event=>event.stopPropagation()}><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]">批注 {annotations.findIndex(annotation=>annotation.id===selected.id)+1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={()=>setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 transition hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40">批注用户 · <span className="font-medium text-black/65">{selected.author_name||'未知用户'}</span></div></div>}
|
||||
{point&&<div className="absolute z-20 w-64 -translate-x-1/2 rounded-2xl bg-white p-3 shadow-2xl" style={{left:`${Math.min(.78,Math.max(.22,point.x))*100}%`,top:`${Math.min(.7,point.y)*100}%`}} onClick={event=>event.stopPropagation()}><div className="mb-2 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1"><MessageCircle size={13}/>添加图片批注</span><button aria-label="取消添加批注" onClick={()=>setPoint(null)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={event=>setText(event.target.value)} className="w-full resize-none rounded-lg border border-black/10 p-2 text-xs" placeholder="描述需要调整的位置…"/><button onClick={submit} className="mt-2 flex w-full items-center justify-center gap-1 rounded-full bg-black py-2 text-xs text-white"><Check size={12}/>提交批注</button></div>}</div></figure>
|
||||
}
|
||||
28
src/components/AnnotatableText.tsx
Normal file
28
src/components/AnnotatableText.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react';
|
||||
import { Check, MessageCircle, Plus, X } from 'lucide-react';
|
||||
import type { TextAnnotation } from '@shared/types';
|
||||
|
||||
export default function AnnotatableText({ label, annotations, onAdd, children }: { label: string; annotations: TextAnnotation[]; onAdd: (content: string) => Promise<void>; children: React.ReactNode }) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const selected = annotations.find((annotation) => annotation.id === selectedId);
|
||||
|
||||
const submit = async () => {
|
||||
if (!text.trim() || busy) return;
|
||||
setBusy(true);
|
||||
try { await onAdd(text.trim()); setText(''); setAdding(false); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return <div className="relative">
|
||||
{children}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<button type="button" onClick={() => { setAdding(true); setSelectedId(null); }} className="inline-flex items-center gap-1.5 rounded-full border border-black/10 bg-white px-3 py-1.5 text-[11px] text-black/45 transition hover:border-black/25 hover:text-black"><Plus size={11}/>添加{label}批注</button>
|
||||
{annotations.map((annotation, index) => <button key={annotation.id} type="button" aria-label={`查看${label}批注 ${index + 1}`} onClick={() => { setAdding(false); setSelectedId((current) => current === annotation.id ? null : annotation.id); }} className={`grid h-7 min-w-7 place-items-center rounded-full px-2 text-[10px] font-semibold text-white transition ${selectedId === annotation.id ? 'bg-black' : 'bg-[#ef4b2f] hover:scale-105'}`}>{index + 1}</button>)}
|
||||
</div>
|
||||
{selected && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]">{label}批注 {annotations.findIndex((annotation) => annotation.id === selected.id) + 1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={() => setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40">批注用户 · <span className="font-medium text-black/65">{selected.author_name || '未知用户'}</span></div></div>}
|
||||
{adding && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="mb-3 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1.5"><MessageCircle size={13}/>添加{label}批注</span><button aria-label="取消添加批注" onClick={() => setAdding(false)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={(event) => setText(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 p-3 text-sm outline-none focus:border-[#ef4b2f]" placeholder={`填写针对${label}的修改意见…`}/><button disabled={busy || !text.trim()} onClick={() => void submit()} className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-full bg-black py-2.5 text-xs text-white disabled:opacity-30"><Check size={12}/>{busy ? '正在提交…' : '提交批注'}</button></div>}
|
||||
</div>;
|
||||
}
|
||||
117
src/components/AnnotationPanel.tsx
Normal file
117
src/components/AnnotationPanel.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Trash2, MessageCircle } from 'lucide-react';
|
||||
import type { ImageWithAnnotations } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AnnotationPanelProps {
|
||||
image: ImageWithAnnotations;
|
||||
highlightedId: number | null;
|
||||
onHighlight: (id: number | null) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
image,
|
||||
highlightedId,
|
||||
onHighlight,
|
||||
onDelete,
|
||||
}: AnnotationPanelProps) {
|
||||
return (
|
||||
<aside className="flex flex-col h-full bg-cream border-l border-stone-200">
|
||||
<div className="px-6 py-5 border-b border-stone-200">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-stone-500 chapter-prefix">
|
||||
Annotations
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline justify-between">
|
||||
<h3 className="font-display text-2xl tracking-tightest text-ink">
|
||||
标注索引
|
||||
</h3>
|
||||
<span className="font-mono text-xs text-stone-500">
|
||||
{image.annotations.length} 项
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-stone-500 leading-relaxed">
|
||||
在左侧大图上点击任意位置,即可添加新的标注点位。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto scroll-thin">
|
||||
{image.annotations.length === 0 ? (
|
||||
<div className="px-6 py-16 text-center">
|
||||
<div className="inline-flex items-center justify-center w-12 h-12 border border-stone-300 rounded-full mb-4">
|
||||
<MessageCircle className="w-5 h-5 text-stone-400" strokeWidth={1.2} />
|
||||
</div>
|
||||
<p className="font-display text-lg text-stone-600">尚无标注</p>
|
||||
<p className="mt-1 text-xs text-stone-400">
|
||||
点击图片任意位置开始注释
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-stone-200/70">
|
||||
{image.annotations.map((ann, i) => (
|
||||
<li
|
||||
key={ann.id}
|
||||
onMouseEnter={() => onHighlight(ann.id)}
|
||||
onMouseLeave={() => onHighlight(null)}
|
||||
onClick={() =>
|
||||
onHighlight(highlightedId === ann.id ? null : ann.id)
|
||||
}
|
||||
className={cn(
|
||||
'group px-6 py-4 cursor-pointer transition-colors',
|
||||
highlightedId === ann.id
|
||||
? 'bg-ochre/5'
|
||||
: 'hover:bg-stone-50',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full border-2 font-mono text-[10px] font-bold transition-colors',
|
||||
highlightedId === ann.id
|
||||
? 'bg-ochre border-ochre text-cream'
|
||||
: 'bg-cream border-ochre/60 text-ochre',
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-ink leading-relaxed whitespace-pre-wrap break-words">
|
||||
{ann.content}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 font-mono text-[9px] uppercase tracking-[0.15em] text-stone-400">
|
||||
<span>
|
||||
x: {ann.x.toFixed(3)}, y: {ann.y.toFixed(3)}
|
||||
</span>
|
||||
<span>
|
||||
{new Date(ann.created_at).toLocaleDateString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(ann.id);
|
||||
}}
|
||||
className="flex-shrink-0 text-stone-300 hover:text-ochre transition-colors p-1"
|
||||
aria-label="删除注释"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-stone-200 font-mono text-[9px] uppercase tracking-[0.2em] text-stone-400 flex items-center justify-between">
|
||||
<span>Hover to highlight</span>
|
||||
<span>Click № to focus</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
225
src/components/ImageViewer.tsx
Normal file
225
src/components/ImageViewer.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { useRef, useState, useCallback } from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import type { ImageWithAnnotations, Annotation } from '@shared/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ImageViewerProps {
|
||||
image: ImageWithAnnotations;
|
||||
index: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onAddAnnotation: (x: number, y: number, content: string) => void;
|
||||
onHighlightAnnotation: (id: number | null) => void;
|
||||
highlightedId: number | null;
|
||||
}
|
||||
|
||||
export default function ImageViewer({
|
||||
image,
|
||||
index,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
onAddAnnotation,
|
||||
onHighlightAnnotation,
|
||||
highlightedId,
|
||||
}: ImageViewerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [pending, setPending] = useState<{ x: number; y: number } | null>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
// 点击点位/弹窗时不创建新注释
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-pin]') || target.closest('[data-popup]')) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) return;
|
||||
setPending({ x, y });
|
||||
setDraft('');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const submitDraft = () => {
|
||||
if (!pending || !draft.trim()) return;
|
||||
onAddAnnotation(pending.x, pending.y, draft.trim());
|
||||
setPending(null);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const cancelDraft = () => {
|
||||
setPending(null);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* 顶部元信息 */}
|
||||
<div className="flex items-center justify-between mb-4 font-mono text-[10px] uppercase tracking-[0.25em] text-stone-500">
|
||||
<span className="chapter-prefix">Plate {String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}</span>
|
||||
<span>{image.width}×{image.height || '—'}</span>
|
||||
</div>
|
||||
|
||||
{/* 主图区 */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onClick={handleClick}
|
||||
className="relative bg-stone-100 overflow-hidden paper-edge-strong cursor-crosshair select-none"
|
||||
style={{ aspectRatio: image.width && image.height ? `${image.width} / ${image.height}` : '4 / 3' }}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt=""
|
||||
className="w-full h-full object-contain pointer-events-none"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* 已有注释点位 */}
|
||||
{image.annotations.map((ann, i) => (
|
||||
<AnnotationPin
|
||||
key={ann.id}
|
||||
annotation={ann}
|
||||
number={i + 1}
|
||||
highlighted={highlightedId === ann.id}
|
||||
onClick={() =>
|
||||
onHighlightAnnotation(highlightedId === ann.id ? null : ann.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 新建注释弹窗 */}
|
||||
{pending && (
|
||||
<div
|
||||
data-popup
|
||||
className="absolute z-30 -translate-x-1/2 -translate-y-[calc(100%+12px)]"
|
||||
style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="bg-cream paper-edge-strong border border-ink/10 w-72 p-4 animate-scale-in">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ochre">
|
||||
№ New Annotation
|
||||
</span>
|
||||
<button
|
||||
onClick={cancelDraft}
|
||||
className="text-stone-400 hover:text-ink"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitDraft();
|
||||
if (e.key === 'Escape') cancelDraft();
|
||||
}}
|
||||
placeholder="写下你对这一处的观察…"
|
||||
rows={3}
|
||||
className="w-full bg-transparent border border-stone-300 p-2 text-sm text-ink resize-none focus:outline-none focus:border-ochre"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="font-mono text-[9px] uppercase tracking-wider text-stone-400">
|
||||
⌘ + ⏎
|
||||
</span>
|
||||
<button
|
||||
onClick={submitDraft}
|
||||
disabled={!draft.trim()}
|
||||
className="inline-flex items-center gap-1.5 bg-ink text-cream px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.2em] disabled:opacity-30 hover:bg-ochre transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" strokeWidth={1.5} />
|
||||
Pin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 左右切换 */}
|
||||
{total > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={index === 0}
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 lg:-translate-x-6 w-10 h-10 flex items-center justify-center border border-ink/20 bg-cream/80 backdrop-blur hover:bg-ink hover:text-cream disabled:opacity-0 disabled:pointer-events-none transition-all"
|
||||
aria-label="上一张"
|
||||
>
|
||||
<span className="font-mono text-lg">←</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={index === total - 1}
|
||||
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 lg:translate-x-6 w-10 h-10 flex items-center justify-center border border-ink/20 bg-cream/80 backdrop-blur hover:bg-ink hover:text-cream disabled:opacity-0 disabled:pointer-events-none transition-all"
|
||||
aria-label="下一张"
|
||||
>
|
||||
<span className="font-mono text-lg">→</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AnnotationPinProps {
|
||||
annotation: Annotation;
|
||||
number: number;
|
||||
highlighted: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function AnnotationPin({ annotation, number, highlighted, onClick }: AnnotationPinProps) {
|
||||
return (
|
||||
<button
|
||||
data-pin
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className={cn(
|
||||
'absolute z-20 group/pin',
|
||||
'transition-transform duration-300',
|
||||
highlighted ? 'z-30 scale-125' : 'hover:scale-110',
|
||||
)}
|
||||
style={{
|
||||
left: `${annotation.x * 100}%`,
|
||||
top: `${annotation.y * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
aria-label={`注释 ${number}`}
|
||||
>
|
||||
{/* 脉冲圈 */}
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inset-0 rounded-full animate-pulse-soft',
|
||||
highlighted ? 'opacity-100' : 'opacity-60',
|
||||
)}
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(184, 65, 46, 0.3) 0%, transparent 70%)',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
left: '-6px',
|
||||
top: '-6px',
|
||||
}}
|
||||
/>
|
||||
{/* 编号点位 */}
|
||||
<span
|
||||
className={cn(
|
||||
'relative flex items-center justify-center w-6 h-6 rounded-full border-2 font-mono text-[10px] font-bold transition-colors',
|
||||
highlighted
|
||||
? 'bg-ochre border-ochre text-cream'
|
||||
: 'bg-cream border-ochre text-ochre group-hover/pin:bg-ochre group-hover/pin:text-cream',
|
||||
)}
|
||||
style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.25)' }}
|
||||
>
|
||||
{number}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
83
src/components/NoteCard.tsx
Normal file
83
src/components/NoteCard.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MessageCircle, ImageIcon } from 'lucide-react';
|
||||
import type { Note } from '@shared/types';
|
||||
|
||||
interface NoteCardProps {
|
||||
note: Note;
|
||||
index: number;
|
||||
}
|
||||
|
||||
export default function NoteCard({ note, index }: NoteCardProps) {
|
||||
const date = new Date(note.created_at);
|
||||
const dateLabel = `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
||||
const num = String(index + 1).padStart(3, '0');
|
||||
|
||||
// 给卡片轻微的纵向错落
|
||||
const offset = index % 3 === 0 ? 'lg:mt-12' : index % 3 === 2 ? 'lg:mt-6' : '';
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/notes/${note.id}`}
|
||||
className={`group block animate-fade-up ${offset}`}
|
||||
style={{ animationDelay: `${Math.min(index * 80, 600)}ms` }}
|
||||
>
|
||||
<article className="relative">
|
||||
{/* 编号标签 */}
|
||||
<div className="flex items-baseline justify-between mb-3 font-mono text-[10px] uppercase tracking-[0.2em] text-stone-500">
|
||||
<span>№ {num}</span>
|
||||
<span>{dateLabel}</span>
|
||||
</div>
|
||||
|
||||
{/* 封面图 */}
|
||||
<div className="relative overflow-hidden bg-stone-100 aspect-[4/5] paper-edge">
|
||||
{note.cover_image ? (
|
||||
<img
|
||||
src={note.cover_image}
|
||||
alt={note.title}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-stone-400">
|
||||
<ImageIcon className="w-8 h-8" strokeWidth={1} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片数徽章 */}
|
||||
{note.image_count > 1 && (
|
||||
<div className="absolute top-3 right-3 inline-flex items-center gap-1 bg-cream/90 backdrop-blur px-2 py-1 font-mono text-[10px] text-ink">
|
||||
<ImageIcon className="w-2.5 h-2.5" strokeWidth={1.5} />
|
||||
{note.image_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 注释数徽章 */}
|
||||
{note.annotation_count > 0 && (
|
||||
<div className="absolute bottom-3 left-3 inline-flex items-center gap-1 bg-ochre text-cream px-2 py-1 font-mono text-[10px]">
|
||||
<MessageCircle className="w-2.5 h-2.5" strokeWidth={1.5} />
|
||||
{note.annotation_count}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 悬停遮罩线 */}
|
||||
<div className="absolute inset-0 border border-ink/0 group-hover:border-ink/30 transition-colors duration-500 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* 标题 */}
|
||||
<h3 className="mt-4 font-display text-2xl leading-tight text-ink tracking-tightest group-hover:text-ochre transition-colors duration-300">
|
||||
{note.title}
|
||||
</h3>
|
||||
|
||||
{/* 描述 */}
|
||||
{note.description && (
|
||||
<p className="mt-2 text-sm text-stone-600 line-clamp-2 leading-relaxed">
|
||||
{note.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 底部细线 */}
|
||||
<div className="mt-4 h-px bg-stone-200 origin-left scale-x-100 group-hover:bg-ochre/40 transition-colors duration-500" />
|
||||
</article>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
13
src/components/ProtectedRoute.tsx
Normal file
13
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { user, initialized, initialize } = useAuthStore();
|
||||
const location = useLocation();
|
||||
useEffect(() => { if (!initialized) void initialize(); }, [initialized, initialize]);
|
||||
if (!initialized) return <div className="grid min-h-[70vh] place-items-center text-sm text-black/35">正在验证运营身份…</div>;
|
||||
if (!user) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||
if (user.must_change_password && location.pathname !== '/change-password') return <Navigate to="/change-password" replace />;
|
||||
return children;
|
||||
}
|
||||
26
src/components/SiteFooter.tsx
Normal file
26
src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
export default function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-stone-200/60 mt-32">
|
||||
<div className="mx-auto max-w-[1400px] px-6 lg:px-10 py-10">
|
||||
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
|
||||
<div>
|
||||
<div className="font-display text-3xl text-ink tracking-tightest leading-none">
|
||||
纸笺
|
||||
</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-stone-500 mt-2">
|
||||
№ End of Volume
|
||||
</div>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-stone-500 leading-relaxed">
|
||||
<div>图文笔记档案 · Annotated Visual Notes</div>
|
||||
<div className="mt-1">© {new Date().getFullYear()} Atelier Press · All entries archived locally.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-6 border-t border-stone-200/50 font-mono text-[10px] uppercase tracking-[0.2em] text-stone-400 flex items-center justify-between">
|
||||
<span>Set in Fraunces & Inter</span>
|
||||
<span>Bound by light & ink</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
32
src/components/SiteHeader.tsx
Normal file
32
src/components/SiteHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Aperture, LogIn, LogOut, Settings2, ShieldCheck } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
|
||||
const roleLabel = {
|
||||
platform_admin: '平台管理员',
|
||||
group_admin: '组管理员',
|
||||
operator: '光影叙事',
|
||||
} as const;
|
||||
|
||||
export default function SiteHeader() {
|
||||
const { user, initialized, initialize, logout } = useAuthStore();
|
||||
useEffect(() => { if (!initialized) void initialize(); }, [initialized, initialize]);
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-black/[.08] bg-[#faf9f5]/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-[72px] max-w-[1500px] items-center justify-between px-5 lg:px-10">
|
||||
<Link to="/" className="group flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-full bg-[#181817] text-[#f7c45c] shadow-sm transition-transform duration-500 group-hover:rotate-45"><Aperture size={17} strokeWidth={1.5}/></span>
|
||||
<div><div className="font-display text-[22px] font-medium leading-none tracking-[-.04em]">交付工作台</div><div className="mt-1.5 font-mono text-[9px] uppercase tracking-[.24em] text-black/40">Delivery · Review · Proof</div></div>
|
||||
</Link>
|
||||
{user ? <div className="flex items-center gap-2">
|
||||
{(user.role === 'platform_admin' || user.role === 'group_admin') && <Link to="/management" className="grid h-9 w-9 place-items-center rounded-full border border-black/[.09] bg-white text-black/45 transition hover:border-black/25 hover:text-black" title="管理与审计"><Settings2 size={14}/></Link>}
|
||||
<div className="flex items-center gap-2 rounded-full border border-black/[.09] bg-white px-3 py-2 text-xs text-black/60"><ShieldCheck size={14} className="text-[#b56a2d]"/><span className="hidden max-w-28 truncate lg:inline text-black/40">{user.group_name || '平台工作区'} ·</span><span className="hidden sm:inline">{user.display_name}</span><span className="rounded-full bg-[#f2eee7] px-2 py-0.5 text-[9px] text-black/55">{roleLabel[user.role]}</span></div>
|
||||
<button onClick={() => void logout()} className="grid h-9 w-9 place-items-center rounded-full border border-black/[.09] bg-white text-black/40 transition hover:border-black/25 hover:text-black" title="退出登录"><LogOut size={14}/></button>
|
||||
</div> : <Link to="/login" className="inline-flex items-center gap-2 rounded-full border border-black/[.12] bg-white px-4 py-2 text-xs text-black/60 transition hover:border-black hover:bg-black hover:text-white"><LogIn size={13}/>进入工作台</Link>}
|
||||
</div>
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-[#b56a2d]/70 to-transparent" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
11
src/components/StatusBadge.tsx
Normal file
11
src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ReviewStatus } from '@shared/types';
|
||||
|
||||
const labels: Record<ReviewStatus, string> = { draft: '草稿', pending: '待验收', changes_requested: '需修改', approved: '已通过' };
|
||||
const styles: Record<ReviewStatus, string> = {
|
||||
draft: 'bg-black/5 text-black/55', pending: 'bg-amber-100 text-amber-800',
|
||||
changes_requested: 'bg-red-100 text-red-700', approved: 'bg-emerald-100 text-emerald-700',
|
||||
};
|
||||
|
||||
export default function StatusBadge({ status }: { status: ReviewStatus }) {
|
||||
return <span className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${styles[status]}`}>{labels[status]}</span>;
|
||||
}
|
||||
80
src/components/StorageSettings.tsx
Normal file
80
src/components/StorageSettings.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react';
|
||||
import type { StorageConfig } from '@shared/types';
|
||||
import { api, ApiError } from '@/api/client';
|
||||
|
||||
const initialForm = {
|
||||
region: 'ap-guangzhou', bucket: '', public_base_url: '', cdn_domain: '',
|
||||
path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
|
||||
};
|
||||
|
||||
export default function StorageSettings() {
|
||||
const [items, setItems] = useState<StorageConfig[]>([]);
|
||||
const [form, setForm] = useState(initialForm);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
const [notice, setNotice] = useState('');
|
||||
const active = useMemo(() => items.find((item) => item.status === 'active'), [items]);
|
||||
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setItems(await api.listStorageConfigs()); }
|
||||
catch (reason) { setError(messageOf(reason)); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { void load(); }, []);
|
||||
|
||||
const save = async () => {
|
||||
setBusy('save'); setError(''); setNotice('');
|
||||
try {
|
||||
await api.createStorageConfig(form);
|
||||
setForm(initialForm);
|
||||
setNotice('配置草稿已加密保存,请执行连接测试。');
|
||||
await load();
|
||||
} catch (reason) { setError(messageOf(reason)); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
const test = async (id: number) => {
|
||||
setBusy('test'); setError(''); setNotice('');
|
||||
try {
|
||||
const result = await api.testStorageConfig(id);
|
||||
setNotice(result.test_message);
|
||||
await load();
|
||||
} catch (reason) { setError(messageOf(reason)); await load(); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
const activate = async (id: number) => {
|
||||
setBusy('activate'); setError(''); setNotice('');
|
||||
try {
|
||||
await api.activateStorageConfig(id);
|
||||
setNotice('新存储桶已启用,后续上传将写入该配置。');
|
||||
await load();
|
||||
} catch (reason) { setError(messageOf(reason)); }
|
||||
finally { setBusy(null); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="grid gap-3 py-8">{[1, 2].map((item) => <div key={item} className="h-28 animate-pulse rounded-2xl bg-black/5"/>)}</div>;
|
||||
|
||||
return <section className="py-8">
|
||||
<div className="mb-7"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#d15f37]">Infrastructure / Object Storage</p><h2 className="mt-2 font-display text-4xl tracking-[-.04em]">腾讯云 COS</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-black/45">先保存为草稿,再执行上传、读取和删除测试。只有测试通过的配置可以成为正式存储。</p></div>
|
||||
{error && <div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
|
||||
{notice && <div className="mb-5 flex items-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800"><CheckCircle2 size={15}/>{notice}</div>}
|
||||
|
||||
<div className="grid gap-5 xl:grid-cols-[.85fr_1.15fr]">
|
||||
<div className="space-y-5">
|
||||
<ConfigCard title="当前生效" icon={<Cloud size={18}/>} item={active}/>
|
||||
{draft && <div className="rounded-[24px] border border-[#d15f37]/25 bg-[#fff8f3] p-5"><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.24em] text-[#d15f37]">Pending configuration</p><h3 className="mt-2 font-display text-2xl">{draft.bucket}</h3><p className="mt-1 text-xs text-black/40">{draft.region} · {draft.path_prefix || '根目录'}</p></div><Status status={draft.test_status}/></div><div className="mt-5 grid grid-cols-2 gap-3"><button disabled={busy !== null} onClick={() => void test(draft.id)} className="inline-flex items-center justify-center gap-2 rounded-full border border-black/15 bg-white py-3 text-sm disabled:opacity-40">{busy === 'test' ? <Loader2 size={14} className="animate-spin"/> : <RadioTower size={14}/>}测试连接</button><button disabled={busy !== null || draft.test_status !== 'passed'} onClick={() => void activate(draft.id)} className="inline-flex items-center justify-center gap-2 rounded-full bg-[#171714] py-3 text-sm text-white disabled:opacity-25">{busy === 'activate' ? <Loader2 size={14} className="animate-spin"/> : <ShieldCheck size={14}/>}启用配置</button></div>{draft.test_message && <p className={`mt-3 text-xs leading-5 ${draft.test_status === 'failed' ? 'text-red-600' : 'text-emerald-700'}`}>{draft.test_message}</p>}</div>}
|
||||
<div className="rounded-[24px] border border-black/[.08] bg-[#171714] p-5 text-white"><LockKeyhole size={18} className="text-[#f2b27e]"/><h3 className="mt-4 font-display text-2xl">凭证不会返回浏览器</h3><p className="mt-2 text-xs leading-6 text-white/45">SecretId 与 SecretKey 使用 AES-256-GCM 加密保存。页面只记录“已配置”,无法读取完整密钥。</p></div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[28px] border border-black/[.08] bg-white p-5 sm:p-7"><div className="flex items-center gap-3"><div className="grid h-10 w-10 place-items-center rounded-full bg-[#f1eee7]"><Database size={17}/></div><div><h3 className="font-display text-2xl">新建配置草稿</h3><p className="text-xs text-black/35">保存不会立即影响现有上传</p></div></div><div className="mt-7 grid gap-5 sm:grid-cols-2"><Field label="地域 Region"><input value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="ap-guangzhou"/></Field><Field label="存储桶 Bucket"><input value={form.bucket} onChange={(e) => setForm({ ...form, bucket: e.target.value })} placeholder="bucket-name-1250000000"/></Field><Field label="SecretId"><input autoComplete="off" value={form.secret_id} onChange={(e) => setForm({ ...form, secret_id: e.target.value })}/></Field><Field label="SecretKey"><input autoComplete="new-password" type="password" value={form.secret_key} onChange={(e) => setForm({ ...form, secret_key: e.target.value })}/></Field><Field label="公共访问域名"><input value={form.public_base_url} onChange={(e) => setForm({ ...form, public_base_url: e.target.value })} placeholder="https://bucket.cos.region.myqcloud.com"/></Field><Field label="CDN 域名(可选)"><input value={form.cdn_domain} onChange={(e) => setForm({ ...form, cdn_domain: e.target.value })} placeholder="https://cdn.example.com"/></Field><div className="sm:col-span-2"><Field label="文件路径前缀"><input value={form.path_prefix} onChange={(e) => setForm({ ...form, path_prefix: e.target.value })} placeholder="delivery-desk"/></Field></div></div><button disabled={busy !== null || !form.region || !form.bucket || !form.secret_id || !form.secret_key} onClick={() => void save()} className="mt-7 inline-flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-3.5 text-sm text-white transition hover:bg-[#d15f37] disabled:opacity-30">{busy === 'save' ? <Loader2 size={15} className="animate-spin"/> : <LockKeyhole size={15}/>}加密保存为草稿</button></div>
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ConfigCard({ title, icon, item }: { title: string; icon: React.ReactNode; item?: StorageConfig }) { return <div className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center gap-2 text-black/45">{icon}<span className="font-mono text-[9px] uppercase tracking-[.22em]">{title}</span></div>{item ? <><div className="mt-5 flex items-start justify-between gap-3"><div><h3 className="font-display text-2xl">{item.bucket}</h3><p className="mt-1 text-xs text-black/40">{item.region} · {item.path_prefix || '根目录'}</p></div><span className="rounded-full bg-emerald-50 px-2.5 py-1 text-[9px] text-emerald-700">ACTIVE</span></div><p className="mt-4 break-all text-xs leading-5 text-black/40">{item.cdn_domain || item.public_base_url || '使用 COS 默认访问域名'}</p></> : <div className="py-8 text-center text-sm text-black/30">当前仍使用本地 uploads 存储</div>}</div>; }
|
||||
function Status({ status }: { status: StorageConfig['test_status'] }) { const map = { untested: ['未测试', 'bg-black/5 text-black/40'], passed: ['已通过', 'bg-emerald-100 text-emerald-700'], failed: ['未通过', 'bg-red-100 text-red-700'] } as const; return <span className={`rounded-full px-2.5 py-1 text-[9px] ${map[status][1]}`}>{map[status][0]}</span>; }
|
||||
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-[#faf9f5] [&_input]:p-3 [&_input]:outline-none [&_input]:transition [&_input]:focus:border-[#d15f37]">{children}</div></label>; }
|
||||
function messageOf(reason: unknown) { return reason instanceof ApiError || reason instanceof Error ? reason.message : '操作失败'; }
|
||||
96
src/index.css
Normal file
96
src/index.css
Normal file
@@ -0,0 +1,96 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: 'Noto Sans SC', 'Inter', system-ui, sans-serif;
|
||||
line-height: 1.55;
|
||||
font-weight: 400;
|
||||
color: #1A1A1A;
|
||||
background-color: #faf9f5;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: #faf9f5;
|
||||
/* 纸张噪点纹理叠加 */
|
||||
background-image:
|
||||
radial-gradient(circle at 25% 30%, rgba(184, 65, 46, 0.04) 0%, transparent 35%),
|
||||
radial-gradient(circle at 75% 70%, rgba(92, 107, 90, 0.04) 0%, transparent 40%),
|
||||
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0.12 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: rgba(184, 65, 46, 0.18);
|
||||
color: #1A1A1A;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 数字编号风格的章节前缀 */
|
||||
.chapter-prefix::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1.5rem;
|
||||
height: 1px;
|
||||
background-color: currentColor;
|
||||
vertical-align: middle;
|
||||
margin-right: 0.75rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.paper-edge {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(26, 26, 26, 0.04),
|
||||
0 8px 24px -12px rgba(26, 26, 26, 0.12);
|
||||
}
|
||||
|
||||
.paper-edge-strong {
|
||||
box-shadow:
|
||||
0 1px 0 rgba(26, 26, 26, 0.06),
|
||||
0 20px 50px -20px rgba(26, 26, 26, 0.25);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.scroll-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
.scroll-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scroll-thin::-webkit-scrollbar-thumb {
|
||||
background: rgba(26, 26, 26, 0.15);
|
||||
border-radius: 0;
|
||||
}
|
||||
.scroll-thin::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(26, 26, 26, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局滚动条 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #F0EBE0;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #C9BCA3;
|
||||
border: 2px solid #F0EBE0;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #A8997A;
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
11
src/pages/ChangePassword.tsx
Normal file
11
src/pages/ChangePassword.tsx
Normal 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
21
src/pages/Collection.tsx
Normal 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>
|
||||
}
|
||||
81
src/pages/CustomerReview.tsx
Normal file
81
src/pages/CustomerReview.tsx
Normal 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
34
src/pages/Dashboard.tsx
Normal 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
183
src/pages/Gallery.tsx
Normal 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
50
src/pages/Login.tsx
Normal 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
170
src/pages/Management.tsx
Normal 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="2–40 个字符"><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
19
src/pages/NewVersion.tsx
Normal 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">1–30 张,选择顺序即展示顺序</small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{items.map((item,index)=><div key={item.url} className="group relative overflow-hidden rounded-2xl border border-black/10 bg-white"><img src={item.url} alt="" className="aspect-[4/5] w-full object-cover"/><div className="absolute inset-x-2 bottom-2 flex justify-between"><button onClick={()=>move(index,-1)} className="rounded-full bg-white/90 p-2 disabled:opacity-30" disabled={index===0}><ArrowUp size={13}/></button><button onClick={()=>setItems((current)=>current.filter((_,i)=>i!==index))} className="rounded-full bg-white/90 p-2"><X size={13}/></button></div>{index===0&&<span className="absolute left-2 top-2 rounded-full bg-black px-2 py-1 text-[9px] text-white">封面</span>}</div>)}</div></section></div></main>;
|
||||
}
|
||||
65
src/pages/NoteDetail.tsx
Normal file
65
src/pages/NoteDetail.tsx
Normal 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
64
src/pages/Project.tsx
Normal 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
19
src/pages/Upload.tsx
Normal 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>}
|
||||
26
src/store/useAuthStore.ts
Normal file
26
src/store/useAuthStore.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { create } from 'zustand';
|
||||
import type { CurrentUser } from '@shared/types';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
interface AuthState {
|
||||
user: CurrentUser | null;
|
||||
loading: boolean;
|
||||
initialized: boolean;
|
||||
initialize: () => Promise<void>;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null, loading: false, initialized: false,
|
||||
initialize: async () => {
|
||||
try { set({ user: await api.me(), initialized: true }); }
|
||||
catch { set({ user: null, initialized: true }); }
|
||||
},
|
||||
login: async (username, password) => {
|
||||
set({ loading: true });
|
||||
try { await api.login(username, password); set({ user: await api.me(), loading: false, initialized: true }); }
|
||||
catch (error) { set({ loading: false }); throw error; }
|
||||
},
|
||||
logout: async () => { await api.logout(); set({ user: null }); },
|
||||
}));
|
||||
42
src/store/useNotesStore.ts
Normal file
42
src/store/useNotesStore.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Note, NoteListQuery } from '@shared/types';
|
||||
import { api } from '../api/client';
|
||||
|
||||
interface NotesState {
|
||||
notes: Note[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
query: NoteListQuery;
|
||||
fetchNotes: (query?: NoteListQuery) => Promise<void>;
|
||||
setQuery: (q: Partial<NoteListQuery>) => void;
|
||||
removeNote: (id: number) => void;
|
||||
}
|
||||
|
||||
export const useNotesStore = create<NotesState>((set, get) => ({
|
||||
notes: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
query: { sort: 'created_at', order: 'desc' },
|
||||
|
||||
fetchNotes: async (query) => {
|
||||
const nextQuery = query ?? get().query;
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const notes = await api.listNotes(nextQuery);
|
||||
set({ notes, loading: false, query: nextQuery });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '加载失败';
|
||||
set({ loading: false, error: message });
|
||||
}
|
||||
},
|
||||
|
||||
setQuery: (q) => {
|
||||
const next = { ...get().query, ...q };
|
||||
set({ query: next });
|
||||
get().fetchNotes(next);
|
||||
},
|
||||
|
||||
removeNote: (id) => {
|
||||
set((s) => ({ notes: s.notes.filter((n) => n.id !== id) }));
|
||||
},
|
||||
}));
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user