feat(review): 支持多候选稿验收轮次

- 支持每轮提交 1–5 个候选稿并按指定稿验收
- 保留历史轮次只读并兼容单候选稿版本接口
- 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
This commit is contained in:
yuzhe
2026-07-21 20:25:52 +08:00
parent 721e971dd8
commit 6091d61612
26 changed files with 586 additions and 97 deletions

View File

@@ -1,7 +1,7 @@
# Delivery Desk 开发约定
- 包管理器使用 pnpm提交前运行 `pnpm check``pnpm lint``pnpm build``pnpm test:postgres-runtime`
- 业务术语统一为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。`collections` 只是内部数据库与路由标识,用户界面和文档不再称“作品集”“阶段任务”。
- 包管理器使用 pnpm提交前运行 `pnpm check``pnpm lint``pnpm build``pnpm test:review-rounds``pnpm test:collection-status``pnpm test:postgres-runtime``pnpm db:postgres:validate`
- 业务术语统一为“运营组 → 项目 → 作品交付集 → 作品 → 验收轮次 → 候选稿”。`collections``work_versions` 只是内部数据库与路由标识,用户界面和文档不再称“作品集”“阶段任务”或把候选稿称为版本
- 数据库结构变更必须同时更新 `api/db.ts``db/postgres/schema.sql` 和相关迁移验证。
- `data/``uploads/``.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入,不写入源码。

View File

@@ -1,11 +1,11 @@
# 交付工作台Delivery Desk
面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收。
面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 验收轮次 → 候选稿”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收。
## 当前能力
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
- 项目、作品交付集、作品和作品版本管理
- 项目、作品交付集、作品和多候选稿验收轮次管理
- 多图上传、封面预览、图片排序和腾讯云 COS 存储
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
- 客户项目密码、访问期限和独立验收入口
@@ -88,5 +88,8 @@ docker compose up -d --build
pnpm check
pnpm lint
pnpm build
pnpm test:review-rounds
pnpm test:collection-status
pnpm test:postgres-runtime
pnpm db:postgres:validate
```

View File

@@ -28,7 +28,8 @@ if (databaseUrl) {
if (databaseUrl === 'pg-mem://') {
schema = schema
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '');
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '')
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '');
}
await pool.query(schema);
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);

View File

@@ -174,6 +174,19 @@ db.exec(`
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS review_rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
round_number INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'reviewing',
selected_version_number INTEGER,
created_by INTEGER,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(note_id, round_number),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS review_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
@@ -202,6 +215,8 @@ addColumn('notes', 'external_id TEXT');
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('notes', 'active_round_id INTEGER');
addColumn('notes', 'approved_version_number INTEGER');
addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'");
addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'");
addColumn('projects', 'group_id INTEGER');
@@ -213,6 +228,9 @@ addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('users', 'last_login_at TEXT');
addColumn('collections', 'completed_at TEXT');
addColumn('work_versions', 'review_round_id INTEGER');
addColumn('work_versions', "candidate_name TEXT NOT NULL DEFAULT '方案 A'");
addColumn('work_versions', "candidate_status TEXT NOT NULL DEFAULT 'pending'");
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''");
@@ -256,6 +274,32 @@ db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_descrip
WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run();
db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status)
SELECT id, version_number, title, description, tags, review_status FROM notes`).run();
db.exec(`
INSERT OR IGNORE INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at)
SELECT v.note_id, v.version_number,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END,
CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END,
v.created_at
FROM work_versions v JOIN notes n ON n.id = v.note_id;
UPDATE work_versions
SET review_round_id = (SELECT r.id FROM review_rounds r WHERE r.note_id = work_versions.note_id AND r.round_number = work_versions.version_number),
candidate_name = COALESCE(NULLIF(candidate_name, ''), '方案 A'),
candidate_status = CASE
WHEN review_status = 'approved' THEN 'selected'
WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'changes_requested' THEN 'changes_requested'
WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'pending' THEN 'pending'
WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'draft' THEN 'draft'
ELSE 'not_selected'
END
WHERE review_round_id IS NULL;
UPDATE notes
SET active_round_id = (SELECT r.id FROM review_rounds r WHERE r.note_id = notes.id AND r.round_number = notes.version_number),
approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = notes.id AND v.review_status = 'approved')
WHERE active_round_id IS NULL;
`);
db.exec(`
UPDATE collections
SET status = CASE
@@ -288,6 +332,7 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs(status);
CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_review_rounds_note_id ON review_rounds(note_id, round_number);
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number);
`);

View File

