feat(review): 重构项目级单方案验收协作

This commit is contained in:
yuzhe
2026-07-22 16:41:03 +08:00
parent 6091d61612
commit e7e268d4eb
45 changed files with 1830 additions and 812 deletions

View File

@@ -1,4 +1,4 @@
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkCollection, WorkComment } from '@shared/types';
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'; }
@@ -44,49 +44,47 @@ export const api = {
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 });
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}`);
},
createWorkVersion: (noteId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
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/notes/${noteId}/versions`, { method: 'POST', body: form });
return request<Note>(`/api/projects/${projectId}/works`, { method: 'POST', body: form });
},
createReviewRound: (noteId: number, candidates: Array<{ candidate_name: string; title: string; description: string; tags: string[]; images: File[] }>) => {
createRound: (workId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('candidates', JSON.stringify(candidates.map((candidate) => ({ ...candidate, images: undefined, image_count: candidate.images.length }))));
candidates.forEach((candidate) => candidate.images.forEach((file) => form.append('images', file)));
return request<Note>(`/api/notes/${noteId}/review-rounds`, { method: 'POST', body: form });
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) }),
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) }),
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'>; 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}` : ''}`),
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) }),
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, versionNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version_number: versionNumber, decision, reason }) }),
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 }) }),
};