Files
delivery-desk/src/api/client.ts

91 lines
12 KiB
TypeScript
Raw Normal View History

import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, FeedbackReply, FeedbackType, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkComment, WorkFeedbackBundle } 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) }),
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}`);
},
listProjectWorks: (projectId: number, query: NoteListQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
return request<Note[]>(`/api/projects/${projectId}/works?${params}`);
},
getWork: (id: number, round?: number) => request<NoteDetail>(`/api/works/${id}${round ? `?round=${round}` : ''}`),
getWorkFeedback: (id: number) => request<WorkFeedbackBundle>(`/api/works/${id}/annotations`),
createWork: (projectId: 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/projects/${projectId}/works`, { method: 'POST', body: form });
},
createRound: (workId: 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/works/${workId}/rounds`, { 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) }),
addTextSelectionAnnotation: (workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request<TextAnnotation>(`/api/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
replyToFeedback: (workId: number, type: FeedbackType, feedbackId: number, content: string) => request<FeedbackReply>(`/api/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
withdrawFeedback: (workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }),
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' | 'review_status'>; works: Note[]; reviewer_name: string }>(`/api/review/${slug}/project`),
getCustomerWorkRound: (slug: string, workId: number, round?: number) => request<NoteDetail>(`/api/review/${slug}/works/${workId}${round ? `?round=${round}` : ''}`),
getCustomerWorkFeedback: (slug: string, workId: number) => request<WorkFeedbackBundle>(`/api/review/${slug}/works/${workId}/annotations`),
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) }),
addCustomerTextSelectionAnnotation: (slug: string, workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
replyToCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number, content: string) => request<FeedbackReply>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
withdrawCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }),
submitCustomerRoundDecision: (slug: string, workId: number, roundNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${workId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ round_number: roundNumber, decision, reason }) }),
};