@@ -3,7 +3,7 @@ import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js';
interface NoteRow {
id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: string;
review_status: ReviewStatus; version_number: number; created_at: string;
review_status: ReviewStatus; version_number: number; active_round_id: number | null; approved_version_number: number | null; created_at: string;
image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null;
}
@@ -11,6 +11,7 @@ function toNote(row: NoteRow): Note {
return {
...row,
id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number),
active_round_id: row.active_round_id == null ? null : Number(row.active_round_id), approved_version_number: row.approved_version_number == null ? null : Number(row.approved_version_number),
image_count: Number(row.image_count), annotation_count: Number(row.annotation_count), comment_count: Number(row.comment_count),
tags: JSON.parse(row.tags || '[]') as string[],
cover_image: row.cover_url ? (/^https?:\/\//.test(row.cover_url) || row.cover_url.startsWith('/') ? row.cover_url : `/uploads/${row.cover_url}`) : '',
@@ -18,7 +19,7 @@ function toNote(row: NoteRow): Note {
}
const select = `
SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at,
SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.active_round_id, n.approved_version_number, n.created_at,
COALESCE(ic.image_count, 0) AS image_count,
COALESCE(ac.annotation_count, 0) AS annotation_count,
COALESCE(cc.comment_count, 0) AS comment_count,

View File

@@ -26,8 +26,9 @@ router.post('/:imageId/annotations', requireWriter, async (req: AuthRequest, res
const imageId = Number(req.params.imageId);
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); return; }
const projectId = await imageProjectId(imageId);
if (!projectId || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权操作该图片' }); return; }
const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; collection_status: string }>('SELECT c.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,c.status AS collection_status FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=?', [imageId]);
if (!context || !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该图片' }); return; }
if (context.collection_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史验收轮次为只读状态' }); return; }
const { x, y } = req.body ?? {};
const content = String(req.body?.content ?? '').trim();
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }

View File

@@ -30,6 +30,15 @@ function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } {
return { valid, urls };
}
type CandidateBody = { candidate_name?: unknown; title?: unknown; description?: unknown; tags?: unknown; images?: unknown; image_count?: unknown };
function parseCandidates(value: unknown): CandidateBody[] | null {
try {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
return Array.isArray(parsed) ? parsed as CandidateBody[] : null;
} catch { return null; }
}
// GET /api/notes - 笔记列表
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
const { sort, order, q, collectionId, status, tag, externalId } = req.query as {
@@ -76,9 +85,10 @@ router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest,
const content = String(req.body?.content ?? '').trim();
if (!Number.isFinite(noteId) || !Number.isFinite(versionNumber) || !['title', 'description'].includes(target)) { res.status(400).json({ error: '批注目标无效' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; collection_status: string }>('SELECT c.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,c.status AS collection_status FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
if (!context) { res.status(404).json({ error: '作品版本不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权批注该作品' }); return; }
if (context.collection_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史验收轮次为只读状态' }); return; }
const id = await database.insertId('INSERT INTO text_annotations (note_id, version_number, target, content, author_name) VALUES (?, ?, ?, ?, ?)', [noteId, versionNumber, target, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { noteId, versionNumber, target });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
@@ -175,6 +185,50 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
});
router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanupFiles = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const id = Number(req.params.noteId);
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=?', [id]);
if (!context) { cleanupFiles(); res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { cleanupFiles(); res.status(403).json({ error: '无权操作该作品' }); return; }
const rawCandidates = parseCandidates(req.body?.candidates);
if (!rawCandidates || rawCandidates.length < 1 || rawCandidates.length > 5) { cleanupFiles(); res.status(400).json({ error: '每轮需要提交 15 个候选稿' }); return; }
const normalized = rawCandidates.map((candidate, index) => ({
candidate_name: String(candidate.candidate_name ?? `方案 ${String.fromCharCode(65 + index)}`).trim(),
title: String(candidate.title ?? '').trim(),
description: String(candidate.description ?? '').trim(),
tags: parseTags(candidate.tags),
image_count: Number(candidate.image_count ?? 0),
imageUrls: parseImageUrls(candidate.images),
}));
if (normalized.some((candidate) => !candidate.candidate_name || candidate.candidate_name.length > 30 || !candidate.title)) { cleanupFiles(); res.status(400).json({ error: '候选稿名称须为 130 个字符,标题不能为空' }); return; }
let note;
if (files.length) {
const expected = normalized.reduce((sum, candidate) => sum + candidate.image_count, 0);
if (expected !== files.length || normalized.some((candidate) => candidate.image_count < 1 || candidate.image_count > 30)) { cleanupFiles(); res.status(400).json({ error: '候选稿图片数量与上传文件不一致' }); return; }
let offset = 0;
const uploadCandidates = normalized.map((candidate) => {
const candidateFiles = files.slice(offset, offset + candidate.image_count); offset += candidate.image_count;
return { ...candidate, files: candidateFiles.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) };
});
note = await notesService.createReviewRound(id, uploadCandidates, req.authUser?.id);
} else {
const totalImages = normalized.reduce((sum, candidate) => sum + candidate.imageUrls.urls.length, 0);
if (totalImages > 30 || normalized.some((candidate) => !candidate.imageUrls.valid || candidate.imageUrls.urls.length < 1)) { cleanupFiles(); res.status(400).json({ error: '每个候选稿至少需要 1 个有效公开图片 URL本轮总计不超过 30 张' }); return; }
note = await notesService.createReviewRoundFromUrls(id, normalized.map((candidate) => ({ ...candidate, images: candidate.imageUrls.urls })), req.authUser?.id);
}
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: normalized.length, versionNumber: note.version_number });
res.status(201).json(note);
} catch (error) {
cleanupFiles();
next(error);
}
});
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
const status = req.body?.status;
@@ -196,14 +250,16 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
const id = Number(req.params.noteId);
const reason = String(req.body?.reason ?? '').trim();
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number }>('SELECT n.review_status, n.version_number, n.collection_id, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number; active_round_id: number | null }>('SELECT n.review_status, n.version_number, n.collection_id, n.active_round_id, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
const actor = req.authUser!;
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
await tx.execute("UPDATE notes SET approved_version_number = NULL WHERE id = ?", [id]);
await tx.execute("UPDATE work_versions SET review_status = 'pending', candidate_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
if (note.active_round_id) await tx.execute("UPDATE review_rounds SET status = 'reviewing', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [note.active_round_id]);
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]);
await recalculateCollectionStatus(Number(note.collection_id), tx);
});

View File

@@ -1,10 +1,10 @@
import { Router, type Response } from 'express';
import { database, withTransaction } from '../database.js';
import { database } from '../database.js';
import { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js';
import { verifyPassword } from '../auth.js';
import { notesService } from '../services/notesService.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { recalculateCollectionStatus } from '../services/collectionsService.js';
import { decideCandidate, ReviewDecisionError } from '../services/reviewService.js';
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
const router=Router();router.use(optionalCustomer);
@@ -22,12 +22,12 @@ router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async
router.get('/:slug/works/:noteId',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const belongs=await database.one<{review_status:string}>('SELECT n.review_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=?',[noteId,project.id]);if(!belongs||belongs.review_status==='draft'){res.status(404).json({error:'作品不存在或尚未提交'});return}const version=req.query.version?Number(req.query.version):undefined;const detail=await notesService.getDetail(noteId,version);if(!detail){res.status(404).json({error:'作品版本不存在'});return}res.json(detail)});
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string}>('SELECT c.status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(belongs.status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 12000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string;round_status:string}>('SELECT c.status,r.status AS round_status FROM notes n JOIN collections c ON c.id=n.collection_id LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'){res.status(409).json({error:'当前验收轮次为只读状态'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 12000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}const belongs=await database.one<{status:string}>('SELECT c.status FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}if(belongs.status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}const belongs=await database.one<{status:string;round_status:string;review_round_id:number;active_round_id:number|null}>('SELECT c.status,r.status AS round_status,v.review_round_id,n.active_round_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'||Number(belongs.review_round_id)!==Number(belongs.active_round_id)){res.status(409).json({error:'历史验收轮次为只读状态'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string}>(`SELECT c.status FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(belongs.status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string;round_status:string;review_round_id:number;active_round_id:number|null}>(`SELECT c.status,r.status AS round_status,v.review_round_id,n.active_round_id FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'||Number(belongs.review_round_id)!==Number(belongs.active_round_id)){res.status(409).json({error:'历史验收轮次为只读状态'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string;collection_id:number;collection_status:string}>(`SELECT n.version_number,n.review_status,n.collection_id,c.status AS collection_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND n.review_status!='draft' AND c.project_id=?`,[noteId,project.id]);if(!current){res.status(404).json({error:'作品不存在或尚未提交'});return}if(current.collection_status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE notes SET review_status=? WHERE id=?',[decision,noteId]);await tx.execute('UPDATE work_versions SET review_status=? WHERE note_id=? AND version_number=?',[decision,noteId,current.version_number]);await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)',[noteId,current.version_number,decision,current.review_status,decision,reason,req.customer!.reviewer_name,'client']);if(reason)await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,reason,req.customer!.reviewer_name]);await recalculateCollectionStatus(Number(current.collection_id),tx)});res.json({success:true,status:decision})});
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!Number.isInteger(versionNumber)||versionNumber<1){res.status(400).json({error:'验收决定必须明确指定候选稿版本'});return}if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(reason.length>2000){res.status(400).json({error:'验收原因不能超过 2000 个字符'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}try{res.json(await decideCandidate({noteId,versionNumber,projectId:Number(project.id),decision,reason,actorName:req.customer!.reviewer_name,actorRole:'client'}))}catch(error){if(error instanceof ReviewDecisionError){res.status(error.statusCode).json({error:error.message});return}throw error}});
export default router;

View File

@@ -1,23 +1,90 @@
import sharp from 'sharp';
import type { Note, NoteDetail, ImageWithAnnotations, ReviewStatus } from '../../shared/types.js';
import type { ImageWithAnnotations, Note, NoteDetail, 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 { database, databaseDialect, withTransaction, type QueryContext } from '../database.js';
import { storeUploadedFile } from '../storage.js';
import { recalculateCollectionStatus } from './collectionsService.js';
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
export interface UploadCandidate { candidate_name: string; title: string; description: string; tags: string[]; files: UploadedFile[] }
export interface UrlCandidate { candidate_name: string; title: string; description: string; tags: string[]; images: string[] }
type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string };
type PreparedCandidate = { candidate_name: string; title: string; description: string; tags: string[]; images: StoredImage[] };
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[]) {
async function prepareFiles(files: UploadedFile[]): Promise<StoredImage[]> {
return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) })));
}
function externalImages(images: string[]): StoredImage[] {
return images.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external', storageKey: '' }));
}
async function createRoundInTransaction(
tx: QueryContext,
noteId: number,
collectionId: number,
candidates: PreparedCandidate[],
createdBy: number | undefined,
fromStatus: ReviewStatus,
): Promise<{ roundId: number; roundNumber: number; firstVersion: number }> {
const note = await tx.one<{ active_round_id: number | null }>('SELECT active_round_id FROM notes WHERE id = ?' + (databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''), [noteId]);
if (note?.active_round_id) {
await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND candidate_status = 'pending'", [note.active_round_id]);
await tx.execute("UPDATE review_rounds SET status = 'completed', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status = 'reviewing'", [new Date().toISOString(), note.active_round_id]);
}
const maxima = await tx.one<{ max_version: number | string | null; max_round: number | string | null }>(
`SELECT (SELECT MAX(version_number) FROM work_versions WHERE note_id = ?) AS max_version,
(SELECT MAX(round_number) FROM review_rounds WHERE note_id = ?) AS max_round`,
[noteId, noteId],
);
const firstVersion = Number(maxima?.max_version ?? 0) + 1;
const roundNumber = Number(maxima?.max_round ?? 0) + 1;
const roundId = await tx.insertId(
"INSERT INTO review_rounds (note_id, round_number, status, created_by) VALUES (?, ?, 'reviewing', ?)",
[noteId, roundNumber, createdBy ?? null],
);
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index];
const versionNumber = firstVersion + index;
await tx.execute(
"INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 'pending', ?)",
[noteId, versionNumber, candidate.title, candidate.description, JSON.stringify(candidate.tags), roundId, candidate.candidate_name, createdBy ?? null],
);
await imagesRepository.createMany(noteId, candidate.images, versionNumber, 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')",
[noteId, versionNumber, fromStatus, '工作台'],
);
}
const first = candidates[0];
await tx.execute(
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?",
[first.title, first.description, JSON.stringify(first.tags), firstVersion, roundId, noteId],
);
await recalculateCollectionStatus(collectionId, tx);
return { roundId, roundNumber, firstVersion };
}
async function prepareUploadCandidates(candidates: UploadCandidate[]): Promise<PreparedCandidate[]> {
return Promise.all(candidates.map(async (candidate) => ({
candidate_name: candidate.candidate_name,
title: candidate.title,
description: candidate.description,
tags: candidate.tags,
images: await prepareFiles(candidate.files),
})));
}
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 });
@@ -34,13 +101,16 @@ export const notesService = {
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; collection_status: NoteDetail['collection']['status'] }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name, c.status AS collection_status 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 versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
v.candidate_name, v.candidate_status, v.created_at, r.round_number, r.status AS round_status, r.selected_version_number
FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id
WHERE v.note_id = ? ORDER BY r.round_number DESC, v.version_number ASC`, [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[] })),
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), review_round_id: Number(item.review_round_id), round_number: Number(item.round_number), selected_version_number: item.selected_version_number == null ? null : Number(item.selected_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, status: workContext.collection_status },
@@ -52,10 +122,8 @@ export const notesService = {
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)]);
await recalculateCollectionStatus(collectionId, tx);
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: prepared }], undefined, 'draft');
return id;
});
return (await notesRepository.findById(noteId))!;
@@ -63,10 +131,8 @@ export const notesService = {
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)]);
await recalculateCollectionStatus(collectionId, tx);
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: externalImages(imageUrls) }], undefined, 'draft');
return id;
});
return (await notesRepository.findById(noteId))!;
@@ -76,35 +142,30 @@ export const notesService = {
return notesRepository.findByExternalId(collectionId, externalId);
},
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
async createReviewRound(id: number, candidates: UploadCandidate[], 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, '工作台']);
await recalculateCollectionStatus(current.collection_id, tx);
});
const prepared = await prepareUploadCandidates(candidates);
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
return (await notesRepository.findById(id))!;
},
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
async createReviewRoundFromUrls(id: number, candidates: UrlCandidate[], 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, '工作台']);
await recalculateCollectionStatus(current.collection_id, tx);
});
const prepared = candidates.map((candidate) => ({ ...candidate, images: externalImages(candidate.images) }));
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
return (await notesRepository.findById(id))!;
},
async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise<Note> {
return this.createReviewRound(id, [{ candidate_name: '方案 A', title, description, tags, files }], createdBy);
},
async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise<Note> {
return this.createReviewRoundFromUrls(id, [{ candidate_name: '方案 A', title, description, tags, images: imageUrls }], createdBy);
},
async remove(id: number) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number }>('SELECT collection_id FROM notes WHERE id = ?', [id]);
@@ -114,12 +175,17 @@ export const notesService = {
return removed;
});
},
async setStatus(id: number, status: ReviewStatus) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number; version_number: number }>('SELECT collection_id, version_number FROM notes WHERE id = ?', [id]);
const note = await tx.one<{ collection_id: number; active_round_id: number | null }>('SELECT collection_id, active_round_id FROM notes WHERE id = ?', [id]);
if (!note) return false;
await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]);
await tx.execute('UPDATE work_versions SET review_status = ? WHERE note_id = ? AND version_number = ?', [status, id, note.version_number]);
if (note.active_round_id) {
const candidateStatus = status === 'draft' ? 'draft' : 'pending';
await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ? AND candidate_status NOT IN ('selected', 'not_selected')", [status, candidateStatus, note.active_round_id]);
await tx.execute('UPDATE review_rounds SET status = ?, selected_version_number = NULL, completed_at = NULL WHERE id = ?', [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]);
}
await recalculateCollectionStatus(Number(note.collection_id), tx);
return true;
});

