106 lines
8.8 KiB
TypeScript
106 lines
8.8 KiB
TypeScript
import sharp from 'sharp';
|
|
import type { Note, NoteDetail, ImageWithAnnotations, ReviewStatus } from '../../shared/types.js';
|
|
import { notesRepository } from '../repositories/notesRepository.js';
|
|
import { imagesRepository } from '../repositories/imagesRepository.js';
|
|
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
|
import { database, withTransaction } from '../database.js';
|
|
import { storeUploadedFile } from '../storage.js';
|
|
|
|
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
|
|
|
|
async function readImageSize(filePath: string): Promise<{ width: number; height: number }> {
|
|
try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; }
|
|
catch { return { width: 0, height: 0 }; }
|
|
}
|
|
|
|
async function prepareFiles(files: UploadedFile[]) {
|
|
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
|
|
}
|
|
|
|
export const notesService = {
|
|
async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number; externalId?: string }) {
|
|
return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId });
|
|
},
|
|
|
|
async getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> {
|
|
const current = await notesRepository.findById(id);
|
|
if (!current) return null;
|
|
const selectedVersion = requestedVersion && requestedVersion !== current.version_number
|
|
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, requestedVersion])
|
|
: undefined;
|
|
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
|
|
const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current;
|
|
const images = await imagesRepository.listByNote(id, note.version_number);
|
|
const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]);
|
|
if (!workContext) return null;
|
|
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>('SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]);
|
|
const result: NoteDetail = {
|
|
...note,
|
|
images: [] as ImageWithAnnotations[],
|
|
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
|
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]),
|
|
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
|
|
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]),
|
|
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
|
|
collection: { id: Number(workContext.collection_id), name: workContext.collection_name },
|
|
};
|
|
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
|
|
return result;
|
|
},
|
|
|
|
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
|
|
const prepared = await prepareFiles(files);
|
|
const noteId = await withTransaction(async (tx) => {
|
|
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
|
await imagesRepository.createMany(id, prepared, 1, tx);
|
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
|
return id;
|
|
});
|
|
return (await notesRepository.findById(noteId))!;
|
|
},
|
|
|
|
async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
|
|
const noteId = await withTransaction(async (tx) => {
|
|
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
|
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
|
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
|
return id;
|
|
});
|
|
return (await notesRepository.findById(noteId))!;
|
|
},
|
|
|
|
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
|
|
return notesRepository.findByExternalId(collectionId, externalId);
|
|
},
|
|
|
|
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
|
|
const current = await notesRepository.findById(id);
|
|
if (!current) throw new Error('作品不存在');
|
|
const nextVersion = current.version_number + 1;
|
|
const prepared = await prepareFiles(files);
|
|
await withTransaction(async (tx) => {
|
|
await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]);
|
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
|
await imagesRepository.createMany(id, prepared, nextVersion, tx);
|
|
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
|
|
});
|
|
return (await notesRepository.findById(id))!;
|
|
},
|
|
|
|
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
|
|
const current = await notesRepository.findById(id);
|
|
if (!current) throw new Error('作品不存在');
|
|
const nextVersion = current.version_number + 1;
|
|
await withTransaction(async (tx) => {
|
|
await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]);
|
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
|
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, tx);
|
|
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
|
|
});
|
|
return (await notesRepository.findById(id))!;
|
|
},
|
|
|
|
async remove(id: number) { return notesRepository.remove(id); },
|
|
async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
|
|
};
|