View File

@@ -0,0 +1,58 @@
import type { ReviewStatus } from '../../shared/types.js';
import { databaseDialect, withTransaction, type QueryContext } from '../database.js';
import { recalculateCollectionStatus } from './collectionsService.js';
export class ReviewDecisionError extends Error {
constructor(public statusCode: number, message: string) { super(message); }
}
export interface CandidateDecisionInput {
noteId: number;
versionNumber: number;
projectId: number;
decision: 'approved' | 'changes_requested';
reason: string;
actorName: string;
actorRole: 'client';
}
export async function decideCandidateInTransaction(tx: QueryContext, input: CandidateDecisionInput) {
const candidate = await tx.one<{
review_round_id: number; candidate_status: string; review_status: ReviewStatus;
collection_id: number; active_round_id: number | null; round_status: string;
title: string; description: string; tags: string;
}>(`SELECT v.review_round_id,v.candidate_status,v.review_status,v.title,v.description,v.tags,
n.collection_id,n.active_round_id,r.status AS round_status
FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id
JOIN review_rounds r ON r.id=v.review_round_id
WHERE v.note_id=? AND v.version_number=? AND c.project_id=?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
[input.noteId, input.versionNumber, input.projectId]);
if (!candidate) throw new ReviewDecisionError(404, '候选稿不存在');
if (Number(candidate.active_round_id) !== Number(candidate.review_round_id) || candidate.round_status !== 'reviewing') {
throw new ReviewDecisionError(409, '历史验收轮次为只读状态');
}
if (!['pending', 'changes_requested'].includes(candidate.candidate_status)) {
throw new ReviewDecisionError(409, '该候选稿当前不能重复验收');
}
if (input.decision === 'approved') {
await tx.execute("UPDATE work_versions SET candidate_status='not_selected', review_status='draft' WHERE review_round_id=? AND version_number!=?", [candidate.review_round_id, input.versionNumber]);
await tx.execute("UPDATE work_versions SET candidate_status='selected', review_status='approved' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE review_rounds SET status='completed', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, new Date().toISOString(), candidate.review_round_id]);
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [candidate.title, candidate.description, candidate.tags, input.versionNumber, input.versionNumber, input.noteId]);
} else {
await tx.execute("UPDATE work_versions SET candidate_status='changes_requested', review_status='changes_requested' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]);
const remaining = await tx.one<{ count: number | string }>("SELECT COUNT(*) AS count FROM work_versions WHERE review_round_id=? AND candidate_status='pending'", [candidate.review_round_id]);
const workStatus: ReviewStatus = Number(remaining?.count ?? 0) > 0 ? 'pending' : 'changes_requested';
await tx.execute('UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status=? WHERE id=?', [candidate.title, candidate.description, candidate.tags, input.versionNumber, workStatus, input.noteId]);
}
await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)', [input.noteId, input.versionNumber, input.decision, candidate.review_status, input.decision, input.reason, input.actorName, input.actorRole]);
if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')", [input.noteId, input.reason, input.actorName]);
await recalculateCollectionStatus(Number(candidate.collection_id), tx);
return { success: true as const, status: input.decision, version_number: input.versionNumber };
}
export async function decideCandidate(input: CandidateDecisionInput) {
return withTransaction((tx) => decideCandidateInTransaction(tx, input));
}

View File

@@ -57,6 +57,8 @@ CREATE TABLE IF NOT EXISTS notes (
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')),
version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0),
active_round_id BIGINT,
approved_version_number INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -117,11 +119,32 @@ CREATE TABLE IF NOT EXISTS work_versions (
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')),
review_round_id BIGINT,
candidate_name TEXT NOT NULL DEFAULT '方案 A',
candidate_status TEXT NOT NULL DEFAULT 'pending' CHECK (candidate_status IN ('draft', 'pending', 'changes_requested', 'selected', 'not_selected')),
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (note_id, version_number)
);
CREATE TABLE IF NOT EXISTS review_rounds (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
round_number INTEGER NOT NULL CHECK (round_number > 0),
status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed')),
selected_version_number INTEGER,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(note_id, round_number)
);
ALTER TABLE notes ADD COLUMN IF NOT EXISTS active_round_id BIGINT;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS approved_version_number INTEGER;
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS review_round_id BIGINT;
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_name TEXT NOT NULL DEFAULT '方案 A';
ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_status TEXT NOT NULL DEFAULT 'pending';
CREATE TABLE IF NOT EXISTS review_events (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -205,6 +228,7 @@ CREATE INDEX IF NOT EXISTS annotations_image_id_idx ON annotations(image_id);
CREATE INDEX IF NOT EXISTS comments_note_id_idx ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions(project_id);
CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number);
CREATE INDEX IF NOT EXISTS review_rounds_note_id_idx ON review_rounds(note_id, round_number);
CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id);
-- COLLECTION_STATUS_REPAIR_START
@@ -227,4 +251,34 @@ FROM (
WHERE c.id = s.collection_id AND c.status != 'archived';
-- COLLECTION_STATUS_REPAIR_END
-- REVIEW_ROUND_REPAIR_START
INSERT INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at)
SELECT v.note_id, v.version_number,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END,
CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END,
CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END,
v.created_at
FROM work_versions v JOIN notes n ON n.id = v.note_id
ON CONFLICT (note_id, round_number) DO NOTHING;
UPDATE work_versions v
SET review_round_id = r.id,
candidate_name = COALESCE(NULLIF(v.candidate_name, ''), '方案 A'),
candidate_status = CASE
WHEN v.review_status = 'approved' THEN 'selected'
WHEN v.version_number = n.version_number AND v.review_status = 'changes_requested' THEN 'changes_requested'
WHEN v.version_number = n.version_number AND v.review_status = 'pending' THEN 'pending'
WHEN v.version_number = n.version_number AND v.review_status = 'draft' THEN 'draft'
ELSE 'not_selected'
END
FROM review_rounds r, notes n
WHERE r.note_id = v.note_id AND r.round_number = v.version_number AND n.id = v.note_id AND v.review_round_id IS NULL;
UPDATE notes n
SET active_round_id = r.id,
approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = n.id AND v.review_status = 'approved')
FROM review_rounds r
WHERE r.note_id = n.id AND r.round_number = n.version_number AND n.active_round_id IS NULL;
-- REVIEW_ROUND_REPAIR_END
COMMIT;

View File

@@ -21,7 +21,8 @@ flowchart LR
└── 项目
└── 作品交付集
└── 作品
└── 版本
└── 验收轮次
└── 候选稿15 个)
```
- 一个运营组只能有一位组管理员,可以有多位光影叙事。
@@ -52,9 +53,10 @@ flowchart LR
| `customer_sessions` | 客户项目级验收会话 |
| `projects` | 项目、客户访问密码和访问期限 |
| `collections` | 项目下的作品交付集 |
| `notes` | 作品当前状态和当前版本 |
| `work_versions` | 各版本标题、正文、标签和状态快照 |
| `images` | 版本图片、顺序、存储提供方和对象 Key |
| `notes` | 作品当前状态、活动轮次和选中稿 |
| `review_rounds` | 验收轮次、完成状态和选中候选稿 |
| `work_versions` | 各候选稿的标题、正文、标签和验收状态快照 |
| `images` | 候选稿图片、顺序、存储提供方和对象 Key |
| `annotations` | 图片坐标批注 |
| `text_annotations` | 标题或正文的版本级批注 |
| `work_comments` | 作品总体反馈与回复 |
@@ -77,6 +79,6 @@ flowchart LR
## 验收状态
作品状态为 `draft``pending``changes_requested``approved`客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。
作品状态为 `draft``pending``changes_requested``approved`一个验收轮次可包含 15 个候选稿;单稿退修时,其他待验收稿仍可继续验收。客户选中并通过任意一稿后,作品即通过,同轮其他稿标记为未选用,历史轮次只读。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。
作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed``archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新版本或重新打开作品会自动恢复为验收中。
作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed``archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新验收轮次或重新打开作品会自动恢复为验收中。

View File

@@ -3,8 +3,8 @@
## 已完成
- 三类工作台角色、运营组隔离、账号管理和 7 天会话
- 项目、作品交付集、作品、版本和验收状态
- 手动多图上传、封面、上传前拖拽排序及新版本
- 项目、作品交付集、作品、多候选稿验收轮次和验收状态
- 手动多图上传、封面、上传前拖拽排序及新验收轮次
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
- 客户项目链接、密码、姓名、期限和验收决定
- API Key、审计日志、COS 前端配置及连接测试

View File

@@ -23,11 +23,13 @@ API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。
| `/api/projects` | 项目创建、查询和编辑 |
| `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 |
| `/api/notes` | 作品查询与创建 |
| `/api/notes/:noteId/versions` | 创建作品新版本 |
| `/api/notes/:noteId/review-rounds` | 创建包含 15 个候选稿的验收轮次 |
| `/api/notes/:noteId/versions` | 兼容接口:创建单候选稿验收轮次 |
| `/api/notes/:noteId/status` | 草稿与待验收状态切换 |
| `/api/notes/:noteId/text-annotations` | 标题/正文批注 |
| `/api/images/:imageId/annotations` | 图片坐标批注 |
| `/api/review/:slug/*` | 客户登录、浏览反馈与验收 |
| `/api/review/:slug/*` | 客户登录、浏览反馈 |
| `/api/review/:slug/works/:noteId/decision` | 客户对指定候选稿作出验收决定 |
| `/api/health` | 数据库就绪检查 |
## 查询运营组、项目、作品交付集和作品
@@ -72,7 +74,7 @@ curl -X POST http://localhost:3010/api/projects/1/collections \
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
```
新建作品交付集的 `status``draft`。上传首件作品后自动变为 `reviewing`;全部非草稿作品通过后自动变为 `completed`。响应中的 `work_count``approved_count``completed_at` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新版本、修改验收状态和删除作品都会触发服务端重算。
新建作品交付集的 `status``draft`。上传首件作品后自动变为 `reviewing`;全部非草稿作品通过后自动变为 `completed`。响应中的 `work_count``approved_count``completed_at` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新验收轮次、修改验收状态和删除作品都会触发服务端重算。
## 上传作品
@@ -99,7 +101,7 @@ curl -X POST http://localhost:3010/api/notes \
URL 图片不会进入当前配置的 COS也不会由服务检查其内容或长期可用性因此调用方需要保证链接公开、稳定且确实指向图片。工作台手动上传仍接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按原文保存和展示不会自动添加 `#` 或拆分为标签库。
## 创建新版本
## 创建单候选稿验收轮次(兼容接口)
```bash
curl -X POST http://localhost:3010/api/notes/12/versions \
@@ -116,7 +118,44 @@ curl -X POST http://localhost:3010/api/notes/12/versions \
}'
```
批注绑定作品版本或具体图片,不会因新版本覆盖历史验收证据
该接口保留给只提交一个方案的现有调用方。批注绑定候选稿或具体图片,不会被新验收轮次覆盖
## 提交多候选稿验收轮次
`POST /api/notes/:noteId/review-rounds` 可在同一轮中提交 15 个候选稿。JSON 请求中每个候选稿使用公开图片 URL
```json
{
"candidates": [
{
"candidate_name": "暖色方案",
"title": "夏日新品",
"description": "暖色调正文",
"tags": ["#夏日", "#新品"],
"images": ["https://cdn.example.com/warm-01.jpg"]
},
{
"candidate_name": "冷色方案",
"title": "夏日新品",
"description": "冷色调正文",
"tags": ["#夏日", "#新品"],
"images": ["https://cdn.example.com/cool-01.jpg"]
}
]
}
```
客户验收决定必须带上候选稿的 `version_number`。选中并通过某稿后,同轮其他稿自动标记为 `not_selected`,历史轮次变为只读。提交新轮次时,尚未结束的上一轮会自动关闭,其中仍在等待验收的候选稿会标记为 `not_selected`。旧的 `/versions` 接口继续可用,等价于创建只有一个候选稿的新轮次。决定接口使用客户登录后获得的 Cookie不能使用工作台 API Key 代替。
```bash
curl -X POST http://localhost:3010/api/review/july-content/works/12/decision \
-b cookies.txt \
-H "Content-Type: application/json" \
-d '{
"version_number": 5,
"decision": "approved"
}'
```
## Python 冒烟脚本
@@ -126,7 +165,7 @@ curl -X POST http://localhost:3010/api/notes/12/versions \
$env:DELIVERY_DESK_API_KEY = 'dd_live_xxx'
python tests/api_create_work.py --project-id 1 --collection-id 1
# 为已有作品创建新版本
# 为已有作品创建单候选稿验收轮次
python tests/api_create_work.py --project-id 1 --collection-id 1 --work-id 12
```

View File

@@ -63,7 +63,10 @@ pnpm install --frozen-lockfile
pnpm check
pnpm lint
pnpm build
pnpm test:review-rounds
pnpm test:collection-status
pnpm test:postgres-runtime
pnpm db:postgres:validate
```
正式切换前还应验证管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。

View File

@@ -15,6 +15,7 @@
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
"test:collection-status": "tsx scripts/test-sqlite-collection-status.ts",
"test:review-rounds": "tsx scripts/test-sqlite-review-rounds.ts",
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
},
"dependencies": {

View File

@@ -13,7 +13,7 @@ if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${s
const tables = [
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
'work_comments', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
@@ -47,7 +47,10 @@ try {
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
}
const collectionStatusRepair = schema.match(/-- COLLECTION_STATUS_REPAIR_START([\s\S]+?)-- COLLECTION_STATUS_REPAIR_END/)?.[1];
const reviewRoundRepair = schema.match(/-- REVIEW_ROUND_REPAIR_START([\s\S]+?)-- REVIEW_ROUND_REPAIR_END/)?.[1];
if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本');
if (!reviewRoundRepair) throw new Error('PostgreSQL schema 缺少验收轮次修复脚本');
await client.query(reviewRoundRepair);
await client.query(collectionStatusRepair);
await client.query('COMMIT');
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);

View File

@@ -119,6 +119,8 @@ try {
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
const firstRound=(workDetail.body as {versions:Array<{version_number:number;round_status:string;candidate_status:string}>}).versions.find((item)=>item.version_number===1);
if(firstRound?.round_status!=='completed'||firstRound.candidate_status!=='not_selected')throw new Error('新验收轮次未自动收口旧轮次');
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
@@ -127,7 +129,7 @@ try {
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
expectStatus(reviewProject.response.status, 200, '客户项目读取');
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
expectStatus(approveV2.response.status,200,'客户通过作品',approveV2.body);
const completedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const completedCollection=(completedCollections.body as Array<{id:number;status:string;completed_at:string|null;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
@@ -142,7 +144,7 @@ try {
expectStatus(reopenApproved.response.status,200,'组管理员重新打开已通过作品',reopenApproved.body);
const reopenedByAdmin=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
if((reopenedByAdmin.body as Array<{id:number;status:string}>).find((item)=>Number(item.id)===collectionId)?.status!=='reviewing')throw new Error('管理员重新打开作品后,作品交付集未回到验收中');
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
expectStatus(approveReopened.response.status,200,'客户通过重新打开的作品',approveReopened.body);
const versionThree=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V3',description:'完成后追加版本',tags:['API 测试'],images:['https://cdn.example.com/runtime-v3.jpg']})});
@@ -150,17 +152,44 @@ try {
const reopenedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const reopenedCollection=(reopenedCollections.body as Array<{id:number;status:string;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
if(reopenedCollection?.status!=='reviewing'||reopenedCollection.completed_at!==null)throw new Error('新版本未将作品交付集重新打开为验收中');
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'changes_requested',reason:'请调整第三版'})},reviewCookie);
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'changes_requested',reason:'请调整第三版'})},reviewCookie);
expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body);
const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie);
expectStatus(approveV3.response.status,200,'客户再次通过',approveV3.body);
const candidateRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[
{candidate_name:'暖色方案',title:'候选稿 A',description:'暖色方向',tags:['A'],images:['https://cdn.example.com/runtime-candidate-a.jpg']},
{candidate_name:'冷色方案',title:'候选稿 B',description:'冷色方向',tags:['B'],images:['https://cdn.example.com/runtime-candidate-b.jpg']}
]})});
expectStatus(candidateRound.response.status,201,'创建多候选稿验收轮次',candidateRound.body);
const candidateDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
expectStatus(candidateDetail.response.status,200,'读取多候选稿',candidateDetail.body);
const latestCandidates=(candidateDetail.body as {versions:Array<{version_number:number;round_number:number;candidate_name:string;candidate_status:string}>}).versions.filter((item)=>item.round_number===4);
if(latestCandidates.length!==2||latestCandidates.map((item)=>item.version_number).join(',')!=='4,5')throw new Error('同一验收轮次未生成两个独立候选稿');
const historicalDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie);
expectStatus(historicalDecision.response.status,409,'历史验收轮次不可重复决策',historicalDecision.body);
const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})},reviewCookie);
expectStatus(historicalClientAnnotation.response.status,409,'客户不可批注历史轮次',historicalClientAnnotation.body);
const historicalOperatorAnnotation=await request(`/api/notes/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})});
expectStatus(historicalOperatorAnnotation.response.status,409,'工作台不可批注历史轮次',historicalOperatorAnnotation.body);
const oversizedRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{candidate_name:'超量方案',title:'超量方案',images:Array.from({length:31},(_,index)=>`https://cdn.example.com/oversized-${index}.jpg`)}]})});
expectStatus(oversizedRound.response.status,400,'验收轮次总图片不可超过 30 张',oversizedRound.body);
const changesA=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:4,decision:'changes_requested',reason:'A 方案需调整'})},reviewCookie);
expectStatus(changesA.response.status,200,'单个候选稿要求修改',changesA.body);
const afterChanges=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
if((afterChanges.body as {review_status:string}).review_status!=='pending')throw new Error('仍有待验收候选稿时作品不应整体进入需修改');
const chooseB=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:5,decision:'approved'})},reviewCookie);
expectStatus(chooseB.response.status,200,'选择并通过候选稿 B',chooseB.body);
const selectedDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
const selectedBody=selectedDetail.body as {version_number:number;approved_version_number:number;review_status:string;versions:Array<{version_number:number;candidate_status:string}>};
if(selectedBody.version_number!==5||selectedBody.approved_version_number!==5||selectedBody.review_status!=='approved')throw new Error('作品未指向客户选中的候选稿');
if(selectedBody.versions.find((item)=>item.version_number===4)?.candidate_status!=='not_selected'||selectedBody.versions.find((item)=>item.version_number===5)?.candidate_status!=='selected')throw new Error('选中候选稿后同轮状态未正确收口');
const secondWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({collectionId,externalId:'runtime-client-work-002',title:'完成后新增作品',description:'验证部分通过',tags:['API 测试'],images:['https://cdn.example.com/runtime-second.jpg']})});
expectStatus(secondWork.response.status,201,'完成后新增作品',secondWork.body);
const secondWorkId=Number((secondWork.body as {id:number}).id);
const partialCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const partialCollection=(partialCollections.body as Array<{id:number;status:string;work_count:number;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
if(partialCollection?.status!=='reviewing'||Number(partialCollection.work_count)!==2||Number(partialCollection.approved_count)!==1)throw new Error('完成后新增作品未恢复验收中或进度统计错误');
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:1,decision:'approved'})},reviewCookie);
expectStatus(approveSecond.response.status,200,'客户通过新增作品',approveSecond.body);
const forbiddenDraft=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
expectStatus(forbiddenDraft.response.status,409,'普通写入不能绕过重新打开规则',forbiddenDraft.body);

View File

@@ -0,0 +1,45 @@
process.env.NODE_ENV = 'test';
delete process.env.DATABASE_URL;
const { db } = await import('../api/db.js');
const { database, closeDatabase } = await import('../api/database.js');
const { decideCandidateInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js');
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
db.exec('BEGIN IMMEDIATE');
try {
const project = await database.one<{ id: number }>('SELECT id FROM projects ORDER BY id LIMIT 1');
assert(project, 'SQLite 测试需要至少一个项目');
const collectionId = await database.insertId("INSERT INTO collections (project_id, name, status) VALUES (?, ?, 'reviewing')", [project.id, `候选稿测试 ${Date.now()}`]);
const noteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, '候选稿 A']);
const roundId = await database.insertId("INSERT INTO review_rounds (note_id, round_number, status) VALUES (?, 1, 'reviewing')", [noteId]);
await database.execute('UPDATE notes SET active_round_id = ? WHERE id = ?', [roundId, noteId]);
await database.execute("INSERT INTO work_versions (note_id, version_number, title, review_status, review_round_id, candidate_name, candidate_status) VALUES (?, 1, ?, 'pending', ?, ?, 'pending')", [noteId, '候选稿 A', roundId, '方案 A']);
await database.execute("INSERT INTO work_versions (note_id, version_number, title, review_status, review_round_id, candidate_name, candidate_status) VALUES (?, 2, ?, 'pending', ?, ?, 'pending')", [noteId, '候选稿 B', roundId, '方案 B']);
await decideCandidateInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'changes_requested', reason: 'A 需调整', actorName: '测试客户', actorRole: 'client' });
assert((await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id = ?', [noteId]))?.review_status === 'pending', '仍有待验收候选稿时作品应保持待验收');
await decideCandidateInTransaction(database, { noteId, versionNumber: 2, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' });
const note = await database.one<{ version_number: number; approved_version_number: number; review_status: string }>('SELECT version_number, approved_version_number, review_status FROM notes WHERE id = ?', [noteId]);
assert(note?.version_number === 2 && note.approved_version_number === 2 && note.review_status === 'approved', '作品未指向选中候选稿');
const candidates = await database.all<Array<{ version_number: number; candidate_status: string }>[number]>('SELECT version_number, candidate_status FROM work_versions WHERE review_round_id = ? ORDER BY version_number', [roundId]);
assert(candidates[0]?.candidate_status === 'not_selected' && candidates[1]?.candidate_status === 'selected', '同轮候选稿结果未正确收口');
assert((await database.one<{ status: string }>('SELECT status FROM collections WHERE id = ?', [collectionId]))?.status === 'completed', '作品通过后作品交付集未自动完成');
let historicalRejected = false;
try {
await decideCandidateInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' });
} catch (error) {
historicalRejected = error instanceof ReviewDecisionError && error.statusCode === 409;
}
assert(historicalRejected, '已完成轮次的候选稿不应被重复验收');
process.stdout.write('SQLite 多候选稿验证通过:单稿退修、其他候选继续验收、选中收口与历史只读\n');
} finally {
db.exec('ROLLBACK');
await closeDatabase();
}

View File

@@ -5,7 +5,7 @@ import { newDb } from 'pg-mem';
const tables = [
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
'work_comments', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
@@ -22,7 +22,8 @@ try {
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
.replace(/^BEGIN;|COMMIT;$/gm, '')
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '');
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '')
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '');
await client.query(schema);
for (const table of tables) {
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
@@ -38,6 +39,9 @@ try {
}
const current = (await client.query(`SELECT n.version_number,n.review_status,i.storage_provider FROM notes n JOIN images i ON i.note_id=n.id AND i.version_number=n.version_number WHERE n.id=1 LIMIT 1`)).rows[0];
if (!current || Number(current.version_number) < 1) throw new Error('作品版本关系未正确迁移');
const invalidRoundLinks = Number((await client.query('SELECT COUNT(*)::int AS count FROM work_versions v LEFT JOIN review_rounds r ON r.id=v.review_round_id WHERE r.id IS NULL')).rows[0].count);
const invalidActiveRounds = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE r.id IS NULL OR r.note_id!=n.id')).rows[0].count);
if (invalidRoundLinks || invalidActiveRounds) throw new Error(`验收轮次迁移关联无效versions=${invalidRoundLinks}, notes=${invalidActiveRounds}`);
const activeStorage = Number((await client.query("SELECT COUNT(*)::int AS count FROM storage_configs WHERE status='active'")).rows[0].count);
if (activeStorage > 1) throw new Error('活动对象存储配置超过一个');
process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`);

View File

@@ -1,4 +1,6 @@
export type ReviewStatus = 'draft' | 'pending' | 'changes_requested' | 'approved';
export type CandidateStatus = 'draft' | 'pending' | 'changes_requested' | 'selected' | 'not_selected';
export type ReviewRoundStatus = 'draft' | 'reviewing' | 'completed';
export type CollectionStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
export type UserRole = 'platform_admin' | 'group_admin' | 'operator';
@@ -132,6 +134,8 @@ export interface Note {
tags: string[];
review_status: ReviewStatus;
version_number: number;
active_round_id: number | null;
approved_version_number: number | null;
cover_image: string;
image_count: number;
annotation_count: number;
@@ -198,6 +202,12 @@ export interface NoteDetail extends Note {
export interface WorkVersion {
version_number: number;
review_round_id: number;
round_number: number;
candidate_name: string;
candidate_status: CandidateStatus;
round_status: ReviewRoundStatus;
selected_version_number: number | null;
title: string;
description: string;
tags: string[];
@@ -205,6 +215,16 @@ export interface WorkVersion {
created_at: string;
}
export interface ReviewRound {
id: number;
note_id: number;
round_number: number;
status: ReviewRoundStatus;
selected_version_number: number | null;
completed_at: string | null;
created_at: string;
}
export interface ReviewEvent {
id: number;
version_number: number;

View File

@@ -68,6 +68,12 @@ export const api = {
payload.images.forEach((file) => form.append('images', file));
return request<Note>(`/api/notes/${noteId}/versions`, { method: 'POST', body: form });
},
createReviewRound: (noteId: number, candidates: Array<{ candidate_name: string; 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 });
},
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) }),
@@ -82,5 +88,5 @@ export const api = {
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 }) }),
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 }) }),
};

View File

@@ -0,0 +1,13 @@
import type { CandidateStatus } from '@shared/types';
const labels: Record<CandidateStatus, string> = {
draft: '草稿', pending: '待选择', changes_requested: '需修改', selected: '已选用', not_selected: '未选用',
};
const styles: Record<CandidateStatus, string> = {
draft: 'bg-black/5 text-black/45', pending: 'bg-amber-50 text-amber-700', changes_requested: 'bg-red-50 text-red-700',
selected: 'bg-emerald-50 text-emerald-700', not_selected: 'bg-black/5 text-black/35',
};
export default function CandidateStatusBadge({ status }: { status: CandidateStatus }) {
return <span className={`rounded-full px-2.5 py-1 text-[10px] font-medium ${styles[status]}`}>{labels[status]}</span>;
}

File diff suppressed because one or more lines are too long

View File

@@ -1,19 +1,47 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowUp, ImagePlus, X } from 'lucide-react';
import { ArrowLeft, GripVertical, ImagePlus, Layers3, Plus, Trash2 } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
type Item = { file: File; url: string };
type ImageItem = { file: File; url: string };
type CandidateDraft = { key: string; candidate_name: string; title: string; description: string; tags: string; images: ImageItem[] };
const candidateName = (index: number) => `方案 ${String.fromCharCode(65 + index)}`;
export default function NewVersionPage() {
const id = Number(useParams().noteId); const navigate = useNavigate();
const [work,setWork]=useState<NoteDetail|null>(null); const [title,setTitle]=useState(''); const [description,setDescription]=useState(''); const [tags,setTags]=useState(''); const [items,setItems]=useState<Item[]>([]); const [busy,setBusy]=useState(false); const [error,setError]=useState('');
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);setTitle(item.title);setDescription(item.description);setTags(item.tags.join(', '))})},[id]);
useEffect(()=>()=>items.forEach((item)=>URL.revokeObjectURL(item.url)),[items]);
const add=(files:FileList|null)=>{if(!files)return;setItems((current)=>[...current,...Array.from(files).slice(0,30-current.length).map((file)=>({file,url:URL.createObjectURL(file)}))])};
const move=(index:number,direction:-1|1)=>setItems((current)=>{const target=index+direction;if(target<0||target>=current.length)return current;const copy=[...current];[copy[index],copy[target]]=[copy[target],copy[index]];return copy});
const submit=async()=>{if(!title.trim()||!items.length)return;setBusy(true);setError('');try{await api.createWorkVersion(id,{title:title.trim(),description,tags:tags?[tags]:[],images:items.map((item)=>item.file)});navigate(`/works/${id}`)}catch(reason){setError(reason instanceof Error?reason.message:'新版本上传失败');setBusy(false)}};
if(!work)return null;
return <main className="mx-auto max-w-6xl px-5 py-9 lg:px-10 lg:py-12"><Link to={`/works/${id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.75fr_1.25fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">New revision / V{work.version_number+1}</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><p className="mt-4 text-sm leading-6 text-black/45"></p><label className="mt-8 block text-xs text-black/50"><input value={title} onChange={(e)=>setTitle(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50"><textarea rows={7} value={description} onChange={(e)=>setDescription(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">Tag<input value={tags} onChange={(e)=>setTags(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label>{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy||!title.trim()||!items.length} onClick={()=>void submit()} className="mt-7 w-full rounded-full bg-black py-4 text-sm text-white disabled:opacity-30">{busy?'正在上传…':`创建 V${work.version_number+1}`}</button></section><section><label className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif" multiple className="hidden" onChange={(e)=>add(e.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-3 block text-sm"></b><small className="mt-1 block text-black/35">130 </small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{items.map((item,index)=><div key={item.url} className="group relative overflow-hidden rounded-2xl border border-black/10 bg-white"><img src={item.url} alt="" className="aspect-[4/5] w-full object-cover"/><div className="absolute inset-x-2 bottom-2 flex justify-between"><button onClick={()=>move(index,-1)} className="rounded-full bg-white/90 p-2 disabled:opacity-30" disabled={index===0}><ArrowUp size={13}/></button><button onClick={()=>setItems((current)=>current.filter((_,i)=>i!==index))} className="rounded-full bg-white/90 p-2"><X size={13}/></button></div>{index===0&&<span className="absolute left-2 top-2 rounded-full bg-black px-2 py-1 text-[9px] text-white"></span>}</div>)}</div></section></div></main>;
const [work,setWork]=useState<NoteDetail|null>(null); const [candidates,setCandidates]=useState<CandidateDraft[]>([]);
const [activeKey,setActiveKey]=useState(''); const [busy,setBusy]=useState(false); const [error,setError]=useState('');
const [dragIndex,setDragIndex]=useState<number|null>(null);
const candidatesRef=useRef<CandidateDraft[]>([]);
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);const first={key:crypto.randomUUID(),candidate_name:'方案 A',title:item.title,description:item.description,tags:item.tags.join(', '),images:[]};setCandidates([first]);setActiveKey(first.key)}).catch((reason)=>setError(reason instanceof Error?reason.message:'作品加载失败'))},[id]);
useEffect(()=>{candidatesRef.current=candidates},[candidates]);
useEffect(()=>()=>{candidatesRef.current.forEach((candidate)=>candidate.images.forEach((image)=>URL.revokeObjectURL(image.url)))},[]);
const active=candidates.find((candidate)=>candidate.key===activeKey)??candidates[0];
const totalImages=useMemo(()=>candidates.reduce((sum,candidate)=>sum+candidate.images.length,0),[candidates]);
const update=(key:string,changes:Partial<CandidateDraft>)=>setCandidates((current)=>current.map((candidate)=>candidate.key===key?{...candidate,...changes}:candidate));
const addCandidate=()=>{if(candidates.length>=5)return;const next={key:crypto.randomUUID(),candidate_name:candidateName(candidates.length),title:work?.title??'',description:work?.description??'',tags:work?.tags.join(', ')??'',images:[]};setCandidates([...candidates,next]);setActiveKey(next.key)};
const removeCandidate=(key:string)=>{if(candidates.length===1)return;const removed=candidates.find((candidate)=>candidate.key===key);removed?.images.forEach((image)=>URL.revokeObjectURL(image.url));const next=candidates.filter((candidate)=>candidate.key!==key);setCandidates(next);if(activeKey===key)setActiveKey(next[0].key)};
const addImages=(files:FileList|null)=>{if(!files||!active)return;const allowance=Math.max(0,30-totalImages);const next=Array.from(files).slice(0,allowance).map((file)=>({file,url:URL.createObjectURL(file)}));update(active.key,{images:[...active.images,...next]})};
const removeImage=(index:number)=>{if(!active)return;URL.revokeObjectURL(active.images[index].url);update(active.key,{images:active.images.filter((_,itemIndex)=>itemIndex!==index)})};
const dropImage=(target:number)=>{if(!active||dragIndex===null||dragIndex===target){setDragIndex(null);return}const images=[...active.images];const[moved]=images.splice(dragIndex,1);images.splice(target,0,moved);update(active.key,{images});setDragIndex(null)};
const valid=candidates.every((candidate)=>candidate.candidate_name.trim()&&candidate.title.trim()&&candidate.images.length>0)&&totalImages<=30;
const submit=async()=>{if(!valid)return;setBusy(true);setError('');try{await api.createReviewRound(id,candidates.map((candidate)=>({candidate_name:candidate.candidate_name.trim(),title:candidate.title.trim(),description:candidate.description,tags:candidate.tags.trim()?[candidate.tags.trim()]:[],images:candidate.images.map((image)=>image.file)})));navigate(`/works/${id}`)}catch(reason){setError(reason instanceof Error?reason.message:'验收轮次提交失败');setBusy(false)}};
if(!work||!active)return <main className="grid min-h-[60vh] place-items-center px-5 text-sm text-black/45">{error||'正在加载作品…'}</main>;
return <main className="mx-auto max-w-[1400px] 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>
<header className="mt-8 flex flex-col gap-6 border-b border-black/10 pb-8 lg:flex-row lg:items-end lg:justify-between"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">New review round</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl"></h1><p className="mt-4 max-w-xl text-sm leading-7 text-black/45">稿稿</p></div><button disabled={busy||!valid} onClick={()=>void submit()} className="rounded-full bg-black px-7 py-3.5 text-sm text-white disabled:opacity-30">{busy?'正在提交…':`提交 ${candidates.length} 个候选稿`}</button></header>
<div className="mt-8 grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside><div className="rounded-[24px] border border-black/10 bg-white p-3"><div className="mb-3 flex items-center justify-between px-2"><span className="flex items-center gap-2 text-xs text-black/45"><Layers3 size={14}/></span><span className="font-mono text-[10px] text-black/30">{candidates.length}/5</span></div><div className="space-y-2">{candidates.map((candidate,index)=><button key={candidate.key} onClick={()=>setActiveKey(candidate.key)} className={`w-full rounded-2xl border p-4 text-left transition ${active.key===candidate.key?'border-black bg-[#171714] text-white':'border-transparent bg-[#f4f1ea] text-black'}`}><span className="text-[10px] opacity-45">{String(index+1).padStart(2,'0')}</span><b className="mt-1 block truncate text-sm">{candidate.candidate_name||'未命名方案'}</b><small className="mt-1 block opacity-45">{candidate.images.length} </small></button>)}</div><button disabled={candidates.length>=5} onClick={addCandidate} className="mt-3 flex w-full items-center justify-center gap-2 rounded-full border border-dashed border-black/15 py-3 text-xs text-black/50 disabled:opacity-30"><Plus size={13}/>稿</button></div><p className="mt-4 px-2 text-[11px] leading-5 text-black/35"> 5 稿 30 稿</p></aside>
<section className="rounded-[30px] border border-black/10 bg-white p-5 md:p-8"><div className="flex flex-wrap items-center justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.24em] text-[#ba623c]">Candidate {candidates.findIndex((candidate)=>candidate.key===active.key)+1}</p><h2 className="mt-2 font-display text-3xl">稿</h2></div>{candidates.length>1&&<button onClick={()=>removeCandidate(active.key)} className="inline-flex items-center gap-2 rounded-full border border-red-100 px-4 py-2 text-xs text-red-600"><Trash2 size={13}/>稿</button>}</div>
<div className="mt-7 grid gap-5 md:grid-cols-2"><label className="text-xs text-black/50">稿<input value={active.candidate_name} onChange={(event)=>update(active.key,{candidate_name:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label><label className="text-xs text-black/50"><input value={active.title} onChange={(event)=>update(active.key,{title:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label></div><label className="mt-5 block text-xs text-black/50"><textarea rows={5} value={active.description} onChange={(event)=>update(active.key,{description:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">Tag<input value={active.tags} onChange={(event)=>update(active.key,{tags:event.target.value})} className="mt-2 w-full rounded-xl border border-black/10 p-3.5 text-sm"/></label>
<label className="mt-7 grid min-h-36 cursor-pointer place-items-center rounded-[24px] border border-dashed border-black/20 bg-[#f8f6f1] text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/avif" multiple className="hidden" onChange={(event)=>addImages(event.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-2 block text-sm">稿</b><small className="mt-1 block text-black/35"></small></span></label>
<div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3 xl:grid-cols-4">{active.images.map((image,index)=><div key={image.url} data-image-index={index} draggable onDragStart={()=>setDragIndex(index)} onDragOver={(event)=>event.preventDefault()} onDrop={()=>dropImage(index)} onPointerDown={(event)=>{if(event.pointerType!=='mouse'){setDragIndex(index);event.currentTarget.setPointerCapture(event.pointerId)}}} onPointerUp={(event)=>{if(event.pointerType==='mouse')return;const target=document.elementFromPoint(event.clientX,event.clientY)?.closest<HTMLElement>('[data-image-index]');dropImage(Number(target?.dataset.imageIndex??index))}} onPointerCancel={()=>setDragIndex(null)} className="group"><div className="relative cursor-grab overflow-hidden rounded-2xl border border-black/10 bg-[#eeece6] active:cursor-grabbing"><img src={image.url} alt="" className="aspect-[4/5] w-full select-none object-cover" draggable={false}/><span className="absolute left-2 top-2 grid h-6 min-w-6 place-items-center rounded-full bg-black/70 px-1.5 font-mono text-[9px] text-white">{index+1}</span><GripVertical className="absolute bottom-2 right-2 text-white drop-shadow" size={16}/></div><button onClick={()=>removeImage(index)} className="mt-2 w-full text-center text-[10px] text-black/35 transition hover:text-red-600"></button></div>)}</div>
{error&&<p className="mt-5 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}
</section>
</div>
</main>;
}

View File

@@ -6,6 +6,7 @@ import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import StatusBadge from '@/components/StatusBadge';
import CandidateStatusBadge from '@/components/CandidateStatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function NoteDetailPage() {
@@ -26,9 +27,10 @@ export default function NoteDetailPage() {
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 viewingCurrent = version === undefined;
const viewed = work.versions.find((item) => item.version_number === work.version_number);
const annotationsReadOnly = work.collection.status === 'completed' || !viewed || viewed.round_status !== 'reviewing' || Number(viewed.review_round_id) !== Number(work.active_round_id);
const canReopen = viewingCurrent && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
const send = async () => {
if (!comment.trim()) return;
@@ -47,13 +49,13 @@ export default function NoteDetailPage() {
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 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={`flex items-center gap-2 rounded-full px-3 py-1.5 text-[11px] ${item.version_number === work.version_number ? 'bg-black text-white' : 'bg-black/5'}`}><span> {item.round_number} · {item.candidate_name}</span><CandidateStatusBadge status={item.candidate_status}/></Link>)}{viewingCurrent && <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 className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly={annotationsReadOnly} 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')} readOnly={annotationsReadOnly} 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')} readOnly={annotationsReadOnly} 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>