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

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

View File

@@ -1,8 +1,9 @@
# Delivery Desk 开发约定
- 包管理器使用 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` 和相关迁移验证
- 业务术语统一为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案`collections``work_versions` 是迁移期内部兼容结构,不得出现在新产品界面或新 API 命名中
-接口使用 `/api/projects/:projectId/works``/api/works/:workId/rounds``notes``collections``versions` 路由只做一个兼容周期,不再扩展
- 数据库结构变更必须同时更新 `api/db.ts``db/postgres/schema.sql` 和迁移验证。
- `data/``uploads/``.env*`、COS 凭证、数据库文件及用户上传内容不得提交。
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入,不写入源码
- 本地开发可使用 SQLite正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。
- 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。

View File

@@ -1,27 +1,19 @@
# 交付工作台Delivery Desk
面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 验收轮次 → 候选稿”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收
面向图文作品交付与客户验收的响应式 Web 工作台。当前产品层级为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案
## 当前能力
- 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离
- 项目、作品交付集、作品和多候选稿验收轮次管理
- 多图上传、封面预览、图片排序和腾讯云 COS 存储
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
- 项目、作品、单方案验收轮次和项目级自动验收状态
- 手动多图上传、公开图片 URL API、拖拽排序和腾讯云 COS
- 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注
- 标题、正文和 Tag 选区批注、作品总体反馈和验收记录
- 客户项目密码、访问期限和独立验收入口
- 平台级/项目级 API Key、审计日志和账号管理
- SQLite 本地开发、PostgreSQL 正式运行及迁移脚本
- Docker 单机部署
- SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署
未落地范围见 [初版交接说明](docs/handoff.md)。
## 技术结构
- React 18、TypeScript、Vite、Tailwind CSS
- Express API
- 本地开发SQLite 与本地 `uploads`
- 正式环境PostgreSQL、腾讯云 COS
- COS SecretId/SecretKey 由平台管理员在前端配置,服务端使用 AES-256-GCM 加密,接口不返回明文
未落地范围见 [初版交接说明](docs/handoff.md)。
## 本地开发
@@ -32,57 +24,25 @@ pnpm install
pnpm dev
```
- 前端:`http://localhost:5180`
- API`http://localhost:3010`
- 健康检查:`http://localhost:3010/api/health`
- 前端http://localhost:5180
- APIhttp://localhost:3010
- 健康检查http://localhost:3010/api/health
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地图片保存在 `uploads/`这两个目录包含运行数据、账号信息或用户文件,已排除在 Git 之外。
未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`两者均包含运行数据或用户文件,已排除在 Git 之外。
SQLite 首次启动会创建开发账号并要求首次登录改密。不要把这些开发账号用于公网环境
复制 `.env.example``.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL``COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面或部署密钥注入,不能提交到 Git
## 环境配置
复制 `.env.example``.env`,再按环境填写。关键变量:
| 变量 | 用途 |
|---|---|
| `DATABASE_URL` | PostgreSQL 连接串;留空时使用 SQLite |
| `PGSSL` / `PG_POOL_MAX` | PostgreSQL SSL 与连接池配置 |
| `CORS_ORIGIN` | 允许携带凭据访问 API 的前端来源,多个值以逗号分隔 |
| `COS_CONFIG_ENCRYPTION_KEY` | 加密前端保存的 COS 凭证,至少 32 个随机字符 |
| `INITIAL_ADMIN_PASSWORD` | 空 PostgreSQL 首次初始化的平台管理员临时密码 |
`COS_CONFIG_ENCRYPTION_KEY` 一经用于保存 COS 配置后必须稳定保管,更换会导致旧密文无法解密。真实 COS 凭证不得写入 `.env.example`、源码、镜像或日志。
## PostgreSQL 迁移
## PostgreSQL 与 Docker
```bash
pnpm db:postgres:validate
pnpm db:postgres:migrate
```
目标数据库已有数据时迁移会拒绝覆盖。确认替换时才可执行:
```bash
pnpm db:postgres:migrate -- --replace
```
## Docker 部署
`.env` 中至少设置 `POSTGRES_PASSWORD``COS_CONFIG_ENCRYPTION_KEY``INITIAL_ADMIN_PASSWORD`,然后运行:
```bash
docker compose up -d --build
```
应用通过 `http://服务器地址:3010` 同时提供前端与 API。公网环境应在前面配置 HTTPS 反向代理;生产 Cookie 会自动添加 `Secure`
目标 PostgreSQL 已有数据时迁移默认拒绝覆盖。仅确认替换时使用 `pnpm db:postgres:migrate -- --replace`
## 文档与检查
- [架构与数据模型](docs/architecture.md)
- [API 接入指南](docs/integration-guide.md)
- [部署与运维手册](docs/operator-runbook.md)
- [初版交接说明](docs/handoff.md)
## 检查
```bash
pnpm check
@@ -93,3 +53,10 @@ pnpm test:collection-status
pnpm test:postgres-runtime
pnpm db:postgres:validate
```
更多资料:
- [架构与数据模型](docs/architecture.md)
- [API 接入指南](docs/integration-guide.md)
- [部署与运维手册](docs/operator-runbook.md)
- [初版交接说明](docs/handoff.md)

View File

@@ -22,6 +22,8 @@ import groupsRoutes from './routes/groups.js';
import managementRoutes from './routes/management.js';
import storageRoutes from './routes/storage.js';
import reviewRoutes from './routes/review.js';
import worksRoutes from './routes/works.js';
import projectWorksRoutes from './routes/projectWorks.js';
import { UPLOADS_DIR } from './upload.js';
import { database, databaseDialect } from './database.js';
@@ -50,6 +52,8 @@ app.use(
* API 路由
*/
app.use('/api/notes', notesRoutes);
app.use('/api/works', worksRoutes);
app.use('/api/projects/:projectId/works', projectWorksRoutes);
app.use('/api/images', imagesRoutes);
app.use('/api/annotations', annotationsRoutes);
app.use('/api/projects', projectsRoutes);

View File

@@ -29,7 +29,10 @@ if (databaseUrl) {
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(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '');
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '')
.replace(/-- PROJECT_REVIEW_STATUS_REPAIR_START[\s\S]+?-- PROJECT_REVIEW_STATUS_REPAIR_END/, '')
.replace(/-- TEXT_ANNOTATION_TARGET_REPAIR_START[\s\S]+?-- TEXT_ANNOTATION_TARGET_REPAIR_END/, '')
.replace(/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_REPAIR_END/, '');
}
await pool.query(schema);
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);

105
api/db.ts
View File

@@ -102,6 +102,8 @@ db.exec(`
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
review_status TEXT NOT NULL DEFAULT 'draft',
review_completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS collections (
@@ -116,6 +118,7 @@ db.exec(`
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER,
external_id TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@@ -136,16 +139,23 @@ db.exec(`
x REAL NOT NULL,
y REAL NOT NULL,
content TEXT NOT NULL,
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS work_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
@@ -154,9 +164,30 @@ db.exec(`
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
target TEXT NOT NULL,
start_offset INTEGER NOT NULL DEFAULT 0,
end_offset INTEGER NOT NULL DEFAULT 0,
selected_text TEXT NOT NULL DEFAULT '',
prefix_text TEXT NOT NULL DEFAULT '',
suffix_text TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS feedback_replies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
feedback_type TEXT NOT NULL,
feedback_id INTEGER NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL,
author_role TEXT NOT NULL,
withdrawn_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
@@ -182,6 +213,7 @@ db.exec(`
selected_version_number INTEGER,
created_by INTEGER,
completed_at TEXT,
completion_reason TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(note_id, round_number),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
@@ -211,6 +243,7 @@ function addColumn(table: string, definition: string) {
}
addColumn('notes', "collection_id INTEGER");
addColumn('notes', 'project_id INTEGER');
addColumn('notes', 'external_id TEXT');
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
@@ -219,7 +252,12 @@ 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('annotations', "author_role TEXT NOT NULL DEFAULT 'client'");
addColumn('annotations', 'withdrawn_at TEXT');
addColumn('annotations', "closure_reason TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'group_id INTEGER');
addColumn('projects', "review_status TEXT NOT NULL DEFAULT 'draft'");
addColumn('projects', 'review_completed_at TEXT');
addColumn('projects', "access_password_hash TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0');
addColumn('projects', 'access_expires_at TEXT');
@@ -228,9 +266,21 @@ 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_comments', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('work_comments', 'withdrawn_at TEXT');
addColumn('work_comments', "closure_reason TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', 'start_offset INTEGER NOT NULL DEFAULT 0');
addColumn('text_annotations', 'end_offset INTEGER NOT NULL DEFAULT 0');
addColumn('text_annotations', "selected_text TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', "prefix_text TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', "suffix_text TEXT NOT NULL DEFAULT ''");
addColumn('text_annotations', "author_role TEXT NOT NULL DEFAULT 'client'");
addColumn('text_annotations', 'withdrawn_at TEXT');
addColumn('text_annotations', "closure_reason TEXT NOT NULL DEFAULT ''");
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'");
addColumn('review_rounds', "completion_reason TEXT NOT NULL DEFAULT ''");
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 != ''");
@@ -270,6 +320,8 @@ if (!collectionId) {
}
db.prepare('UPDATE notes SET collection_id = ? WHERE collection_id IS NULL').run(collectionId);
db.prepare('UPDATE projects SET group_id = ? WHERE group_id IS NULL').run(groupId);
db.prepare('UPDATE notes SET project_id = (SELECT c.project_id FROM collections c WHERE c.id = notes.collection_id) WHERE project_id IS NULL').run();
db.prepare('UPDATE work_comments SET version_number = (SELECT n.version_number FROM notes n WHERE n.id = work_comments.note_id) WHERE version_number IS NULL OR version_number < 1').run();
db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集'
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)
@@ -300,6 +352,22 @@ db.exec(`
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 projects
SET review_status = CASE
WHEN status = 'archived' THEN 'archived'
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft') THEN 'draft'
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft' AND n.review_status != 'approved') THEN 'completed'
ELSE 'reviewing'
END,
review_completed_at = CASE
WHEN status != 'archived'
AND EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft')
AND NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft' AND n.review_status != 'approved')
THEN COALESCE(review_completed_at, datetime('now'))
ELSE NULL
END;
`);
db.exec(`
UPDATE collections
SET status = CASE
@@ -319,10 +387,12 @@ db.exec(`
db.exec(`
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes(collection_id);
CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id);
CREATE INDEX IF NOT EXISTS idx_images_note_id ON images(note_id);
CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations(image_id);
CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id);
@@ -336,4 +406,39 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number);
`);
export function repairMultiSchemeRounds() {
db.transaction(() => {
const duplicates = db.prepare(`SELECT review_round_id, note_id FROM work_versions WHERE review_round_id IS NOT NULL GROUP BY review_round_id, note_id HAVING COUNT(*) > 1`).all() as Array<{ review_round_id: number; note_id: number }>;
for (const duplicate of duplicates) {
const round = db.prepare('SELECT * FROM review_rounds WHERE id=?').get(duplicate.review_round_id) as Record<string, unknown> | undefined;
const note = db.prepare('SELECT active_round_id,version_number FROM notes WHERE id=?').get(duplicate.note_id) as { active_round_id: number | null; version_number: number } | undefined;
const versions = db.prepare('SELECT version_number FROM work_versions WHERE review_round_id=? ORDER BY version_number').all(duplicate.review_round_id) as Array<{ version_number: number }>;
if (!round || !note || versions.length < 2) continue;
const keeper = versions.some((item) => Number(item.version_number) === Number(note.version_number)) ? Number(note.version_number) : Number(versions[0].version_number);
for (const version of versions.filter((item) => Number(item.version_number) !== keeper)) {
const nextRound = Number((db.prepare('SELECT COALESCE(MAX(round_number),0)+1 AS value FROM review_rounds WHERE note_id=?').get(duplicate.note_id) as { value: number }).value);
const remainsActive = Number(note.active_round_id) === Number(duplicate.review_round_id) && Number(note.version_number) === Number(version.version_number);
const result = db.prepare(`INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason)
VALUES (?,?,?,?,?,?,?,?)`).run(
duplicate.note_id,
nextRound,
remainsActive ? round.status : 'completed',
Number(round.selected_version_number) === Number(version.version_number) ? version.version_number : null,
remainsActive ? round.completed_at : (round.completed_at || new Date().toISOString()),
round.created_by ?? null,
round.created_at,
remainsActive ? round.completion_reason : (round.completion_reason || 'migrated_single_scheme'),
);
const newRoundId = Number(result.lastInsertRowid);
db.prepare('UPDATE work_versions SET review_round_id=? WHERE note_id=? AND version_number=?').run(newRoundId, duplicate.note_id, version.version_number);
if (remainsActive) db.prepare('UPDATE notes SET active_round_id=? WHERE id=?').run(newRoundId, duplicate.note_id);
}
if (Number(round.selected_version_number) !== keeper) db.prepare('UPDATE review_rounds SET selected_version_number=NULL WHERE id=?').run(duplicate.review_round_id);
}
})();
}
repairMultiSchemeRounds();
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''");
db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL');
export default db;

View File

@@ -6,11 +6,11 @@ function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Num
export const annotationsRepository = {
async listByImage(imageId: number): Promise<Annotation[]> {
return (await database.all<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation);
return (await database.all<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, author_role, status, closure_reason, withdrawn_at, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation);
},
async create(imageId: number, data: CreateAnnotationRequest): Promise<Annotation> {
const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name) VALUES (?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户']);
return toAnnotation((await database.one<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE id = ?', [id]))!);
const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name, author_role) VALUES (?, ?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户', data.author_role || 'client']);
return toAnnotation((await database.one<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, author_role, status, closure_reason, withdrawn_at, created_at FROM annotations WHERE id = ?', [id]))!);
},
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; },
};

View File

@@ -2,7 +2,7 @@ import { database } from '../database.js';
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;
id: number; project_id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: 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;
}
@@ -10,7 +10,7 @@ interface NoteRow {
function toNote(row: NoteRow): Note {
return {
...row,
id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number),
id: Number(row.id), project_id: Number(row.project_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[],
@@ -19,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.active_round_id, n.approved_version_number, n.created_at,
SELECT n.id, n.project_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,
@@ -38,8 +38,8 @@ export const notesRepository = {
if (query.status) { conditions.push('n.review_status = ?'); params.push(query.status); }
if (query.q?.trim()) { conditions.push('(n.title LIKE ? OR n.description LIKE ?)'); params.push(`%${query.q.trim()}%`, `%${query.q.trim()}%`); }
if (query.tag?.trim()) { conditions.push('n.tags LIKE ?'); params.push(`%"${query.tag.trim()}"%`); }
if (query.projectId) { conditions.push('n.collection_id IN (SELECT id FROM collections WHERE project_id = ?)'); params.push(query.projectId); }
if (query.groupId) { conditions.push('n.collection_id IN (SELECT c.id FROM collections c JOIN projects p ON p.id = c.project_id WHERE p.group_id = ?)'); params.push(query.groupId); }
if (query.projectId) { conditions.push('n.project_id = ?'); params.push(query.projectId); }
if (query.groupId) { conditions.push('n.project_id IN (SELECT id FROM projects WHERE group_id = ?)'); params.push(query.groupId); }
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
const order = query.order === 'asc' ? 'ASC' : 'DESC';
const sort = query.sort === 'annotations' ? 'annotation_count' : 'n.created_at';
@@ -53,6 +53,10 @@ export const notesRepository = {
const row = await database.one<NoteRow>(`${select} WHERE n.collection_id = ? AND n.external_id = ?`, [collectionId, externalId]);
return row ? toNote(row) : null;
},
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> {
const row = await database.one<NoteRow>(`${select} WHERE n.project_id = ? AND n.external_id = ?`, [projectId, externalId]);
return row ? toNote(row) : null;
},
async create(title: string, description: string, collectionId: number, tags: string[]): Promise<number> {
return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']);
},

View File

@@ -1,5 +1,4 @@
import { Router, type Response } from 'express';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
@@ -8,10 +7,13 @@ const router = Router();
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.annotationId);
if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM annotations a JOIN images i ON i.id = a.image_id JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE a.id = ?', [id]);
const context = await database.one<{ project_id: number; version_number: number; current_version: number; author_name: string; author_role: string; round_status: string; project_status: string; project_review_status: string }>(`SELECT n.project_id,i.version_number,n.version_number AS current_version,a.author_name,a.author_role,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status
FROM annotations a JOIN images i ON i.id=a.image_id JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE a.id=?`, [id]);
if (!context) { res.status(404).json({ error: '批注不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该批注' }); return; }
await annotationsRepository.remove(id);
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (context.author_role !== 'operator' || context.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的批注' }); return; }
await database.execute('UPDATE annotations SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL', [id]);
res.status(204).end();
});

View File

@@ -7,20 +7,22 @@ const router = Router();
router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId); const content = String(req.body?.content ?? '').trim();
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 = ?', [noteId]);
const context = await database.one<{ project_id: number; version_number: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,n.version_number,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id = ?', [noteId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.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 (?, ?, ?, 'operator')", [noteId, content, req.authUser?.display_name || 'API']);
const id = await database.insertId("INSERT INTO work_comments (note_id, version_number, content, author_name, author_role) VALUES (?, ?, ?, ?, 'operator')", [noteId, context.version_number, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.commentId); const status = req.body?.status;
if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_comments wc JOIN notes n ON n.id = wc.note_id JOIN collections c ON c.id = n.collection_id WHERE wc.id = ?', [id]);
const context = await database.one<{ project_id: number; version_number: number; current_version: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,wc.version_number,n.version_number AS current_version,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM work_comments wc JOIN notes n ON n.id=wc.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE wc.id = ?', [id]);
if (!context) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该反馈' }); return; }
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
await database.execute('UPDATE work_comments SET status = ? WHERE id = ?', [status, id]);
res.json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});

View File

@@ -7,7 +7,7 @@ import { database } from '../database.js';
const router = Router();
async function imageProjectId(imageId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT c.project_id FROM images i JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE i.id = ?', [imageId]))?.project_id;
return (await database.one<{ project_id: number }>('SELECT n.project_id FROM images i JOIN notes n ON n.id = i.note_id WHERE i.id = ?', [imageId]))?.project_id;
}
router.get('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
@@ -26,14 +26,14 @@ 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 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]);
const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; project_review_status: string; project_status: string }>('SELECT n.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status FROM images i JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_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; }
if (context.project_status !== 'active' || context.project_review_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; }
if (!content) { res.status(400).json({ error: '批注内容不能为空' }); return; }
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API' }));
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API', author_role: 'operator' }));
} catch (error) { next(error); }
});

View File

@@ -5,10 +5,10 @@ import { Router, type Response, type NextFunction } from 'express';
import { upload } from '../upload.js';
import { notesService } from '../services/notesService.js';
import { recalculateCollectionStatus } from '../services/collectionsService.js';
import { recalculateProjectReviewStatus } from '../services/projectsService.js';
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
import { database, withTransaction } from '../database.js';
import fs from 'fs';
import type { TextAnnotation } from '../../shared/types.js';
const router = Router();
@@ -79,19 +79,8 @@ router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, ne
});
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
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(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; 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]));
res.setHeader('Deprecation', 'true');
res.status(410).json({ error: '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' });
});
// POST /api/notes - 上传新笔记 (multipart/form-data)
@@ -124,12 +113,13 @@ router.post(
return;
}
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]);
const collection = await database.one<{ project_id: number; project_status: string }>('SELECT c.project_id,p.status AS project_status FROM collections c JOIN projects p ON p.id=c.project_id WHERE c.id = ?', [collectionId]);
if (!collection || !await canWriteProject(req, collection.project_id)) {
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
return;
}
if (collection.project_status !== 'active') { files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* noop */ } }); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
if (externalId) {
const existing = await notesService.findByExternalId(collectionId, externalId);
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
@@ -167,9 +157,10 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
try {
const id = Number(req.params.noteId);
const context = await database.one<{ title: string; description: string; tags: string; project_id: number }>('SELECT n.title, n.description, n.tags, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const context = await database.one<{ title: string; description: string; tags: string; project_id: number; project_status: string }>('SELECT n.title,n.description,n.tags,n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 130 个有效的 HTTP/HTTPS 图片 URL' }); return; }
if (!files.length && !imageUrls.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; }
@@ -190,11 +181,12 @@ router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30),
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]);
const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_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; }
if (context.project_status !== 'active') { cleanupFiles(); res.status(409).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; }
if (!rawCandidates || rawCandidates.length !== 1) { cleanupFiles(); res.status(400).json({ error: '每个验收轮次只能提交一个方案' }); return; }
const normalized = rawCandidates.map((candidate, index) => ({
candidate_name: String(candidate.candidate_name ?? `方案 ${String.fromCharCode(65 + index)}`).trim(),
@@ -215,13 +207,15 @@ router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30),
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);
const only = uploadCandidates[0];
note = await notesService.createRound(id, { title: only.title, description: only.description, tags: only.tags, files: only.files }, 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);
const only = normalized[0];
note = await notesService.createRoundFromUrls(id, { title: only.title, description: only.description, tags: only.tags, images: only.imageUrls.urls }, req.authUser?.id);
}
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: normalized.length, versionNumber: note.version_number });
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: 1, versionNumber: note.version_number, deprecatedRoute: true });
res.status(201).json(note);
} catch (error) {
cleanupFiles();
@@ -236,8 +230,9 @@ router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Res
res.status(400).json({ error: '无效的验收状态' });
return;
}
const context = await database.one<{ project_id: number; review_status: string }>('SELECT c.project_id, n.review_status FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
const context = await database.one<{ project_id: number; review_status: string; project_status: string }>('SELECT n.project_id,n.review_status,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; }
if (!await notesService.setStatus(id, status)) {
res.status(404).json({ error: '作品不存在' });
@@ -250,9 +245,10 @@ 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; 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]);
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number; active_round_id: number | null; project_status: string }>('SELECT n.review_status,n.version_number,n.project_id,n.collection_id,n.active_round_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_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.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
const actor = req.authUser!;
await withTransaction(async (tx) => {
@@ -262,6 +258,7 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
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);
await recalculateProjectReviewStatus(Number(note.project_id), tx);
});
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
res.json({ success: true, status: 'pending' });
@@ -274,8 +271,9 @@ router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response)
res.status(400).json({ error: '无效的笔记 ID' });
return;
}
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]);
const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const ok = await notesService.remove(id);
if (!ok) {
res.status(404).json({ error: '笔记不存在' });

View File

@@ -0,0 +1,72 @@
import fs from 'node:fs';
import { Router, type NextFunction, type Response } from 'express';
import { canWriteProject, requireWriter, audit, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
import { notesService } from '../services/notesService.js';
import { upload } from '../upload.js';
const router = Router({ mergeParams: true });
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
const raw = String(value ?? '').trim();
if (!raw) return [];
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
catch { /* comma-separated form input */ }
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
}
function parseImageUrls(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
const urls = value.map((item) => String(item).trim());
return urls.every((url) => {
try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); }
catch { return false; }
}) ? urls : null;
}
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
const projectId = Number(req.params.projectId);
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该项目' }); return; }
const { sort, order, q, status, tag, externalId } = req.query as Record<string, string | undefined>;
res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters<typeof notesService.list>[0]['status'], tag, externalId }));
});
router.post('/', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const projectId = Number(req.params.projectId);
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权向该项目上传作品' }); return; }
const project = await database.one<{ status: string }>('SELECT status FROM projects WHERE id = ?', [projectId]);
if (!project) { cleanup(); res.status(404).json({ error: '项目不存在' }); return; }
if (project.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const title = String(req.body?.title ?? '').trim();
const description = String(req.body?.description ?? '').trim();
const tags = parseTags(req.body?.tags);
const externalId = String(req.body?.externalId ?? req.body?.external_id ?? '').trim() || null;
const imageUrls = parseImageUrls(req.body?.images);
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) { cleanup(); res.status(400).json({ error: 'externalId 格式无效' }); return; }
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '请提供 130 张上传图片或公开图片 URL' }); return; }
if (externalId) {
const existing = await notesService.findByProjectExternalId(projectId, externalId);
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
}
let work;
try {
work = files.length
? await notesService.createInProject(projectId, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, externalId)
: await notesService.createInProjectFromUrls(projectId, title, description, imageUrls, tags, externalId);
} catch (error) {
const existing = externalId ? await notesService.findByProjectExternalId(projectId, externalId) : null;
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
throw error;
}
await audit(req, 'work.create', 'work', work.id, { projectId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
res.status(201).json(work);
} catch (error) { cleanup(); next(error); }
});
export default router;

View File

@@ -5,22 +5,29 @@ import type { Project, WorkCollection } from '../../shared/types.js';
const router = Router();
const reader = requireWriter;
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string; pending_count: number | string; changes_requested_count: number | string; approved_count: number | string };
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
function projectSelect(where: string) {
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.review_status, p.review_completed_at, p.created_at,
p.customer_access_enabled, p.access_expires_at,
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
COALESCE(cc.collection_count, 0) AS collection_count,
COALESCE(wc.work_count, 0) AS work_count
COALESCE(wc.work_count, 0) AS work_count,
COALESCE(wc.pending_count, 0) AS pending_count,
COALESCE(wc.changes_requested_count, 0) AS changes_requested_count,
COALESCE(wc.approved_count, 0) AS approved_count
FROM projects p
JOIN operation_groups g ON g.id = p.group_id
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
LEFT JOIN (SELECT project_id, COUNT(*) AS work_count,
SUM(CASE WHEN review_status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
SUM(CASE WHEN review_status = 'changes_requested' THEN 1 ELSE 0 END) AS changes_requested_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
FROM notes GROUP BY project_id) wc ON wc.project_id = p.id
${where}`;
}
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; }
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count), pending_count: Number(row.pending_count), changes_requested_count: Number(row.changes_requested_count), approved_count: Number(row.approved_count) }; }
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
router.get('/', reader, async (req: AuthRequest, res: Response) => {

View File

@@ -4,30 +4,173 @@ import { createCustomerSession, customerSessionCookie, optionalCustomer, require
import { verifyPassword } from '../auth.js';
import { notesService } from '../services/notesService.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { decideCandidate, ReviewDecisionError } from '../services/reviewService.js';
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
import { decideRound, ReviewDecisionError } from '../services/reviewService.js';
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
const router=Router();router.use(optionalCustomer);
type ProjectAccess={id:number;name:string;slug:string;client_description:string;status:string;customer_access_enabled:boolean|number;access_password_hash:string;access_expires_at:string|Date|null};
const projectBySlug=(slug:string)=>database.one<ProjectAccess>(`SELECT id,name,slug,client_description,status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?`,[slug]);
const expired=(value:string|Date|null)=>Boolean(value&&new Date(value).getTime()<=Date.now());
const router = Router();
router.use(optionalCustomer);
router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}res.json({project_name:project.name,client_description:project.client_description,enabled:Boolean(project.customer_access_enabled),expired:expired(project.access_expires_at),authenticated:Boolean(req.customer?.project_id===Number(project.id)),reviewer_name:req.customer?.project_id===Number(project.id)?req.customer.reviewer_name:null})});
type ProjectAccess = {
id: number; name: string; slug: string; client_description: string; status: string; review_status: string;
customer_access_enabled: boolean | number; access_password_hash: string; access_expires_at: string | Date | null;
};
router.post('/:slug/login',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);const reviewerName=String(req.body?.reviewer_name??'').trim();const password=String(req.body?.password??'');if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}if(!project.customer_access_enabled){res.status(403).json({error:'该项目暂未开放客户访问'});return}if(expired(project.access_expires_at)){res.status(403).json({error:'项目访问链接已到期'});return}if(reviewerName.length<2||reviewerName.length>30){res.status(400).json({error:'请填写 230 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})});
const projectBySlug = (slug: string) => database.one<ProjectAccess>(
'SELECT id,name,slug,client_description,status,review_status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?',
[slug],
);
const expired = (value: string | Date | null) => Boolean(value && new Date(value).getTime() <= Date.now());
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
const storedTagsText = (value: string) => { try { const parsed = JSON.parse(value || '[]'); return Array.isArray(parsed) ? parsed.map(String).join(' ') : ''; } catch { return ''; } };
router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all<Record<string,unknown>>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})});
router.get('/:slug/access', async (req: CustomerRequest, res: Response) => {
const project = await projectBySlug(req.params.slug);
if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; }
res.json({ project_name: project.name, client_description: project.client_description, enabled: Boolean(project.customer_access_enabled), expired: expired(project.access_expires_at), authenticated: Boolean(req.customer?.project_id === Number(project.id)), reviewer_name: req.customer?.project_id === Number(project.id) ? req.customer.reviewer_name : null });
});
router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one<Record<string,unknown>>(`SELECT c.*,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.id=? AND c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at`,[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection:{...collection,id:Number(collection.id),project_id:Number(collection.project_id),work_count:Number(collection.work_count),approved_count:Number(collection.approved_count)},works})});
router.post('/:slug/login', async (req: CustomerRequest, res: Response) => {
const project = await projectBySlug(req.params.slug);
const reviewerName = String(req.body?.reviewer_name ?? '').trim();
const password = String(req.body?.password ?? '');
if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; }
if (!project.customer_access_enabled) { res.status(403).json({ error: '该项目暂未开放客户访问' }); return; }
if (expired(project.access_expires_at)) { res.status(403).json({ error: '项目访问链接已到期' }); return; }
if (reviewerName.length < 2 || reviewerName.length > 30) { res.status(400).json({ error: '请填写 230 个字符的姓名' }); return; }
if (!project.access_password_hash || !verifyPassword(password, project.access_password_hash)) { res.status(401).json({ error: '访问密码错误' }); return; }
const token = await createCustomerSession(Number(project.id), reviewerName);
res.setHeader('Set-Cookie', customerSessionCookie(token));
res.json({ success: true, reviewer_name: reviewerName });
});
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.get('/:slug/project', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft');
res.json({
project: { id: Number(project.id), name: project.name, slug: project.slug, client_description: project.client_description, status: project.status, review_status: project.review_status },
works,
reviewer_name: req.customer!.reviewer_name,
});
});
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.get('/:slug/collections/:collectionId/works', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft');
res.setHeader('Deprecation', 'true');
res.json({ redirect_to: `/review/${project.slug}`, works });
});
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.get('/:slug/works/:workId', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const belongs = await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=? AND project_id=?', [workId, project.id]);
if (!belongs || belongs.review_status === 'draft') { res.status(404).json({ error: '作品不存在或尚未提交' }); return; }
const round = req.query.round ? Number(req.query.round) : undefined;
const version = req.query.version ? Number(req.query.version) : undefined;
const detail = await notesService.getDetail(workId, version, round);
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
res.json(detail);
});
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.get('/:slug/works/:workId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=? AND review_status!=?', [workId, project.id, 'draft'])) { res.status(404).json({ error: '作品不存在' }); return; }
res.json(await notesService.getFeedback(workId));
});
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}});
router.post('/:slug/works/:workId/comments', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const content = String(req.body?.content ?? '').trim();
const work = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND n.project_id=? AND n.review_status!=?', [workId, project.id, 'draft']);
if (!work) { res.status(404).json({ error: '作品不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || work.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,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [workId, work.version_number, content, req.customer!.reviewer_name]);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?', [id]));
});
router.post('/:slug/works/:workId/text-annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const target = req.body?.target as 'title' | 'description' | 'tags';
const startOffset = Number(req.body?.start_offset);
const endOffset = Number(req.body?.end_offset);
const selectedText = String(req.body?.selected_text ?? '');
const content = String(req.body?.content ?? '').trim();
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,r.status AS round_status
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id
WHERE v.note_id=? AND r.round_number=? AND n.project_id=?`, [workId, roundNumber, project.id]);
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? storedTagsText(version.tags) : '';
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?,?,?,?,'client')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, source.slice(Math.max(0, startOffset - 24), startOffset), source.slice(endOffset, endOffset + 24), 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 context = await database.one<{ review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.review_round_id,n.active_round_id,r.status AS round_status FROM images i JOIN notes n ON n.id=i.note_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 n.project_id=? AND n.review_status!='draft'`, [imageId, project.id]);
if (!context) { res.status(404).json({ error: '图片不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.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, author_role: 'client' }));
});
router.post('/:slug/works/:workId/feedback/:type/:feedbackId/replies', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId);
const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const content = String(req.body?.content ?? '').trim();
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 11000 个字符' }); return; }
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.customer!.reviewer_name, 'client'));
});
router.post('/:slug/works/:workId/feedback/:type/:feedbackId/withdraw', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId);
const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (target.withdrawn_at) { res.json({ success: true }); return; }
if (target.author_role !== 'client' || target.author_name !== req.customer!.reviewer_name) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
await withdrawFeedback(type, feedbackId); res.json({ success: true });
});
router.post('/:slug/works/:workId/decision', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
const project = (await projectBySlug(req.params.slug))!;
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const legacyVersion = Number(req.body?.version_number);
const round = Number.isInteger(roundNumber) ? await database.one<{ version_number: number }>('SELECT v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id=r.id WHERE r.note_id=? AND r.round_number=?', [workId, roundNumber]) : undefined;
const versionNumber = round ? Number(round.version_number) : legacyVersion;
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 decideRound({ noteId: workId, 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;

182
api/routes/works.ts Normal file
View File

@@ -0,0 +1,182 @@
import fs from 'node:fs';
import { Router, type NextFunction, type Response } from 'express';
import { audit, canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
import { notesService } from '../services/notesService.js';
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
import { upload } from '../upload.js';
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
const router = Router();
function parseTags(value: unknown): string[] {
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
const raw = String(value ?? '').trim();
if (!raw) return [];
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
catch { /* comma-separated form input */ }
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
}
function parseImageUrls(value: unknown): string[] | null {
if (value === undefined) return [];
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
const urls = value.map((item) => String(item).trim());
return urls.every((url) => { try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); } catch { return false; } }) ? urls : null;
}
async function workProjectId(workId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT project_id FROM notes WHERE id = ?', [workId]))?.project_id;
}
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
router.get('/:workId', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品' }); return; }
const round = req.query.round ? Number(req.query.round) : undefined;
const detail = await notesService.getDetail(workId, undefined, round);
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
res.json(detail);
});
router.get('/:workId/annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
res.json(await notesService.getFeedback(workId));
});
router.get('/:workId/optimization-context', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const roundNumber = Number(req.query.round);
const includeHistory = req.query.include_history === 'true';
if (!Number.isInteger(roundNumber) || roundNumber < 1) { res.status(400).json({ error: '请指定有效的验收轮次' }); return; }
const projectId = await workProjectId(workId);
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
const [detail, feedback] = await Promise.all([
notesService.getDetail(workId, undefined, roundNumber),
notesService.getFeedback(workId),
]);
const round = feedback?.rounds.find((item) => item.round_number === roundNumber);
if (!detail || !round) { res.status(404).json({ error: '验收轮次不存在' }); return; }
const visible = (item: { status: string; withdrawn_at: string | null }) => includeHistory || (item.status === 'open' && !item.withdrawn_at);
const repliesFor = (type: FeedbackType, id: number) => round.feedback_replies.filter((reply) => reply.feedback_type === type && reply.feedback_id === id && (includeHistory || !reply.withdrawn_at));
const withReplies = <T extends { id: number }>(type: FeedbackType, item: T) => ({ ...item, replies: repliesFor(type, item.id) });
res.json({
project: detail.project,
work_id: workId,
work_label: `Work ${String(workId).padStart(3, '0')}`,
round_number: roundNumber,
version_number: round.version_number,
content: {
title: detail.title,
description: detail.description,
tags: detail.tags,
images: detail.images.map(({ id, url, width, height, order_index }) => ({ image_id: id, url, width, height, order_index })),
},
feedback: {
image_annotations: round.image_annotations.filter(visible).map((item) => withReplies('image_annotation', item)),
text_annotations: round.text_annotations.filter(visible).map((item) => withReplies('text_annotation', item)),
general_comments: round.comments.filter(visible).map((item) => withReplies('comment', item)),
},
});
});
router.post('/:workId/rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
try {
const workId = Number(req.params.workId);
const projectId = await workProjectId(workId);
if (!projectId) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权操作该作品' }); return; }
const projectState = await database.one<{ status: string }>('SELECT status FROM projects WHERE id=?', [projectId]);
if (!projectState || projectState.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
const current = await notesService.getDetail(workId);
if (!current) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
const title = String(req.body?.title ?? current.title).trim();
const description = String(req.body?.description ?? current.description).trim();
const tags = parseTags(req.body?.tags ?? current.tags);
const imageUrls = parseImageUrls(req.body?.images);
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '每轮必须提供 130 张图片' }); return; }
const work = files.length
? await notesService.createRound(workId, { title, description, tags, files: files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) }, req.authUser?.id)
: await notesService.createRoundFromUrls(workId, { title, description, tags, images: imageUrls }, req.authUser?.id);
await audit(req, 'work.round_create', 'work', workId, { roundNumber: work.version_number, imageCount: files.length || imageUrls.length });
res.status(201).json(work);
} catch (error) { cleanup(); next(error); }
});
router.post('/:workId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const roundNumber = Number(req.body?.round_number);
const target = req.body?.target as 'title' | 'description' | 'tags';
const startOffset = Number(req.body?.start_offset);
const endOffset = Number(req.body?.end_offset);
const selectedText = String(req.body?.selected_text ?? '');
const content = String(req.body?.content ?? '').trim();
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string; project_id: number; project_review_status: string; project_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,n.project_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id JOIN projects p ON p.id=n.project_id
WHERE v.note_id=? AND r.round_number=?`, [workId, roundNumber]);
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
if (!await canWriteProject(req, Number(version.project_id))) { res.status(403).json({ error: '无权批注该作品' }); return; }
if (version.project_status !== 'active' || version.project_review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? parseTags(version.tags).join(' ') : '';
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const prefix = source.slice(Math.max(0, startOffset - 24), startOffset);
const suffix = source.slice(endOffset, endOffset + 24);
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?,?,?,?, 'operator')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, prefix, suffix, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { workId, roundNumber, target, startOffset, endOffset });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id = ?', [id]));
});
router.post('/:workId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId);
const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ project_id: number; version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.project_id,n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, Number(context.project_id))) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (context.project_status !== 'active' || context.review_status === 'completed' || context.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,version_number,content,author_name,author_role) VALUES (?,?,?,?, 'operator')", [workId, context.version_number, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.post('/:workId/feedback/:type/:feedbackId/replies', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
const content = String(req.body?.content ?? '').trim(); const projectId = await workProjectId(workId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 11000 个字符' }); return; }
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.authUser?.display_name || 'API', 'operator'));
});
router.post('/:workId/feedback/:type/:feedbackId/withdraw', requireWriter, async (req: AuthRequest, res: Response) => {
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const projectId = await workProjectId(workId);
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
const target = await findFeedbackTarget(workId, type, feedbackId);
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
if (target.withdrawn_at) { res.json({ success: true }); return; }
if (target.author_role !== 'operator' || target.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
await withdrawFeedback(type, feedbackId); res.json({ success: true });
});
export default router;

View File

@@ -0,0 +1,30 @@
import { database } from '../database.js';
import type { FeedbackReply, FeedbackType } from '../../shared/types.js';
export type FeedbackTarget = {
note_id: number;
version_number: number;
author_name: string;
author_role: 'client' | 'operator';
withdrawn_at: string | null;
};
export async function findFeedbackTarget(workId: number, type: FeedbackType, feedbackId: number): Promise<FeedbackTarget | undefined> {
if (type === 'image_annotation') {
return database.one<FeedbackTarget>(`SELECT i.note_id,i.version_number,a.author_name,a.author_role,a.withdrawn_at
FROM annotations a JOIN images i ON i.id=a.image_id WHERE a.id=? AND i.note_id=?`, [feedbackId, workId]);
}
const table = type === 'text_annotation' ? 'text_annotations' : 'work_comments';
return database.one<FeedbackTarget>(`SELECT note_id,version_number,author_name,author_role,withdrawn_at FROM ${table} WHERE id=? AND note_id=?`, [feedbackId, workId]);
}
export async function addFeedbackReply(target: FeedbackTarget, type: FeedbackType, feedbackId: number, content: string, authorName: string, authorRole: 'client' | 'operator'): Promise<FeedbackReply> {
const id = await database.insertId(`INSERT INTO feedback_replies (note_id,version_number,feedback_type,feedback_id,content,author_name,author_role)
VALUES (?,?,?,?,?,?,?)`, [target.note_id, target.version_number, type, feedbackId, content, authorName, authorRole]);
return (await database.one<FeedbackReply>('SELECT * FROM feedback_replies WHERE id=?', [id]))!;
}
export async function withdrawFeedback(type: FeedbackType, feedbackId: number): Promise<void> {
const table = type === 'image_annotation' ? 'annotations' : type === 'text_annotation' ? 'text_annotations' : 'work_comments';
await database.execute(`UPDATE ${table} SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL`, [feedbackId]);
}

View File

@@ -1,18 +1,19 @@
import sharp from 'sharp';
import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus } from '../../shared/types.js';
import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus, WorkFeedbackBundle, WorkRound } from '../../shared/types.js';
import { notesRepository } from '../repositories/notesRepository.js';
import { imagesRepository } from '../repositories/imagesRepository.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js';
import { storeUploadedFile } from '../storage.js';
import { recalculateCollectionStatus } from './collectionsService.js';
import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.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[] }
export interface UploadRound { title: string; description: string; tags: string[]; files: UploadedFile[] }
export interface UrlRound { 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[] };
type PreparedRound = { 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 }; }
@@ -30,59 +31,62 @@ function externalImages(images: string[]): StoredImage[] {
async function createRoundInTransaction(
tx: QueryContext,
noteId: number,
projectId: number,
collectionId: number,
candidates: PreparedCandidate[],
round: PreparedRound,
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]);
): Promise<{ roundId: number; roundNumber: number; versionNumber: 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]);
await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND review_status != 'approved'", [note.active_round_id]);
await tx.execute("UPDATE review_rounds SET status = 'completed', completion_reason = 'superseded', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status IN ('draft', '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 versionNumber = 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(
"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, round.title, round.description, JSON.stringify(round.tags), roundId, createdBy ?? null],
);
await imagesRepository.createMany(noteId, round.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, '工作台'],
);
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],
[round.title, round.description, JSON.stringify(round.tags), versionNumber, roundId, noteId],
);
await recalculateCollectionStatus(collectionId, tx);
return { roundId, roundNumber, firstVersion };
await recalculateProjectReviewStatus(projectId, tx);
return { roundId, roundNumber, versionNumber };
}
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),
})));
async function prepareUploadRound(round: UploadRound): Promise<PreparedRound> {
return { title: round.title, description: round.description, tags: round.tags, images: await prepareFiles(round.files) };
}
function mapRound(row: Omit<WorkRound, 'tags'> & { tags: string }): WorkRound {
return {
...row,
version_number: Number(row.version_number),
review_round_id: Number(row.review_round_id),
round_number: Number(row.round_number),
tags: JSON.parse(row.tags || '[]') as string[],
};
}
export const notesService = {
@@ -90,103 +94,154 @@ export const notesService = {
return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId });
},
async getDetail(id: number, requestedVersion?: number): Promise<NoteDetail | null> {
async getDetail(id: number, requestedVersion?: number, requestedRound?: number): Promise<NoteDetail | null> {
const current = await notesRepository.findById(id);
if (!current) return null;
const selectedVersion = requestedVersion && requestedVersion !== current.version_number
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, requestedVersion])
const requested = requestedRound
? await database.one<{ version_number: number }>('SELECT v.version_number FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? AND r.round_number = ?', [id, requestedRound])
: undefined;
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
const targetVersion = requested ? Number(requested.version_number) : requestedVersion;
const selectedVersion = targetVersion && targetVersion !== current.version_number
? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, targetVersion])
: undefined;
if (targetVersion && targetVersion !== current.version_number && !selectedVersion) return null;
const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current;
const images = await imagesRepository.listByNote(id, note.version_number);
const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string; 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 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
const project = await database.one<{ id: number; name: string; slug: string; status: NoteDetail['project']['status']; review_status: NoteDetail['project']['review_status'] }>('SELECT p.id, p.name, p.slug, p.status, p.review_status FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.id = ?', [id]);
if (!project) return null;
const roundRows = await database.all<Array<Omit<WorkRound, 'tags'> & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id,
v.created_at, r.round_number, r.status AS round_status, r.completion_reason
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]);
WHERE v.note_id = ? ORDER BY r.round_number DESC`, [id]);
const result: NoteDetail = {
...note,
images: [] as ImageWithAnnotations[],
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]),
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), 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 },
text_annotations: await database.all<NoteDetail['text_annotations'][number]>('SELECT * 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 = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
feedback_replies: await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
rounds: roundRows.map(mapRound),
review_events: await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? AND version_number = ? ORDER BY id DESC', [id, note.version_number]),
project: { id: Number(project.id), name: project.name, slug: project.slug, status: project.status, review_status: project.review_status },
};
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
return result;
},
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
async createInProject(projectId: number, title: string, description: string, files: UploadedFile[], 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 (?, ?, ?, ?, ?, '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 withTransaction(async (tx) => {
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
},
async createInProjectFromUrls(projectId: number, title: string, description: string, imageUrls: string[], tags: string[], externalId: string | null = null): Promise<Note> {
return withTransaction(async (tx) => {
const collectionId = await ensureProjectCompatibilityCollection(projectId, tx);
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
},
async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
if (!collection) throw new Error('作品交付集不存在');
const projectId = Number(collection.project_id);
const prepared = await prepareFiles(files);
return withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
return (await notesRepository.findById(noteId))!;
},
async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise<Note> {
const noteId = await withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, '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;
const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]);
if (!collection) throw new Error('作品交付集不存在');
const projectId = Number(collection.project_id);
return withTransaction(async (tx) => {
const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]);
await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft');
return (await notesRepository.findById(id))!;
});
return (await notesRepository.findById(noteId))!;
},
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> {
return notesRepository.findByExternalId(collectionId, externalId);
},
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> { return notesRepository.findByExternalId(collectionId, externalId); },
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> { return notesRepository.findByProjectExternalId(projectId, externalId); },
async createReviewRound(id: number, candidates: UploadCandidate[], createdBy?: number): Promise<Note> {
async createRound(id: number, round: UploadRound, createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const prepared = await prepareUploadCandidates(candidates);
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
const prepared = await prepareUploadRound(round);
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status));
return (await notesRepository.findById(id))!;
},
async createReviewRoundFromUrls(id: number, candidates: UrlCandidate[], createdBy?: number): Promise<Note> {
async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise<Note> {
const current = await notesRepository.findById(id);
if (!current) throw new Error('作品不存在');
const prepared = candidates.map((candidate) => ({ ...candidate, images: externalImages(candidate.images) }));
await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status));
const prepared = { ...round, images: externalImages(round.images) };
await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_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);
return this.createRound(id, { 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);
return this.createRoundFromUrls(id, { title, description, tags, images: imageUrls }, createdBy);
},
async getFeedback(id: number): Promise<WorkFeedbackBundle | null> {
const rounds = await database.all<{ round_number: number; version_number: number }>('SELECT r.round_number, v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id = r.id WHERE r.note_id = ? ORDER BY r.round_number DESC', [id]);
if (!rounds.length && !await notesRepository.findById(id)) return null;
const imageAnnotations = await database.all<Array<WorkFeedbackBundle['rounds'][number]['image_annotations'][number] & { version_number: number }>[number]>(`SELECT a.*, i.id AS image_id, i.url AS image_url, i.version_number FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = ? ORDER BY a.id`, [id]);
const textAnnotations = await database.all<Array<NoteDetail['text_annotations'][number] & { version_number: number }>[number]>('SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id', [id]);
const comments = await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id', [id]);
const replies = await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id', [id]);
const events = await database.all<NoteDetail['review_events'][number]>('SELECT * FROM review_events WHERE note_id = ? ORDER BY id', [id]);
return {
work_id: id,
rounds: rounds.map((round) => ({
round_number: Number(round.round_number),
version_number: Number(round.version_number),
image_annotations: imageAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)),
text_annotations: textAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)),
comments: comments.filter((item) => Number(item.version_number) === Number(round.version_number)),
feedback_replies: replies.filter((item) => Number(item.version_number) === Number(round.version_number)),
review_events: events.filter((item) => Number(item.version_number) === Number(round.version_number)),
})),
};
},
async remove(id: number) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number }>('SELECT collection_id FROM notes WHERE id = ?', [id]);
const note = await tx.one<{ collection_id: number; project_id: number }>('SELECT collection_id, project_id FROM notes WHERE id = ?', [id]);
if (!note) return false;
const removed = (await tx.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0;
if (removed) await recalculateCollectionStatus(Number(note.collection_id), tx);
if (removed) {
await recalculateCollectionStatus(Number(note.collection_id), tx);
await recalculateProjectReviewStatus(Number(note.project_id), tx);
}
return removed;
});
},
async setStatus(id: number, status: ReviewStatus) {
return withTransaction(async (tx) => {
const note = await tx.one<{ collection_id: number; active_round_id: number | null }>('SELECT collection_id, active_round_id FROM notes WHERE id = ?', [id]);
const note = await tx.one<{ collection_id: number; project_id: number; active_round_id: number | null }>('SELECT collection_id, project_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]);
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 tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ?", [status, status === 'draft' ? 'draft' : 'pending', note.active_round_id]);
await tx.execute("UPDATE review_rounds SET status = ?, completion_reason = '', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]);
}
await recalculateCollectionStatus(Number(note.collection_id), tx);
await recalculateProjectReviewStatus(Number(note.project_id), tx);
return true;
});
},

View File

@@ -0,0 +1,55 @@
import type { ProjectReviewStatus } from '../../shared/types.js';
import { database, databaseDialect, type QueryContext } from '../database.js';
export interface ProjectReviewStatusResult {
reviewStatus: ProjectReviewStatus;
workCount: number;
approvedCount: number;
completedAt: string | null;
}
export function deriveProjectReviewStatus(workCount: number, approvedCount: number): Exclude<ProjectReviewStatus, 'archived'> {
if (workCount === 0) return 'draft';
if (approvedCount === workCount) return 'completed';
return 'reviewing';
}
export async function recalculateProjectReviewStatus(
projectId: number,
tx: QueryContext = database,
): Promise<ProjectReviewStatusResult | null> {
const project = await tx.one<{ status: string; review_status: ProjectReviewStatus; review_completed_at: string | null }>(
`SELECT status, review_status, review_completed_at FROM projects WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
[projectId],
);
if (!project) return null;
const counts = await tx.one<{ work_count: number | string; approved_count: number | string }>(
`SELECT COUNT(*) AS work_count,
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
FROM notes WHERE project_id = ? AND review_status != 'draft'`,
[projectId],
);
const workCount = Number(counts?.work_count ?? 0);
const approvedCount = Number(counts?.approved_count ?? 0);
if (project.status === 'archived') {
return { reviewStatus: 'archived', workCount, approvedCount, completedAt: project.review_completed_at };
}
const reviewStatus = deriveProjectReviewStatus(workCount, approvedCount);
const completedAt = reviewStatus === 'completed'
? project.review_completed_at ?? new Date().toISOString()
: null;
await tx.execute('UPDATE projects SET review_status = ?, review_completed_at = ? WHERE id = ?', [reviewStatus, completedAt, projectId]);
return { reviewStatus, workCount, approvedCount, completedAt };
}
export async function ensureProjectCompatibilityCollection(projectId: number, tx: QueryContext = database): Promise<number> {
const existing = await tx.one<{ id: number }>('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1', [projectId]);
if (existing) return Number(existing.id);
return tx.insertId(
"INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, '', 'draft')",
[projectId, '__project_default__'],
);
}

View File

@@ -1,12 +1,13 @@
import type { ReviewStatus } from '../../shared/types.js';
import { databaseDialect, withTransaction, type QueryContext } from '../database.js';
import { recalculateCollectionStatus } from './collectionsService.js';
import { recalculateProjectReviewStatus } from './projectsService.js';
export class ReviewDecisionError extends Error {
constructor(public statusCode: number, message: string) { super(message); }
}
export interface CandidateDecisionInput {
export interface RoundDecisionInput {
noteId: number;
versionNumber: number;
projectId: number;
@@ -16,43 +17,48 @@ export interface CandidateDecisionInput {
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' : ''}`,
export async function decideRoundInTransaction(tx: QueryContext, input: RoundDecisionInput) {
const round = await tx.one<{
review_round_id: number; review_status: ReviewStatus; collection_id: number; project_id: number;
active_round_id: number | null; round_status: string; project_status: string; title: string; description: string; tags: string;
}>(`SELECT v.review_round_id,v.review_status,v.title,v.description,v.tags,
n.collection_id,n.project_id,n.active_round_id,r.status AS round_status,p.status AS project_status
FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN review_rounds r ON r.id=v.review_round_id JOIN projects p ON p.id=n.project_id
WHERE v.note_id=? AND v.version_number=? AND n.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') {
if (!round) throw new ReviewDecisionError(404, '验收轮次不存在');
if (round.project_status !== 'active') throw new ReviewDecisionError(409, '已关闭或归档项目为只读状态');
if (Number(round.active_round_id) !== Number(round.review_round_id) || round.round_status !== 'reviewing') {
throw new ReviewDecisionError(409, '历史验收轮次为只读状态');
}
if (!['pending', 'changes_requested'].includes(candidate.candidate_status)) {
throw new ReviewDecisionError(409, '该候选稿当前不能重复验收');
if (!['pending', 'changes_requested'].includes(round.review_status)) {
throw new ReviewDecisionError(409, '该轮次当前不能重复验收');
}
const now = new Date().toISOString();
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]);
await tx.execute("UPDATE review_rounds SET status='completed', completion_reason='approved', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, now, round.review_round_id]);
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, input.versionNumber, input.noteId]);
await tx.execute("UPDATE annotations SET status='confirmed', closure_reason='approved_with_round' WHERE image_id IN (SELECT id FROM images WHERE note_id=? AND version_number=?) AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE text_annotations SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
await tx.execute("UPDATE work_comments SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]);
} 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("UPDATE review_rounds SET status='completed', completion_reason='changes_requested', completed_at=? WHERE id=?", [now, round.review_round_id]);
await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status='changes_requested' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, 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);
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, round.review_status, input.decision, input.reason, input.actorName, input.actorRole]);
if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [input.noteId, input.versionNumber, input.reason, input.actorName]);
await recalculateCollectionStatus(Number(round.collection_id), tx);
await recalculateProjectReviewStatus(Number(round.project_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));
export async function decideRound(input: RoundDecisionInput) {
return withTransaction((tx) => decideRoundInTransaction(tx, input));
}
export const decideCandidate = decideRound;
export const decideCandidateInTransaction = decideRoundInTransaction;

View File

@@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS projects (
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')),
review_status TEXT NOT NULL DEFAULT 'draft' CHECK (review_status IN ('draft', 'reviewing', 'completed', 'archived')),
review_completed_at TIMESTAMPTZ,
access_password_hash TEXT NOT NULL DEFAULT '',
customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE,
access_expires_at TIMESTAMPTZ,
@@ -51,6 +53,7 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
CREATE TABLE IF NOT EXISTS notes (
id BIGSERIAL PRIMARY KEY,
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
external_id TEXT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
@@ -63,6 +66,9 @@ CREATE TABLE IF NOT EXISTS notes (
);
ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT;
ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'draft';
ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_completed_at TIMESTAMPTZ;
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 != '';
CREATE TABLE IF NOT EXISTS images (
@@ -84,17 +90,23 @@ CREATE TABLE IF NOT EXISTS annotations (
y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1),
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS work_comments (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL DEFAULT 1,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
@@ -102,15 +114,38 @@ CREATE TABLE IF NOT EXISTS text_annotations (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
target TEXT NOT NULL CHECK (target IN ('title', 'description')),
target TEXT NOT NULL CHECK (target IN ('title', 'description', 'tags')),
start_offset INTEGER NOT NULL DEFAULT 0,
end_offset INTEGER NOT NULL DEFAULT 0,
selected_text TEXT NOT NULL DEFAULT '',
prefix_text TEXT NOT NULL DEFAULT '',
suffix_text TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
closure_reason TEXT NOT NULL DEFAULT '',
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE TABLE IF NOT EXISTS feedback_replies (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
feedback_type TEXT NOT NULL CHECK (feedback_type IN ('image_annotation', 'text_annotation', 'comment')),
feedback_id BIGINT NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL,
author_role TEXT NOT NULL CHECK (author_role IN ('client', 'operator')),
withdrawn_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id);
CREATE TABLE IF NOT EXISTS work_versions (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
@@ -135,6 +170,7 @@ CREATE TABLE IF NOT EXISTS review_rounds (
selected_version_number INTEGER,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
completed_at TIMESTAMPTZ,
completion_reason TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(note_id, round_number)
);
@@ -144,6 +180,27 @@ 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';
ALTER TABLE review_rounds ADD COLUMN IF NOT EXISTS completion_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client';
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS version_number INTEGER NOT NULL DEFAULT 1;
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS start_offset INTEGER NOT NULL DEFAULT 0;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS end_offset INTEGER NOT NULL DEFAULT 0;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS selected_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS prefix_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS suffix_text TEXT NOT NULL DEFAULT '';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client';
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ;
ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT '';
-- TEXT_ANNOTATION_TARGET_REPAIR_START
ALTER TABLE text_annotations DROP CONSTRAINT IF EXISTS text_annotations_target_check;
ALTER TABLE text_annotations ADD CONSTRAINT text_annotations_target_check
CHECK (target IN ('title', 'description', 'tags'));
-- TEXT_ANNOTATION_TARGET_REPAIR_END
CREATE TABLE IF NOT EXISTS review_events (
id BIGSERIAL PRIMARY KEY,
@@ -223,6 +280,8 @@ CREATE TABLE IF NOT EXISTS storage_configs (
CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config ON storage_configs ((status)) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS collections_project_id_idx ON collections(project_id);
CREATE INDEX IF NOT EXISTS notes_collection_id_idx ON notes(collection_id);
CREATE INDEX IF NOT EXISTS notes_project_id_idx ON notes(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != '';
CREATE INDEX IF NOT EXISTS images_note_version_idx ON images(note_id, version_number, order_index);
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);
@@ -231,6 +290,56 @@ CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_
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);
-- SINGLE_SCHEME_REPAIR_START
DO $$
DECLARE
duplicate RECORD;
version_row RECORD;
source_round RECORD;
note_row RECORD;
keeper_version INTEGER;
next_round INTEGER;
new_round_id BIGINT;
remains_active BOOLEAN;
BEGIN
FOR duplicate IN
SELECT review_round_id, note_id FROM work_versions
WHERE review_round_id IS NOT NULL
GROUP BY review_round_id, note_id HAVING COUNT(*) > 1
LOOP
SELECT * INTO source_round FROM review_rounds WHERE id = duplicate.review_round_id;
SELECT active_round_id, version_number INTO note_row FROM notes WHERE id = duplicate.note_id;
SELECT COALESCE(
(SELECT v.version_number FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id AND v.version_number=note_row.version_number LIMIT 1),
(SELECT MIN(v.version_number) FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id)
) INTO keeper_version;
FOR version_row IN SELECT version_number FROM work_versions WHERE review_round_id=duplicate.review_round_id AND version_number<>keeper_version ORDER BY version_number
LOOP
SELECT COALESCE(MAX(round_number),0)+1 INTO next_round FROM review_rounds WHERE note_id=duplicate.note_id;
remains_active := note_row.active_round_id=duplicate.review_round_id AND note_row.version_number=version_row.version_number;
INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason)
VALUES (
duplicate.note_id,
next_round,
CASE WHEN remains_active THEN source_round.status ELSE 'completed' END,
CASE WHEN source_round.selected_version_number=version_row.version_number THEN version_row.version_number ELSE NULL END,
CASE WHEN remains_active THEN source_round.completed_at ELSE COALESCE(source_round.completed_at,NOW()) END,
source_round.created_by,
source_round.created_at,
CASE WHEN remains_active THEN source_round.completion_reason ELSE COALESCE(NULLIF(source_round.completion_reason,''),'migrated_single_scheme') END
) RETURNING id INTO new_round_id;
UPDATE work_versions SET review_round_id=new_round_id WHERE note_id=duplicate.note_id AND version_number=version_row.version_number;
IF remains_active THEN UPDATE notes SET active_round_id=new_round_id WHERE id=duplicate.note_id; END IF;
END LOOP;
IF source_round.selected_version_number IS DISTINCT FROM keeper_version THEN
UPDATE review_rounds SET selected_version_number=NULL WHERE id=duplicate.review_round_id;
END IF;
END LOOP;
END $$;
-- SINGLE_SCHEME_REPAIR_END
CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL;
-- COLLECTION_STATUS_REPAIR_START
UPDATE collections
SET status = 'draft', completed_at = NULL
@@ -281,4 +390,41 @@ 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
-- PROJECT_REVIEW_STATUS_REPAIR_START
UPDATE notes n
SET project_id = c.project_id
FROM collections c
WHERE c.id = n.collection_id AND n.project_id IS NULL;
UPDATE work_comments wc
SET version_number = n.version_number
FROM notes n
WHERE n.id = wc.note_id AND wc.version_number < 1;
UPDATE projects p
SET review_status = CASE
WHEN p.status = 'archived' THEN 'archived'
WHEN s.work_count IS NULL OR s.work_count = 0 THEN 'draft'
WHEN s.approved_count = s.work_count THEN 'completed'
ELSE 'reviewing'
END,
review_completed_at = CASE
WHEN p.status != 'archived' AND s.work_count > 0 AND s.approved_count = s.work_count
THEN COALESCE(p.review_completed_at, NOW())
ELSE NULL
END
FROM (
SELECT project_id,
COUNT(*) FILTER (WHERE review_status != 'draft') AS work_count,
COUNT(*) FILTER (WHERE review_status = 'approved') AS approved_count
FROM notes GROUP BY project_id
) s
WHERE p.id = s.project_id;
UPDATE projects p
SET review_status = CASE WHEN p.status = 'archived' THEN 'archived' ELSE 'draft' END,
review_completed_at = NULL
WHERE NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = p.id AND n.review_status != 'draft');
-- PROJECT_REVIEW_STATUS_REPAIR_END
COMMIT;

View File

@@ -2,83 +2,72 @@
## 系统边界
Delivery Desk 是单体 Web 应用:React 前端调用 Express API,前后端共享 `shared/types.ts` 类型。开发环境使用 SQLite 和本地上传目录正式环境使用 PostgreSQL 和腾讯云 COS。
Delivery Desk 是 React + Express 单体应用,前后端共享 `shared/types.ts`。开发环境使用 SQLite 和本地上传目录正式环境使用 PostgreSQL 和腾讯云 COS。
```mermaid
flowchart LR
browser["浏览器"] --> app["Express / React 应用"]
app --> database["SQLite 或 PostgreSQL"]
app --> local["本地 uploads开发"]
app --> cos["腾讯云 COS(正式)"]
customer["客户验收链接"] --> app
client["外部 API 客户端"] --> app
browser["工作台 / 客户浏览器"] --> app["Express + React"]
api["外部 API 客户端"] --> app
app --> db["SQLite / PostgreSQL"]
app --> storage["本地 uploads / 腾讯云 COS"]
```
## 业务层级
## 产品层级
```text
运营组
└── 项目
└── 作品交付集
└── 作品
└── 验收轮次
└── 候选稿15 个)
└── 作品
└── 验收轮次(每轮一个方案)
```
- 一个运营组只能有一位组管理员,可以有多位光影叙事
- 平台管理员可以有多位,不属于固定运营组
- 普通工作台账号只能读写所属运营组的数据;平台管理员可跨组管理
- 客户会话只绑定一个项目,不能跨项目浏览
- 平台级 API Key 可创建项目;项目级 API Key 只能操作指定项目
## 运行结构
- `src/`React 页面、组件、状态和 API 客户端。
- `api/routes/`HTTP 路由与输入校验。
- `api/services/`:作品、存储等业务编排。
- `api/repositories/`:查询封装。
- `api/database.ts`SQLite/PostgreSQL 统一查询接口和事务。
- `api/db.ts`SQLite 初始化及增量迁移。
- `db/postgres/schema.sql`PostgreSQL 当前完整 schema。
- `shared/types.ts`:前后端共享领域类型。
`DATABASE_URL` 存在时使用 PostgreSQL否则使用 SQLite。两套数据库必须保持相同业务约束涉及表或字段的修改必须同时更新 `api/db.ts``db/postgres/schema.sql` 及迁移验证脚本。
- 客户会话绑定项目,不能跨项目访问
- 项目级 API Key 只能访问绑定项目;平台级 Key 可跨组管理项目
- 历史作品交付集不再是产品层级。`collections` 表仅作为旧数据和旧 URL 的迁移兼容容器
- `work_versions` 继续保存每轮内容快照,但与 `review_rounds` 强制一对一
- 升级时如检测到旧的一轮多方案数据,会把额外方案拆成只读的独立历史轮次,保留图片、批注、验收事件和当前活动方案,再建立一轮一方案唯一约束
## 主要数据表
| 表 | 用途 |
|---|---|
| `operation_groups` | 运营组及启停状态 |
| `users` / `sessions` | 工作台账号、角色和登录会话 |
| `customer_sessions` | 客户项目级验收会话 |
| `projects` | 项目、客户访问密码和访问期限 |
| `collections` | 项目下的作品交付集 |
| `notes` | 作品当前状态、活动轮次和选中稿 |
| `review_rounds` | 验收轮次完成状态和选中候选稿 |
| `work_versions` | 各候选稿的标题、正文、标签和验收状态快照 |
| `images` | 候选稿图片、顺序存储提供方和对象 Key |
| `operation_groups` | 运营组及状态 |
| `users` / `sessions` | 工作台账号、角色和会话 |
| `customer_sessions` | 项目级客户会话 |
| `projects` | 项目、客户访问配置和自动验收状态 |
| `collections` | 迁移期内部兼容容器,不属于产品层级 |
| `notes` | 作品当前状态、活动轮次和项目归属 |
| `review_rounds` | 验收轮次完成原因 |
| `work_versions` | 单轮内容快照;每轮恰好一条 |
| `images` | 轮次图片、顺序存储信息 |
| `annotations` | 图片坐标批注 |
| `text_annotations` | 标题正文的版本级批注 |
| `work_comments` | 作品总体反馈与回复 |
| `review_events` | 提交、修、通过重新打开等验收记录 |
| `api_keys` | 平台级项目级 API Key 哈希与状态 |
| `storage_configs` | 加密后的 COS 配置及启用状态 |
| `audit_logs` | 管理业务操作审计 |
| `text_annotations` | 标题正文和 Tag 选区批注与文本上下文 |
| `work_comments` | 作品总体反馈 |
| `review_events` | 提交、退修、通过重新打开记录 |
| `api_keys` | 平台级/项目级 API Key 哈希 |
| `storage_configs` | 加密后的 COS 配置 |
| `audit_logs` | 管理业务审计 |
## 存储流程
## 状态计算
平台管理员在管理页新增 COS 配置。SecretId 和 SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密后写入数据库,读取配置的接口不会返回明文
作品状态为 `draft``pending``changes_requested``approved`。只有活动轮次可新增批注和作出验收决定;新轮次会锁定旧轮次。客户通过活动轮次后作品为已通过,退修后运营通过新轮次提交修改
启用配置前会在目标桶的 `.delivery-desk-check/` 路径依次上传、读取并删除一个临时对象。启用后,新上传文件写入
项目验收状态自动计算
```text
<path-prefix>/originals/YYYY/MM/<uuid>.<ext>
```
- 没有非草稿作品:`draft`
- 存在未通过作品:`reviewing`
- 所有非草稿作品通过:`completed`
- 人工归档:`archived`
未启用 COS 时,上传文件保存在本地 `uploads/`。图片 URL 按产品约定为公开随机地址,不提供对象级访问鉴权
完成项目为只读。新增作品、创建新轮次或由管理员重新打开作品时,项目恢复为验收中;已关闭或归档项目始终只读。开放反馈不会阻止通过;通过时仍为开放的反馈会标记为随该轮验收关闭,历史内容保留
## 验收状态
## 批注模型
作品状态为 `draft``pending``changes_requested``approved`。一个验收轮次可包含 15 个候选稿;单稿退修时,其他待验收稿仍可继续验收。客户选中并通过任意一稿后,作品即通过,同轮其他稿标记为未选用,历史轮次只读。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留
- 作品缩略图只展示现有坐标标记,不能新增坐标批注;点击标记会联动打开验收协作面板中的对应反馈
- 点击图片打开悬浮图片窗格;只有该窗格可以新增坐标批注,并支持原图查看、缩放和前后切换。点击窗格外会同时关闭图片窗格和验收协作面板。
- 标题、正文和 Tag 批注保存 `start_offset``end_offset``selected_text` 及前后文,提交时校验选区仍与轮次快照一致。
- `GET /api/works/:workId/annotations` 按轮次返回图片批注、文字批注、总体反馈和验收事件。
作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed``archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新验收轮次或重新打开作品会自动恢复为验收中。
## 存储
平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。外部 API 提供的公开图片 URL 只保存地址,不下载、不转存 COS。

View File

@@ -3,34 +3,33 @@
## 已完成
- 三类工作台角色、运营组隔离、账号管理和 7 天会话
- 项目、作品交付集、作品、多候选稿验收轮次和验收状态
- 手动多图上传、封面上传前拖拽排序及新验收轮次
- 图片坐标批注、标题/正文批注、总体反馈和验收记录
- 客户项目链接、密码、姓名、期限和验收决定
- 项目 → 作品 → 单方案验收轮次,以及项目级自动验收状态
- 手动多图上传、公开 URL API、封面上传前拖拽排序
- 缩略图只读标记、悬浮图片窗格、原图缩放与坐标批注
- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录
- 批注回复线程、只能撤回本人反馈并保留撤回记录
- 客户项目链接、密码、姓名、访问期限和验收决定
- API Key、审计日志、COS 前端配置及连接测试
- SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署
- 桌面端与移动端响应式页面
## 初版上线前仍需完成
以下需求尚未在代码中完整落地,不应在交付时宣称可用:
以下范围尚未完整落地,不应在交付时宣称可用:
- ZIP + CSV 批量导入和最多 100 个作品的异步批量 API
- `externalId` 幂等创建作品(项目和作品交付集暂未支持)
- webhook 与站内未读通知
- PDF 验收报告和最终原图 ZIP 导出
- 批注/回复的参考图片附件
- 项目、作品交付集、作品的回收站、归档恢复和永久删除规则
- 批注/回复的参考图片附件
- 项目作品的回收站、归档恢复和永久删除流程
- 已上传作品在所有阶段的图片重新排序
- 在线人员状态、实时变更通知和并发版本冲突保护
- HEIC/HEIF 转换、缩略图流水线和 EXIF 定位信息清理
- 自动化端到端浏览器测试及真实腾讯云、PostgreSQL 部署演练
- 在线人员状态、实时变更通知和并发冲突保护
- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理
- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练
## 上线门槛
初版正式发布至少应满足:
1. 使用 PostgreSQL 和独立生产 COS 桶,完成一次备份恢复演练。
2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。
1. 使用 PostgreSQL 和独立生产 COS 桶,完成备份恢复演练。
2. 轮换所有在聊天、截图或开发数据中出现过的云密钥和临时密码。
3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。
4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。
4. 根据真实交付承诺,从未完成清单中选定必须进入初版的项目。

View File

@@ -1,60 +1,32 @@
# API 接入指南
## 认证方式
## 认证
工作台网页使用 HttpOnly Cookie 会话。外部客户端使用:
工作台使用 HttpOnly Cookie外部客户端使用:
```http
Authorization: Bearer dd_live_xxx
```
API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。平台管理员创建平台级 Key组管理员创建本组项目级 Key。失效或越权请求会返回 `401``403`
平台级 Key 可跨组创建和查询项目。项目级 Key 只能操作绑定项目,包括在该项目中新建作品和验收轮次。密钥明文只在创建时返回一次
## 主要路由
| 路由组 | 用途 |
|---|---|
| `/api/auth/*` | 登录、退出、当前账号、修改密码 |
| `/api/management/groups` | 运营组创建、改名、启停和管理员更换 |
| `/api/management/users` | 账号创建、改名、启停和重置密码 |
| `/api/management/api-keys` | API Key 创建、查询和吊销 |
| `/api/management/audit-logs` | 审计日志查询 |
| `/api/management/storage-configs` | COS 配置、连接测试和启用 |
| `/api/projects` | 项目创建、查询和编辑 |
| `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 |
| `/api/notes` | 作品查询与创建 |
| `/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/works/:noteId/decision` | 客户对指定候选稿作出验收决定 |
| `/api/health` | 数据库就绪检查 |
## 查询运营组、项目、作品交付集和作品
调用方不需要预先知道数据库 ID。使用 API Key 按顺序查询:
## 发现资源
```bash
# 返回 Key 有权访问的项目,响应包含 group_id、group_name 和项目 id
# 查询 Key 访问的项目,响应包含 group_id、group_name 和项目 id
curl http://localhost:3010/api/projects \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 查询项目中的作品交付集
curl http://localhost:3010/api/projects/1/collections \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
# 查询交付集中的作品,响应包含作品 id、external_id 和 version_number
curl "http://localhost:3010/api/notes?collectionId=1" \
# 查询项目作品
curl http://localhost:3010/api/projects/1/works \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
```
项目级 Key 的项目列表只会返回绑定项目;平台级 Key 可以查询全部运营组的项目。创建作品时只传 `collectionId`,服务会据此确定项目和运营组并校验权限,不需要重复传递 `projectId``groupId`
调用方不再需要作品交付集 ID。`externalId` 在项目内唯一,可用于安全重试和找回作品
## 创建项目
平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。
只有平台级 Key 可以创建项目。
```bash
curl -X POST http://localhost:3010/api/projects \
@@ -63,120 +35,94 @@ curl -X POST http://localhost:3010/api/projects \
-d '{"name":"7 月内容计划","slug":"july-content","groupId":1,"client_description":"客户可见说明"}'
```
`slug` 仅支持小写字母、数字和连字符,并作为客户验收链接的一部分。
## 创建作品
## 创建作品交付集
JSON 请求中的 `images` 为 130 个公开 HTTP/HTTPS URL。服务只保存 URL不下载也不转存 COS数组顺序就是展示顺序第一张为封面。
```bash
curl -X POST http://localhost:3010/api/projects/1/collections \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
```
新建作品交付集的 `status``draft`。上传首件作品后自动变为 `reviewing`;全部非草稿作品通过后自动变为 `completed`。响应中的 `work_count``approved_count``completed_at` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新验收轮次、修改验收状态和删除作品都会触发服务端重算。
## 上传作品
外部客户端使用 JSON 创建作品,`images` 直接传入 130 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL不会下载图片或再次上传到 COS。数组顺序就是展示顺序第一张为封面。
```bash
curl -X POST http://localhost:3010/api/notes \
curl -X POST http://localhost:3010/api/projects/1/works \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"collectionId": 1,
"externalId": "client-work-20260721-001",
"title": "作品标题",
"description": "正文内容",
"tags": ["用户填写的标签原文"],
"images": [
"https://cdn.example.com/works/01.jpg",
"https://cdn.example.com/works/02.jpg"
]
"externalId":"client-work-20260722-001",
"title":"作品标题",
"description":"正文内容",
"tags":["#夏日","用户原文"],
"images":["https://cdn.example.com/01.jpg","https://cdn.example.com/02.jpg"]
}'
```
`externalId` 是调用方在当前作品交付集内的作品唯一标识,支持字母、数字、点、下划线、冒号和横线,最长 128 位。相同 `collectionId + externalId` 的重复请求不会重复创建作品,而会以 `200` 返回原作品并包含 `"idempotent": true`创建成功响应中的 `id` 是后续上传版本所需的 `workId`;如果调用方丢失了该 ID可以通过 `GET /api/notes?collectionId=1&externalId=client-work-20260721-001` 找回
相同 `projectId + externalId` 的重不会重复创建,响应包含 `idempotent: true`调用方负责保证外部图片 URL 长期公开可用
URL 图片不会进入当前配置的 COS也不会由服务检查其内容或长期可用性因此调用方需要保证链接公开、稳定且确实指向图片。工作台手动上传仍接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按原文保存和展示不会自动添加 `#` 或拆分为标签库。
## 创建新验收轮次
## 创建单候选稿验收轮次(兼容接口)
每轮只能提交一个方案。标题、正文、标签和图片会形成不可修改的轮次快照;新轮次自动锁定上一轮。
```bash
curl -X POST http://localhost:3010/api/notes/12/versions \
curl -X POST http://localhost:3010/api/works/12/rounds \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "修改后的标题",
"description": "修改后的正文",
"tags": ["修改后的标签原文"],
"images": [
"https://cdn.example.com/works/v2-01.jpg",
"https://cdn.example.com/works/v2-02.jpg"
]
"title":"修改后的标题",
"description":"修改后的正文",
"tags":["#第二轮"],
"images":["https://cdn.example.com/round-2.jpg"]
}'
```
该接口保留给只提交一个方案的现有调用方。批注绑定候选稿或具体图片,不会被新验收轮次覆盖。
## 查询作品与全部反馈
## 提交多候选稿验收轮次
```bash
# 当前轮或指定轮
curl "http://localhost:3010/api/works/12?round=2" \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
`POST /api/notes/:noteId/review-rounds` 可在同一轮中提交 15 个候选稿。JSON 请求中每个候选稿使用公开图片 URL
# 按轮返回该作品全部反馈和验收事件
curl http://localhost:3010/api/works/12/annotations \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY"
```
标题、正文和 Tag 选区批注使用:
```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"]
}
]
"round_number": 2,
"target": "description",
"start_offset": 4,
"end_offset": 8,
"selected_text": "选中文字",
"content": "这里需要调整"
}
```
客户验收决定必须带上候选稿的 `version_number`。选中并通过某稿后,同轮其他稿自动标记为 `not_selected`,历史轮次变为只读。提交新轮次时,尚未结束的上一轮会自动关闭,其中仍在等待验收的候选稿会标记为 `not_selected`。旧的 `/versions` 接口继续可用,等价于创建只有一个候选稿的新轮次。决定接口使用客户登录后获得的 Cookie不能使用工作台 API Key 代替
服务会校验偏移量和所选文字是否匹配当前轮次快照。历史轮次或已完成项目返回 `409`
批注、文字批注和总体反馈都可回复,类型分别为 `image_annotation``text_annotation``comment`
```http
POST /api/works/:workId/feedback/:type/:feedbackId/replies
POST /api/works/:workId/feedback/:type/:feedbackId/withdraw
```
撤回只允许原作者执行,不会删除数据库记录。客户入口在路径前增加 `/api/review/:slug`,并执行相同的项目归属与身份校验。
## 客户验收
客户输入项目密码和姓名后使用 Cookie 调用:
```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"
}'
-d '{"round_number":2,"decision":"approved"}'
```
## Python 冒烟脚本
`decision``approved``changes_requested`;退修必须填写 `reason`。只允许决定活动轮次。
项目自带 `tests/api_create_work.py`,只使用 Python 标准库。推荐通过环境变量提供项目级 API Key
客户侧全部反馈接口为 `/api/review/:slug/works/:workId/annotations`,仍会校验客户会话绑定的项目。
```powershell
$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
```
旧的 `/api/notes``/api/notes/:id/versions``/api/notes/:id/review-rounds``/api/projects/:id/collections` 暂保留一个兼容周期。旧交付集 URL 会跳转到项目页;旧多候选稿请求会返回 `400`,不会再创建多方案轮次。新接入必须使用项目、作品和轮次接口。
如果项目级 Key 只能访问一个项目,并且项目下只有一个作品交付集,可以省略两个 ID。脚本也支持不传 Key、改用 `--username` 后交互输入密码。
## 错误响应
错误统一以 JSON 返回:
```json
{ "error": "错误说明" }
```
常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 资源不存在、`409` 唯一性或状态冲突、`500` 服务端错误。
错误统一为 `{ "error": "错误说明" }`。常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 不存在、`409` 状态冲突。

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_rounds', 'review_events', 'sessions', 'customer_sessions',
'work_comments', 'feedback_replies', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
@@ -31,6 +31,7 @@ try {
const existing = Number((await client.query('SELECT COUNT(*)::int AS count FROM operation_groups')).rows[0].count);
if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace');
await client.query('BEGIN');
await client.query('DROP INDEX IF EXISTS idx_work_versions_one_per_round');
if (replace) await client.query(`TRUNCATE ${[...tables].reverse().map((table) => `"${table}"`).join(', ')} RESTART IDENTITY CASCADE`);
for (const table of tables) {
@@ -48,9 +49,13 @@ try {
}
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];
const singleSchemeRepair = schema.match(/-- SINGLE_SCHEME_REPAIR_START([\s\S]+?)-- SINGLE_SCHEME_REPAIR_END/)?.[1];
if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本');
if (!reviewRoundRepair) throw new Error('PostgreSQL schema 缺少验收轮次修复脚本');
if (!singleSchemeRepair) throw new Error('PostgreSQL schema 缺少单方案轮次修复脚本');
await client.query(reviewRoundRepair);
await client.query(singleSchemeRepair);
await client.query('CREATE UNIQUE INDEX idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL');
await client.query(collectionStatusRepair);
await client.query('COMMIT');
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);

View File

@@ -4,7 +4,7 @@ process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!';
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
const { default: app } = await import('../api/app.js');
const { closeDatabase } = await import('../api/database.js');
const { database, closeDatabase } = await import('../api/database.js');
const server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.once('listening', resolve));
const address = server.address();
@@ -79,6 +79,9 @@ try {
const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie);
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
const otherProjectId=Number((otherProject.body as {id:number}).id);
const otherWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'其他项目作品',images:['https://cdn.example.com/isolated.jpg']})},adminCookie);
expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body);
const otherWorkId=Number((otherWork.body as {id:number}).id);
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
expectStatus(access.response.status, 200, '配置客户访问');
@@ -98,6 +101,8 @@ try {
if((visibleProjects.body as Array<{id:number}>).length!==1||Number((visibleProjects.body as Array<{id:number}>)[0].id)!==projectId)throw new Error('项目级 Key 未严格隔离到绑定项目');
const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders});
expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body);
const forbiddenFeedback=await request(`/api/works/${otherWorkId}/annotations`,{headers:bearerHeaders});
expectStatus(forbiddenFeedback.response.status,403,'项目级 Key 拒绝读取其他项目作品反馈',forbiddenFeedback.body);
const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body);
if(!(visibleCollections.body as Array<{id:number}>).some((item)=>Number(item.id)===collectionId))throw new Error('项目级 Key 未返回目标作品交付集');
@@ -108,6 +113,8 @@ try {
const reviewingCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
const reviewingCollection=(reviewingCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId);
if(reviewingCollection?.status!=='reviewing'||Number(reviewingCollection.work_count)!==1)throw new Error('新增待验收作品后,作品交付集未进入验收中');
const reviewingProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
if((reviewingProject.body as {review_status:string}).review_status!=='reviewing')throw new Error('新增待验收作品后,项目未进入验收中');
const repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
expectStatus(repeatedWork.response.status,200,'externalId 幂等创建',repeatedWork.body);
if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品');
@@ -119,8 +126,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 firstRound=(workDetail.body as {rounds:Array<{version_number:number;round_status:string;completion_reason:string}>}).rounds.find((item)=>item.version_number===1);
if(firstRound?.round_status!=='completed'||firstRound.completion_reason!=='superseded')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!' }) });
@@ -138,7 +145,9 @@ try {
expectStatus(readonlyComment.response.status,409,'验收完毕后客户只读',readonlyComment.body);
const completedWorkDetail=await request(`/api/review/postgres-runtime-test/works/${workId}`,{},reviewCookie);
expectStatus(completedWorkDetail.response.status,200,'完成后读取作品',completedWorkDetail.body);
if((completedWorkDetail.body as {collection:{status:string}}).collection.status!=='completed')throw new Error('作品详情未返回作品交付集完成状态');
if((completedWorkDetail.body as {project:{review_status:string}}).project.review_status!=='completed')throw new Error('作品详情未返回项目验收完成状态');
const completedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
if((completedProject.body as {review_status:string}).review_status!=='completed')throw new Error('全部作品通过后,项目接口未返回验收完成');
const reopenApproved=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'补充复核'})},newAdminCookie);
expectStatus(reopenApproved.response.status,200,'组管理员重新打开已通过作品',reopenApproved.body);
@@ -152,43 +161,61 @@ 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({version_number:3,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({round_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({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);
const closedRoundComment=await request(`/api/works/${workId}/comments`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'退修轮次不应继续写入'})});
expectStatus(closedRoundComment.response.status,409,'退修轮次禁止新增总体反馈',closedRoundComment.body);
const closedRoundDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,decision:'approved'})},reviewCookie);
expectStatus(closedRoundDecision.response.status,409,'退修轮次不可再次通过',closedRoundDecision.body);
const roundFour=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V4',description:'第四轮',tags:['API 测试'],images:['https://cdn.example.com/runtime-v4.jpg']})});
expectStatus(roundFour.response.status,201,'创建单方案第 4 轮',roundFour.body);
const multiRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{title:'方案 A',images:['https://cdn.example.com/a.jpg']},{title:'方案 B',images:['https://cdn.example.com/b.jpg']}]})});
expectStatus(multiRound.response.status,400,'拒绝一轮多个方案',multiRound.body);
const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',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']})});
const mismatchedSelection=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'错误文字',content:'不应写入'})});
expectStatus(mismatchedSelection.response.status,400,'拒绝与内容快照不匹配的文字选区',mismatchedSelection.body);
const textAnnotation=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',content:'标题选区批注'})});
expectStatus(textAnnotation.response.status,201,'创建标题选区批注',textAnnotation.body);
const textAnnotationId=Number((textAnnotation.body as {id:number}).id);
const tagAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'tags',start_offset:0,end_offset:6,selected_text:'API 测试',content:'Tag 选区批注'})},reviewCookie);
expectStatus(tagAnnotation.response.status,201,'客户创建 Tag 选区批注',tagAnnotation.body);
const feedbackReply=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/replies`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'已记录这条意见'})});
expectStatus(feedbackReply.response.status,201,'回复文字批注',feedbackReply.body);
const feedback=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders});
expectStatus(feedback.response.status,200,'按作品 ID 获取全部反馈',feedback.body);
const roundFeedback=(feedback.body as {rounds:Array<{round_number:number;text_annotations:Array<unknown>;feedback_replies:Array<unknown>}>}).rounds.find((item)=>item.round_number===4);
if(roundFeedback?.text_annotations.length!==2||roundFeedback.feedback_replies.length!==1)throw new Error('作品反馈聚合未返回标题、Tag 批注及其回复');
const optimizationContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders});
expectStatus(optimizationContext.response.status,200,'获取内容优化上下文',optimizationContext.body);
const optimizationBody=optimizationContext.body as {content:{title:string;tags:string[];images:Array<{url:string}>};feedback:{text_annotations:Array<{target:string;replies:Array<unknown>}>}};
if(optimizationBody.content.title!=='接口作品 V4'||optimizationBody.content.tags[0]!=='API 测试'||optimizationBody.content.images[0]?.url!=='https://cdn.example.com/runtime-v4.jpg')throw new Error('内容优化上下文缺少当前轮次图文快照');
if(optimizationBody.feedback.text_annotations.length!==2||optimizationBody.feedback.text_annotations.find((item)=>item.target==='title')?.replies.length!==1)throw new Error('内容优化上下文未组合有效批注与回复');
const withdrawText=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/withdraw`,{method:'POST',headers:bearerHeaders});
expectStatus(withdrawText.response.status,200,'本人留痕撤回文字批注',withdrawText.body);
const afterWithdraw=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders});
const withdrawnItem=(afterWithdraw.body as {rounds:Array<{round_number:number;text_annotations:Array<{id:number;withdrawn_at:string|null}>}>}).rounds.find((item)=>item.round_number===4)?.text_annotations.find((item)=>item.id===textAnnotationId);
if(!withdrawnItem?.withdrawn_at)throw new Error('撤回批注未在作品聚合接口中保留记录');
const actionableContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders});
if((actionableContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文默认返回了已撤回批注');
const historyContext=await request(`/api/works/${workId}/optimization-context?round=4&include_history=true`,{headers:bearerHeaders});
if(!(historyContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文无法按需返回历史批注');
await database.execute("UPDATE projects SET status='closed' WHERE id=?",[projectId]);
const closedProjectRound=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'不应创建',images:['https://cdn.example.com/closed.jpg']})},adminCookie);
expectStatus(closedProjectRound.response.status,409,'已关闭项目禁止创建新轮次',closedProjectRound.body);
const closedProjectDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie);
expectStatus(closedProjectDecision.response.status,409,'已关闭项目禁止客户验收写入',closedProjectDecision.body);
await database.execute("UPDATE projects SET status='active' WHERE id=?",[projectId]);
const approveRoundFour=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie);
expectStatus(approveRoundFour.response.status,200,'客户通过第 4 轮',approveRoundFour.body);
const secondWork=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({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 reopenedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
if((reopenedProject.body as {review_status:string}).review_status!=='reviewing')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({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'})});
@@ -223,7 +250,7 @@ try {
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、作品交付集自动状态、完成只读、API Key 发现、externalId 幂等、JSON URL 作品与新版本\n');
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、JSON URL 作品与单方案轮次\n');
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await closeDatabase();

View File

@@ -1,44 +1,67 @@
process.env.NODE_ENV = 'test';
delete process.env.DATABASE_URL;
const { db } = await import('../api/db.js');
const { db, repairMultiSchemeRounds } = await import('../api/db.js');
const { database, closeDatabase } = await import('../api/database.js');
const { decideCandidateInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js');
const { decideRoundInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js');
const { addFeedbackReply, findFeedbackTarget, withdrawFeedback } = await import('../api/services/feedbackService.js');
function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new Error(message);
}
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']);
const group = await database.one<{ id: number }>('SELECT id FROM operation_groups ORDER BY id LIMIT 1');
assert(group, 'SQLite 测试需要至少一个运营组');
const project = { id: await database.insertId("INSERT INTO projects (group_id,name,slug) VALUES (?,?,?)", [group.id, '单轮验收测试', `round-test-${Date.now()}`]) };
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 (project_id,collection_id,title,review_status) VALUES (?,?,?,'pending')", [project.id, collectionId, '第 1 轮']);
const round1 = 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=?', [round1, 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, '第 1 轮', round1]);
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', '仍有待验收候选稿时作品应保持待验收');
db.exec('DROP INDEX idx_work_versions_one_per_round');
await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id) VALUES (?,99,?,'pending',?)", [noteId, '旧多方案数据', round1]);
repairMultiSchemeRounds();
const migrated = await database.one<{ review_round_id: number; round_status: string }>('SELECT v.review_round_id,r.status AS round_status FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=99', [noteId]);
assert(migrated && Number(migrated.review_round_id) !== Number(round1) && migrated.round_status === 'completed', '旧多方案轮次未拆分为单方案历史轮次');
await database.execute('DELETE FROM work_versions WHERE note_id=? AND version_number=99', [noteId]);
await database.execute('DELETE FROM review_rounds WHERE id=?', [migrated.review_round_id]);
db.exec('CREATE UNIQUE INDEX idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL');
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 duplicateRejected = false;
try { await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id) VALUES (?,2,?,'pending',?)", [noteId, '非法第二方案', round1]); }
catch { duplicateRejected = true; }
assert(duplicateRejected, '同一验收轮次必须拒绝第二个方案');
await decideRoundInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'changes_requested', reason: '需要调整', actorName: '测试客户', actorRole: 'client' });
assert((await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=?', [noteId]))?.review_status === 'changes_requested', '要求修改后作品状态错误');
assert((await database.one<{ completion_reason: string }>('SELECT completion_reason FROM review_rounds WHERE id=?', [round1]))?.completion_reason === 'changes_requested', '退修轮次未完成');
const round2 = await database.insertId("INSERT INTO review_rounds (note_id,round_number,status) VALUES (?,2,'reviewing')", [noteId]);
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, '第 2 轮', round2]);
await database.execute("UPDATE notes SET active_round_id=?,version_number=2,title=?,review_status='pending' WHERE id=?", [round2, '第 2 轮', noteId]);
const imageId = await database.insertId("INSERT INTO images (note_id,url,version_number) VALUES (?, '/test.jpg', 2)", [noteId]);
const annotationId = await database.insertId("INSERT INTO annotations (image_id,x,y,content,author_name,author_role) VALUES (?,.5,.5,'待处理','测试运营','operator')", [imageId]);
const commentId = await database.insertId("INSERT INTO work_comments (note_id,version_number,content) VALUES (?,2,'总体意见')", [noteId]);
const feedbackTarget = await findFeedbackTarget(noteId, 'image_annotation', annotationId);
assert(feedbackTarget, '无法按作品找到图片批注');
await addFeedbackReply(feedbackTarget, 'image_annotation', annotationId, '已收到,正在处理', '测试客户', 'client');
assert((await database.one<{ count: number }>('SELECT COUNT(*) AS count FROM feedback_replies WHERE note_id=? AND feedback_id=?', [noteId, annotationId]))?.count === 1, '批注回复未保存');
await withdrawFeedback('image_annotation', annotationId);
assert(Boolean((await database.one<{ withdrawn_at: string | null }>('SELECT withdrawn_at FROM annotations WHERE id=?', [annotationId]))?.withdrawn_at), '撤回没有保留时间记录');
await decideRoundInTransaction(database, { noteId, versionNumber: 2, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' });
const note = await database.one<{ version_number: number; review_status: string }>('SELECT version_number,review_status FROM notes WHERE id=?', [noteId]);
assert(note?.version_number === 2 && note.review_status === 'approved', '第 2 轮通过后作品状态错误');
assert((await database.one<{ review_status: string }>('SELECT review_status FROM projects WHERE id=?', [project.id]))?.review_status === 'completed', '全部作品通过后项目未完成');
assert((await database.one<{ closure_reason: string }>('SELECT closure_reason FROM work_comments WHERE id=?', [commentId]))?.closure_reason === 'approved_with_round', '未处理反馈没有随轮关闭');
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');
try { await decideRoundInTransaction(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_rounds', 'review_events', 'sessions', 'customer_sessions',
'work_comments', 'feedback_replies', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
@@ -19,11 +19,16 @@ const sqlite = new Database(path.resolve('data/app.db'), { readonly: true });
try {
await client.connect();
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
const schemaSource = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8');
if (!/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_REPAIR_END/.test(schemaSource)) throw new Error('PostgreSQL schema 缺少旧多方案数据修复脚本');
const schema = schemaSource
.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(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '');
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '')
.replace(/-- PROJECT_REVIEW_STATUS_REPAIR_START[\s\S]+?-- PROJECT_REVIEW_STATUS_REPAIR_END/, '')
.replace(/-- TEXT_ANNOTATION_TARGET_REPAIR_START[\s\S]+?-- TEXT_ANNOTATION_TARGET_REPAIR_END/, '')
.replace(/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_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);
@@ -41,7 +46,8 @@ try {
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 invalidProjects = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN projects p ON p.id=n.project_id WHERE p.id IS NULL')).rows[0].count);
if (invalidRoundLinks || invalidActiveRounds || invalidProjects) throw new Error(`迁移关联无效rounds=${invalidRoundLinks}, notes=${invalidActiveRounds}, projects=${invalidProjects}`);
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,7 +1,7 @@
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 ProjectReviewStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
export type UserRole = 'platform_admin' | 'group_admin' | 'operator';
export interface CurrentUser {
@@ -96,11 +96,16 @@ export interface Project {
slug: string;
client_description: string;
status: 'active' | 'closed' | 'archived';
review_status: ProjectReviewStatus;
review_completed_at: string | null;
customer_access_enabled: boolean;
access_expires_at: string | null;
has_access_password: boolean;
collection_count: number;
work_count: number;
pending_count: number;
changes_requested_count: number;
approved_count: number;
created_at: string;
}
@@ -127,6 +132,7 @@ export interface WorkCollection {
export interface Note {
id: number;
project_id: number;
collection_id: number;
external_id: string | null;
title: string;
@@ -161,17 +167,23 @@ export interface Annotation {
y: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
closure_reason: string;
withdrawn_at: string | null;
created_at: string;
}
export interface WorkComment {
id: number;
note_id: number;
version_number: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
closure_reason: string;
withdrawn_at: string | null;
created_at: string;
}
@@ -179,10 +191,33 @@ export interface TextAnnotation {
id: number;
note_id: number;
version_number: number;
target: 'title' | 'description';
target: 'title' | 'description' | 'tags';
start_offset: number;
end_offset: number;
selected_text: string;
prefix_text: string;
suffix_text: string;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
closure_reason: string;
withdrawn_at: string | null;
created_at: string;
}
export type FeedbackType = 'image_annotation' | 'text_annotation' | 'comment';
export interface FeedbackReply {
id: number;
note_id: number;
version_number: number;
feedback_type: FeedbackType;
feedback_id: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
withdrawn_at: string | null;
created_at: string;
}
@@ -194,20 +229,18 @@ export interface NoteDetail extends Note {
images: ImageWithAnnotations[];
text_annotations: TextAnnotation[];
comments: WorkComment[];
project: Pick<Project, 'id' | 'name' | 'slug'>;
collection: Pick<WorkCollection, 'id' | 'name' | 'status'>;
versions: WorkVersion[];
feedback_replies: FeedbackReply[];
project: Pick<Project, 'id' | 'name' | 'slug' | 'status' | 'review_status'>;
rounds: WorkRound[];
review_events: ReviewEvent[];
}
export interface WorkVersion {
export interface WorkRound {
version_number: number;
review_round_id: number;
round_number: number;
candidate_name: string;
candidate_status: CandidateStatus;
round_status: ReviewRoundStatus;
selected_version_number: number | null;
completion_reason: string;
title: string;
description: string;
tags: string[];
@@ -222,6 +255,7 @@ export interface ReviewRound {
status: ReviewRoundStatus;
selected_version_number: number | null;
completed_at: string | null;
completion_reason: string;
created_at: string;
}
@@ -242,6 +276,20 @@ export interface CreateAnnotationRequest {
y: number;
content: string;
author_name?: string;
author_role?: 'client' | 'operator';
}
export interface WorkFeedbackBundle {
work_id: number;
rounds: Array<{
round_number: number;
version_number: number;
image_annotations: Array<Annotation & { image_id: number; image_url: string }>;
text_annotations: TextAnnotation[];
comments: WorkComment[];
feedback_replies: FeedbackReply[];
review_events: ReviewEvent[];
}>;
}
export interface NoteListQuery {

View File

@@ -39,6 +39,7 @@ function AppContent() {
<Route path="/review/:slug/collections/:collectionId" element={<CustomerReviewPage />} />
<Route path="/review/:slug/works/:noteId" element={<CustomerReviewPage />} />
<Route path="/works/:noteId/new-version" element={<ProtectedRoute><NewVersionPage /></ProtectedRoute>} />
<Route path="/projects/:projectId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
<Route path="/projects/:projectId/collections/:collectionId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
<Route path="/upload" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -1,4 +1,4 @@
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkCollection, WorkComment } from '@shared/types';
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, FeedbackReply, FeedbackType, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkComment, WorkFeedbackBundle } from '@shared/types';
export class ApiError extends Error {
constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; }
@@ -44,49 +44,47 @@ export const api = {
createProject: (data: { name: string; slug: string; client_description: string; groupId?: number }) => request<Project>('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProject: (id: number, data: { name: string; client_description: string }) => request<Project>(`/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateCustomerAccess: (id: number, data: { enabled: boolean; password?: string; expires_at?: string | null }) => request<Project>(`/api/projects/${id}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
listCollections: (projectId: number) => request<WorkCollection[]>(`/api/projects/${projectId}/collections`),
createCollection: (projectId: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateCollection: (projectId: number, id: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
listNotes: (query: NoteListQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
return request<Note[]>(`/api/notes?${params}`);
},
getNote: (id: number, version?: number) => request<NoteDetail>(`/api/notes/${id}${version ? `?version=${version}` : ''}`),
createNote: (payload: { collectionId: number; title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('collectionId', String(payload.collectionId));
form.append('title', payload.title);
form.append('description', payload.description);
form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>('/api/notes', { method: 'POST', body: form });
listProjectWorks: (projectId: number, query: NoteListQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
return request<Note[]>(`/api/projects/${projectId}/works?${params}`);
},
createWorkVersion: (noteId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
getWork: (id: number, round?: number) => request<NoteDetail>(`/api/works/${id}${round ? `?round=${round}` : ''}`),
getWorkFeedback: (id: number) => request<WorkFeedbackBundle>(`/api/works/${id}/annotations`),
createWork: (projectId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>(`/api/notes/${noteId}/versions`, { method: 'POST', body: form });
return request<Note>(`/api/projects/${projectId}/works`, { method: 'POST', body: form });
},
createReviewRound: (noteId: number, candidates: Array<{ candidate_name: string; title: string; description: string; tags: string[]; images: File[] }>) => {
createRound: (workId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('candidates', JSON.stringify(candidates.map((candidate) => ({ ...candidate, images: undefined, image_count: candidate.images.length }))));
candidates.forEach((candidate) => candidate.images.forEach((file) => form.append('images', file)));
return request<Note>(`/api/notes/${noteId}/review-rounds`, { method: 'POST', body: form });
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>(`/api/works/${workId}/rounds`, { method: 'POST', body: form });
},
setReviewStatus: (id: number, status: ReviewStatus) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
reopenWork: (id: number, reason: string) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }),
addAnnotation: (imageId: number, data: { x: number; y: number; content: string; author_name?: string }) => request<Annotation>(`/api/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addTextAnnotation: (noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/notes/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addTextSelectionAnnotation: (workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request<TextAnnotation>(`/api/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
replyToFeedback: (workId: number, type: FeedbackType, feedbackId: number, content: string) => request<FeedbackReply>(`/api/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
withdrawFeedback: (workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }),
deleteAnnotation: (id: number) => request<void>(`/api/annotations/${id}`, { method: 'DELETE' }),
addComment: (noteId: number, data: { content: string; author_name: string; author_role?: 'client' | 'operator' }) => request<WorkComment>(`/api/notes/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
getCustomerAccess: (slug: string) => request<CustomerAccessState>(`/api/review/${slug}/access`),
customerLogin: (slug: string, data: { reviewer_name: string; password: string }) => request<{ success: true; reviewer_name: string }>(`/api/review/${slug}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string }>(`/api/review/${slug}/project`),
getCustomerCollection: (slug: string, collectionId: number) => request<{ collection: WorkCollection; works: Note[] }>(`/api/review/${slug}/collections/${collectionId}/works`),
getCustomerWork: (slug: string, noteId: number, version?: number) => request<NoteDetail>(`/api/review/${slug}/works/${noteId}${version ? `?version=${version}` : ''}`),
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status' | 'review_status'>; works: Note[]; reviewer_name: string }>(`/api/review/${slug}/project`),
getCustomerWorkRound: (slug: string, workId: number, round?: number) => request<NoteDetail>(`/api/review/${slug}/works/${workId}${round ? `?round=${round}` : ''}`),
getCustomerWorkFeedback: (slug: string, workId: number) => request<WorkFeedbackBundle>(`/api/review/${slug}/works/${workId}/annotations`),
addCustomerComment: (slug: string, noteId: number, content: string) => request<WorkComment>(`/api/review/${slug}/works/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
addCustomerAnnotation: (slug: string, imageId: number, data: { x: number; y: number; content: string }) => request<Annotation>(`/api/review/${slug}/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addCustomerTextAnnotation: (slug: string, noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
submitCustomerDecision: (slug: string, noteId: number, versionNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version_number: versionNumber, decision, reason }) }),
addCustomerTextSelectionAnnotation: (slug: string, workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
replyToCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number, content: string) => request<FeedbackReply>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
withdrawCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }),
submitCustomerRoundDecision: (slug: string, workId: number, roundNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${workId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ round_number: roundNumber, decision, reason }) }),
};

View File

@@ -1,13 +1,14 @@
import { useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Check, MessageCircle, X } from 'lucide-react';
import type { Annotation, NoteImage } from '@shared/types';
export default function AnnotatableImage({image,annotations,onAdd,readOnly=false}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>;readOnly?:boolean}){
export default function AnnotatableImage({image,annotations,onAdd,readOnly=false,onOpen,onAnnotationOpen,initialAnnotationId}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>;readOnly?:boolean;onOpen?:(annotationId?:number)=>void;onAnnotationOpen?:(annotationId:number)=>void;initialAnnotationId?:number}){
const ref=useRef<HTMLDivElement>(null);const [point,setPoint]=useState<{x:number;y:number}|null>(null);const [selectedId,setSelectedId]=useState<number|null>(null);const [text,setText]=useState('');
useEffect(()=>setSelectedId(initialAnnotationId??null),[initialAnnotationId,image.id]);
const selected=annotations.find(annotation=>annotation.id===selectedId);
const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(selectedId!==null){setSelectedId(null);return}if(readOnly)return;const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')};
const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(onOpen){onOpen();return}if(selectedId!==null){setSelectedId(null);return}if(readOnly)return;const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')};
const submit=async()=>{if(!point||!text.trim())return;await onAdd(point.x,point.y,text.trim());setPoint(null);setText('')};
return <figure className="mb-5 break-inside-avoid"><div ref={ref} onClick={click} className={`relative overflow-hidden rounded-2xl bg-[#e9e7e0] ${readOnly?'cursor-default':'cursor-crosshair'}`} style={{aspectRatio:image.width&&image.height?`${image.width}/${image.height}`:'4/3'}}><img src={image.url} className="h-full w-full object-contain" draggable={false}/>{annotations.map((annotation,index)=><button key={annotation.id} aria-label={`查看批注 ${index+1}`} onClick={event=>{event.stopPropagation();setPoint(null);setSelectedId(current=>current===annotation.id?null:annotation.id)}} className={`absolute z-10 grid h-7 w-7 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border-2 border-white text-[10px] font-bold text-white shadow-lg transition ${selectedId===annotation.id?'scale-110 bg-black':'bg-[#ef4b2f] hover:scale-110'}`} style={{left:`${annotation.x*100}%`,top:`${annotation.y*100}%`}}>{index+1}</button>)}
{selected&&<div className="absolute z-30 w-64 max-w-[calc(100%-1rem)] rounded-2xl border border-black/10 bg-white p-4 shadow-2xl" style={{left:`${Math.min(.76,Math.max(.24,selected.x))*100}%`,top:`${selected.y*100}%`,transform:selected.y>.62?'translate(-50%, calc(-100% - 18px))':'translate(-50%, 18px)'}} onClick={event=>event.stopPropagation()}><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]"> {annotations.findIndex(annotation=>annotation.id===selected.id)+1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={()=>setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 transition hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name||'未知用户'}</span></div></div>}
{point&&<div className="absolute z-20 w-64 -translate-x-1/2 rounded-2xl bg-white p-3 shadow-2xl" style={{left:`${Math.min(.78,Math.max(.22,point.x))*100}%`,top:`${Math.min(.7,point.y)*100}%`}} onClick={event=>event.stopPropagation()}><div className="mb-2 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1"><MessageCircle size={13}/></span><button aria-label="取消添加批注" onClick={()=>setPoint(null)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={event=>setText(event.target.value)} className="w-full resize-none rounded-lg border border-black/10 p-2 text-xs" placeholder="描述需要调整的位置…"/><button onClick={submit} className="mt-2 flex w-full items-center justify-center gap-1 rounded-full bg-black py-2 text-xs text-white"><Check size={12}/></button></div>}</div></figure>
return <figure className="mb-5 break-inside-avoid"><div ref={ref} onClick={click} className={`relative overflow-hidden rounded-2xl bg-[#e9e7e0] ${onOpen?'cursor-zoom-in':readOnly?'cursor-default':'cursor-crosshair'}`} style={{aspectRatio:image.width&&image.height?`${image.width}/${image.height}`:'4/3'}}><img src={image.url} className="h-full w-full object-contain" draggable={false}/>{annotations.map((annotation,index)=><button key={annotation.id} aria-label={`查看批注 ${index+1}`} onClick={event=>{event.stopPropagation();setPoint(null);if(onAnnotationOpen){onAnnotationOpen(annotation.id);return}if(onOpen){onOpen(annotation.id);return}setSelectedId(current=>current===annotation.id?null:annotation.id)}} className={`after:content-[''] absolute z-10 grid h-[22px] w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border border-white text-[9px] font-bold text-white shadow-lg transition after:absolute after:-inset-2 after:rounded-full ${annotation.withdrawn_at?'bg-black/35':selectedId===annotation.id?'scale-110 bg-black':'bg-[#ef4b2f] hover:scale-110'}`} style={{left:`${annotation.x*100}%`,top:`${annotation.y*100}%`}}>{index+1}</button>)}
{selected&&<div className="absolute z-30 w-64 max-w-[calc(100%-1rem)] rounded-2xl border border-black/10 bg-white p-4 text-black shadow-2xl" style={{left:`${Math.min(.76,Math.max(.24,selected.x))*100}%`,top:`${selected.y*100}%`,transform:selected.y>.62?'translate(-50%, calc(-100% - 18px))':'translate(-50%, 18px)'}} onClick={event=>event.stopPropagation()}><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]"> {annotations.findIndex(annotation=>annotation.id===selected.id)+1}</p><p className={`mt-2 whitespace-pre-wrap text-sm leading-6 ${selected.withdrawn_at?'italic text-black/35':'text-black/75'}`}>{selected.withdrawn_at?'该批注已撤回(记录保留)':selected.content}</p></div><button aria-label="关闭批注" onClick={()=>setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 transition hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name||'未知用户'}</span></div></div>}
{point&&<div className="absolute z-20 w-64 -translate-x-1/2 rounded-2xl bg-white p-3 text-black shadow-2xl" style={{left:`${Math.min(.78,Math.max(.22,point.x))*100}%`,top:`${Math.min(.7,point.y)*100}%`}} onClick={event=>event.stopPropagation()}><div className="mb-2 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1"><MessageCircle size={13}/></span><button aria-label="取消添加批注" onClick={()=>setPoint(null)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={event=>setText(event.target.value)} className="w-full resize-none rounded-lg border border-black/10 p-2 text-xs text-black" placeholder="描述需要调整的位置…"/><button onClick={submit} className="mt-2 flex w-full items-center justify-center gap-1 rounded-full bg-black py-2 text-xs text-white"><Check size={12}/></button></div>}</div></figure>
}

View File

@@ -1,28 +1,44 @@
import { useState } from 'react';
import { Check, MessageCircle, Plus, X } from 'lucide-react';
import { useMemo, useRef } from 'react';
import type { TextAnnotation } from '@shared/types';
export default function AnnotatableText({ label, annotations, onAdd, children, readOnly = false }: { label: string; annotations: TextAnnotation[]; onAdd: (content: string) => Promise<void>; children: React.ReactNode; readOnly?: boolean }) {
const [adding, setAdding] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const selected = annotations.find((annotation) => annotation.id === selectedId);
export type TextSelectionDraft = { start: number; end: number; text: string };
const submit = async () => {
if (!text.trim() || busy) return;
setBusy(true);
try { await onAdd(text.trim()); setText(''); setAdding(false); }
finally { setBusy(false); }
export default function AnnotatableText({ text, annotations, onSelect, onOpenAnnotation, readOnly = false, variant = 'body' }: {
text: string;
label: string;
annotations: TextAnnotation[];
onSelect: (selection: TextSelectionDraft) => void;
onOpenAnnotation: (annotationId: number) => void;
readOnly?: boolean;
variant?: 'title' | 'body';
}) {
const root = useRef<HTMLElement>(null);
const visibleAnnotations = useMemo(() => annotations.filter((item) => !item.withdrawn_at), [annotations]);
const boundaries = useMemo(() => Array.from(new Set([0, text.length, ...visibleAnnotations.flatMap((item) => [item.start_offset, item.end_offset])])).filter((value) => value >= 0 && value <= text.length).sort((a, b) => a - b), [visibleAnnotations, text.length]);
const captureSelection = () => {
if (readOnly || !root.current) return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || !selection.rangeCount) return;
const range = selection.getRangeAt(0);
if (!root.current.contains(range.commonAncestorContainer)) return;
const before = range.cloneRange();
before.selectNodeContents(root.current);
before.setEnd(range.startContainer, range.startOffset);
const selectedText = range.toString();
const start = before.toString().length;
const end = start + selectedText.length;
if (!selectedText.trim() || text.slice(start, end) !== selectedText) return;
onSelect({ start, end, text: selectedText });
selection.removeAllRanges();
};
return <div className="relative">
{children}
<div className="mt-3 flex flex-wrap items-center gap-2">
{!readOnly && <button type="button" onClick={() => { setAdding(true); setSelectedId(null); }} className="inline-flex items-center gap-1.5 rounded-full border border-black/10 bg-white px-3 py-1.5 text-[11px] text-black/45 transition hover:border-black/25 hover:text-black"><Plus size={11}/>{label}</button>}
{annotations.map((annotation, index) => <button key={annotation.id} type="button" aria-label={`查看${label}批注 ${index + 1}`} onClick={() => { setAdding(false); setSelectedId((current) => current === annotation.id ? null : annotation.id); }} className={`grid h-7 min-w-7 place-items-center rounded-full px-2 text-[10px] font-semibold text-white transition ${selectedId === annotation.id ? 'bg-black' : 'bg-[#ef4b2f] hover:scale-105'}`}>{index + 1}</button>)}
</div>
{selected && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]">{label} {annotations.findIndex((annotation) => annotation.id === selected.id) + 1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={() => setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name || '未知用户'}</span></div></div>}
{adding && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="mb-3 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1.5"><MessageCircle size={13}/>{label}</span><button aria-label="取消添加批注" onClick={() => setAdding(false)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={(event) => setText(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 p-3 text-sm outline-none focus:border-[#ef4b2f]" placeholder={`填写针对${label}的修改意见…`}/><button disabled={busy || !text.trim()} onClick={() => void submit()} className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-full bg-black py-2.5 text-xs text-white disabled:opacity-30"><Check size={12}/>{busy ? '正在提交…' : '提交批注'}</button></div>}
</div>;
const segments = boundaries.slice(0, -1).map((start, index) => {
const end = boundaries[index + 1];
const matches = visibleAnnotations.filter((item) => item.start_offset <= start && item.end_offset >= end);
return { start, value: text.slice(start, end), matches };
});
const rendered = <>{segments.map((segment) => segment.matches.length ? <mark key={segment.start} title={segment.matches.length > 1 ? `此处有 ${segment.matches.length} 条批注` : '查看批注'} onClick={() => onOpenAnnotation(segment.matches[0].id)} className="cursor-pointer rounded-[3px] bg-[#f2cb78]/45 px-[1px] text-inherit decoration-[#cf6b42] underline decoration-[1.5px] underline-offset-4 transition hover:bg-[#f2cb78]/75">{segment.value}</mark> : <span key={segment.start}>{segment.value}</span>)}</>;
return <div onClick={(event) => event.stopPropagation()}>{variant === 'title' ? <h1 ref={root as React.RefObject<HTMLHeadingElement>} onMouseUp={captureSelection} onTouchEnd={captureSelection} className="max-w-4xl whitespace-pre-wrap font-display text-3xl leading-[1.12] tracking-[-.035em] md:text-4xl">{rendered}</h1> : <p ref={root as React.RefObject<HTMLParagraphElement>} onMouseUp={captureSelection} onTouchEnd={captureSelection} className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{rendered}</p>}</div>;
}

View File

@@ -1,13 +0,0 @@
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>;
}

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react';
import { Check, History, MessageSquareText, Reply, Send, Undo2, X } from 'lucide-react';
import type { FeedbackReply, FeedbackType, NoteDetail } from '@shared/types';
import type { TextSelectionDraft } from './AnnotatableText';
export type DrawerTextDraft = TextSelectionDraft & { label: string };
type Props = {
work: NoteDetail;
open: boolean;
setOpen: (value: boolean) => void;
readOnly: boolean;
actorName?: string;
actorRole?: 'client' | 'operator';
onComment?: (content: string) => Promise<void>;
onTextAnnotation?: (content: string) => Promise<void>;
onCancelTextAnnotation?: () => void;
onReply?: (type: FeedbackType, feedbackId: number, content: string) => Promise<void>;
onWithdraw?: (type: FeedbackType, feedbackId: number) => Promise<void>;
footer?: React.ReactNode;
focusImageId?: number;
focusFeedback?: { type: FeedbackType; id: number } | null;
textDraft?: DrawerTextDraft | null;
};
export default function CollaborationDrawer({ work, open, setOpen, readOnly, actorName, actorRole, onComment, onTextAnnotation, onCancelTextAnnotation, onReply, onWithdraw, footer, focusImageId, focusFeedback, textDraft }: Props) {
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const imageAnnotations = work.images.filter((image) => !focusImageId || image.id === focusImageId).flatMap((image) => image.annotations.map((annotation) => ({ ...annotation, imageId: image.id })));
const feedbackCount = imageAnnotations.length + work.text_annotations.length + work.comments.length;
const textAnnotationGroups = [
{ target: 'title', label: '标题', items: work.text_annotations.filter((item) => item.target === 'title') },
{ target: 'description', label: '正文', items: work.text_annotations.filter((item) => item.target === 'description') },
{ target: 'tags', label: 'Tag', items: work.text_annotations.filter((item) => item.target === 'tags') },
].filter((group) => group.items.length > 0);
const submit = async () => { if (!onComment || !comment.trim()) return; setBusy(true); try { await onComment(comment.trim()); setComment(''); } finally { setBusy(false); } };
const repliesFor = (type: FeedbackType, id: number) => (work.feedback_replies ?? []).filter((reply) => reply.feedback_type === type && Number(reply.feedback_id) === Number(id));
useEffect(() => {
if (!open || !focusFeedback) return;
const frame = window.requestAnimationFrame(() => document.getElementById(`feedback-${focusFeedback.type}-${focusFeedback.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
return () => window.cancelAnimationFrame(frame);
}, [focusFeedback, open]);
return <>
{!open && <button onClick={(event) => { event.stopPropagation(); setOpen(true); }} className="fixed bottom-5 right-5 z-50 flex items-center gap-2 rounded-full bg-[#171714] px-5 py-3.5 text-sm text-white shadow-2xl shadow-black/25 transition hover:-translate-y-0.5"><MessageSquareText size={17}/><span></span>{feedbackCount > 0 && <b className="grid h-5 min-w-5 place-items-center rounded-full bg-[#ef4b2f] px-1.5 text-[10px]">{feedbackCount}</b>}</button>}
{open && <>
<button aria-label="关闭协作面板" onClick={() => setOpen(false)} className="fixed inset-0 z-[99] bg-black/30 md:hidden"/>
<aside onClick={(event) => event.stopPropagation()} className="fixed inset-x-0 bottom-0 z-[100] flex max-h-[88vh] flex-col rounded-t-[30px] border-black/10 bg-[#efede7] shadow-2xl md:inset-y-0 md:left-auto md:w-[420px] md:max-h-none md:rounded-none md:border-l">
<header className="flex items-start justify-between border-b border-black/10 p-5"><div><p className="font-mono text-[9px] uppercase tracking-[.24em] text-[#ba623c]">Review collaboration</p><h2 className="mt-2 font-display text-3xl"></h2><p className="mt-1 text-[11px] text-black/40">{focusImageId ? '当前图片反馈' : `${work.rounds.find((round) => round.version_number === work.version_number)?.round_number ?? '-'} 轮全部反馈`}</p></div><button aria-label="关闭协作面板" onClick={() => setOpen(false)} className="grid h-9 w-9 place-items-center rounded-full bg-white transition hover:bg-black hover:text-white"><X size={15}/></button></header>
<div className="flex-1 space-y-5 overflow-auto p-4">
{textDraft && onTextAnnotation && <TextAnnotationComposer key={`${textDraft.label}-${textDraft.start}-${textDraft.end}-${textDraft.text}`} draft={textDraft} onSubmit={onTextAnnotation} onCancel={onCancelTextAnnotation}/>}
<Section title="图片批注" count={imageAnnotations.length}>{imageAnnotations.map((item, index) => <FeedbackCard key={item.id} type="image_annotation" id={item.id} focused={focusFeedback?.type === 'image_annotation' && focusFeedback.id === item.id} name={item.author_name} role={item.author_role} meta={`图片 ${work.images.findIndex((image) => image.id === item.imageId) + 1} · 标记 ${index + 1}`} content={item.content} withdrawn={Boolean(item.withdrawn_at)} replies={repliesFor('image_annotation', item.id)} {...{readOnly,actorName,actorRole,onReply,onWithdraw}}/>)}</Section>
<Section title="文字批注" count={work.text_annotations.length}>{textAnnotationGroups.map((group) => <div key={group.target} className="rounded-2xl border border-black/[.06] bg-black/[.025] p-2"><div className="mb-2 flex items-center justify-between px-1"><span className="inline-flex items-center gap-2 text-[11px] font-semibold text-black/65"><i className="h-2 w-2 rounded-full bg-[#ba623c]"/>{group.label}</span><span className="rounded-full bg-white px-2 py-0.5 text-[9px] text-black/40">{group.items.length} </span></div><div className="space-y-2">{group.items.map((item, index) => <FeedbackCard key={item.id} type="text_annotation" id={item.id} focused={focusFeedback?.type === 'text_annotation' && focusFeedback.id === item.id} name={item.author_name} role={item.author_role} meta={`批注 ${index + 1}`} context={item.selected_text} content={item.content} withdrawn={Boolean(item.withdrawn_at)} replies={repliesFor('text_annotation', item.id)} {...{readOnly,actorName,actorRole,onReply,onWithdraw}}/>)}</div></div>)}</Section>
<Section title="总体反馈" count={work.comments.length}>{work.comments.map((item) => <FeedbackCard key={item.id} type="comment" id={item.id} focused={focusFeedback?.type === 'comment' && focusFeedback.id === item.id} name={item.author_name} role={item.author_role} meta={item.author_role === 'client' ? '客户' : '工作台'} content={item.content} withdrawn={Boolean(item.withdrawn_at)} replies={repliesFor('comment', item.id)} {...{readOnly,actorName,actorRole,onReply,onWithdraw}}/>)}</Section>
{work.review_events.length > 0 && <Section title="验收记录" count={work.review_events.length}>{work.review_events.map((event) => <div key={event.id} className="rounded-xl bg-white/65 p-3 text-[11px] leading-5 text-black/55"><History className="mr-2 inline" size={12}/> {work.rounds.find((round) => round.version_number === event.version_number)?.round_number ?? event.version_number} · {event.actor_name} · {event.to_status}{event.reason && <p className="mt-1 text-black/70">{event.reason}</p>}</div>)}</Section>}
</div>
<footer className="border-t border-black/10 bg-[#f6f4ef] p-4">{!readOnly && onComment && <div className="relative"><textarea rows={3} value={comment} onChange={(event) => setComment(event.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm outline-none focus:border-[#ba623c]" placeholder="针对整个作品留下意见…"/><button aria-label="提交总体反馈" disabled={busy || !comment.trim()} onClick={() => void submit()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>}{readOnly && <p className="text-center text-xs text-black/40"></p>}{footer}</footer>
</aside>
</>}
</>;
}
function TextAnnotationComposer({ draft, onSubmit, onCancel }: { draft: DrawerTextDraft; onSubmit: (content: string) => Promise<void>; onCancel?: () => void }) {
const [content, setContent] = useState(''); const [busy, setBusy] = useState(false);
const submit = async () => { if (!content.trim() || busy) return; setBusy(true); try { await onSubmit(content.trim()); setContent(''); } finally { setBusy(false); } };
return <section className="rounded-2xl border border-[#ba623c]/20 bg-white p-4 shadow-lg shadow-[#8b5530]/5"><div className="flex items-start justify-between gap-4"><div><p className="text-[10px] font-medium uppercase tracking-[.16em] text-[#ba623c]"></p><p className="mt-2 line-clamp-3 rounded-lg bg-[#f7f1e6] px-3 py-2 text-xs leading-5 text-black/60">{draft.text}</p></div>{onCancel && <button aria-label="取消文字批注" onClick={onCancel} className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-black/5"><X size={12}/></button>}</div><textarea autoFocus rows={4} value={content} onChange={(event) => setContent(event.target.value)} className="mt-3 w-full resize-none rounded-xl border border-black/10 p-3 text-sm outline-none focus:border-[#ba623c]" placeholder="填写针对这段文字的意见…"/><button disabled={busy || !content.trim()} onClick={() => void submit()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full bg-black py-2.5 text-xs text-white disabled:opacity-30"><Check size={12}/>{busy ? '正在提交…' : '提交批注'}</button></section>;
}
function Section({ title, count, children }: { title: string; count: number; children: React.ReactNode }) { return <section><div className="mb-2 flex items-center justify-between text-xs text-black/45"><b>{title}</b><span>{count}</span></div><div className="space-y-2">{count ? children : <p className="rounded-xl border border-dashed border-black/10 py-5 text-center text-[11px] text-black/30"></p>}</div></section>; }
function FeedbackCard({ type, id, name, role, meta, context, content, withdrawn, replies, readOnly, actorName, actorRole, onReply, onWithdraw, focused }: { type: FeedbackType; id: number; name: string; role: 'client'|'operator'; meta: string; context?: string; content: string; withdrawn: boolean; replies: FeedbackReply[]; readOnly: boolean; actorName?: string; actorRole?: 'client'|'operator'; onReply?: Props['onReply']; onWithdraw?: Props['onWithdraw']; focused: boolean }) {
const [replying, setReplying] = useState(false); const [text, setText] = useState(''); const [busy, setBusy] = useState(false);
const own = actorName === name && actorRole === role;
const submit = async () => { if (!onReply || !text.trim()) return; setBusy(true); try { await onReply(type, id, text.trim()); setText(''); setReplying(false); } finally { setBusy(false); } };
return <div id={`feedback-${type}-${id}`} className={`rounded-2xl bg-white p-3 transition duration-300 ${focused ? 'ring-2 ring-[#d68a50] shadow-lg shadow-[#b96b32]/10' : ''}`}>{context && <p className="rounded-lg border-l-2 border-[#d68a50] bg-[#f8f3e9] px-3 py-2 text-xs leading-5 text-black/60">{context}</p>}<div className={`flex items-center justify-between gap-3 text-[10px] text-black/40 ${context ? 'mt-2' : ''}`}><b className="text-black/60">{name}</b><span>{meta}</span></div><p className={`mt-2 whitespace-pre-wrap text-sm leading-6 ${withdrawn ? 'italic text-black/30' : 'text-black/75'}`}>{withdrawn ? '该反馈已撤回(记录保留)' : content}</p>{replies.map((reply) => <div key={reply.id} className="ml-3 mt-2 border-l-2 border-[#e9c58a] pl-3 text-xs leading-5"><b className="text-black/50">{reply.author_name}</b><p className="text-black/65">{reply.withdrawn_at ? '该回复已撤回' : reply.content}</p></div>)}{!readOnly && !withdrawn && <div className="mt-3 flex gap-3 text-[10px] text-black/40"><button onClick={() => setReplying((value) => !value)} className="inline-flex items-center gap-1 hover:text-black"><Reply size={11}/></button>{own && onWithdraw && <button onClick={() => void onWithdraw(type, id)} className="inline-flex items-center gap-1 hover:text-[#ba623c]"><Undo2 size={11}/></button>}</div>}{replying && <div className="mt-3 flex gap-2"><input autoFocus value={text} onChange={(event) => setText(event.target.value)} className="min-w-0 flex-1 rounded-xl border border-black/10 px-3 py-2 text-xs" placeholder="回复这条反馈"/><button aria-label="提交回复" disabled={busy || !text.trim()} onClick={() => void submit()} className="rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={12}/></button></div>}</div>;
}

View File

@@ -0,0 +1,62 @@
import { useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight, Minus, Plus, RotateCcw, X } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
type Props = {
work: NoteDetail;
initialImageId: number;
readOnly: boolean;
slug?: string;
onClose: () => void;
onReload: () => Promise<void>;
onImageChange: (imageId: number) => void;
onOpenAnnotation: (imageId: number, annotationId: number) => void;
};
export default function ImageReviewModal({ work, initialImageId, readOnly, slug, onClose, onReload, onImageChange, onOpenAnnotation }: Props) {
const initialIndex = Math.max(0, work.images.findIndex((image) => image.id === initialImageId));
const [index, setIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
const viewport = useRef<HTMLDivElement>(null);
const image = work.images[index];
useEffect(() => {
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') onClose(); };
window.addEventListener('keydown', closeOnEscape);
return () => { document.body.style.overflow = previous; window.removeEventListener('keydown', closeOnEscape); };
}, [onClose]);
const go = (next: number) => {
if (!work.images[next]) return;
setIndex(next);
onImageChange(work.images[next].id);
setZoom(1);
viewport.current?.scrollTo({ left: 0, top: 0 });
};
const add = async (x: number, y: number, content: string) => {
if (slug) await api.addCustomerAnnotation(slug, image.id, { x, y, content });
else await api.addAnnotation(image.id, { x, y, content });
await onReload();
};
if (!image) return null;
return <div onClick={onClose} className="fixed inset-0 z-[90] grid place-items-center bg-black/70 p-2 backdrop-blur-md sm:p-5" role="dialog" aria-modal="true" aria-label="图片查看与批注">
<section onClick={(event) => event.stopPropagation()} className="flex h-[calc(100vh-1rem)] w-full max-w-[1500px] flex-col overflow-hidden rounded-[24px] border border-white/10 bg-[#171714] text-white shadow-2xl sm:h-[calc(100vh-2.5rem)] sm:rounded-[32px]">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-white/10 px-4 py-3 lg:px-6">
<div className="flex items-center gap-3"><button aria-label="关闭图片窗格" onClick={onClose} className="grid h-9 w-9 place-items-center rounded-full border border-white/15 text-white/70 transition hover:bg-white hover:text-black"><X size={16}/></button><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-[#f3bd69]">Image review</p><p className="mt-1 text-xs text-white/50"> {index + 1} / {work.images.length}</p></div></div>
<div className="flex flex-wrap items-center justify-end gap-2"><button onClick={() => setZoom((value) => Math.max(.5, value - .25))} aria-label="缩小" className="grid h-9 w-9 place-items-center rounded-full border border-white/15"><Minus size={14}/></button><button onClick={() => setZoom((value) => Math.min(3, value + .25))} aria-label="放大" className="grid h-9 w-9 place-items-center rounded-full border border-white/15"><Plus size={14}/></button><button onClick={() => setZoom(1)} aria-label="重置缩放" className="grid h-9 w-9 place-items-center rounded-full border border-white/15"><RotateCcw size={14}/></button></div>
</header>
<div className="flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row">
<div className="relative min-h-0 min-w-0 flex-1">
<div ref={viewport} className="h-full min-w-0 overflow-auto p-3 lg:p-6"><div className="mx-auto transition-[width] duration-200" style={{ width: image.width ? `${image.width * zoom}px` : `${1100 * zoom}px` }}><AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly={readOnly} onAdd={add} onAnnotationOpen={(annotationId) => onOpenAnnotation(image.id, annotationId)}/></div></div>
{index > 0 && <button onClick={() => go(index - 1)} aria-label="上一张图片" className="absolute left-3 top-1/2 z-40 grid h-12 w-12 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-black/70 text-white shadow-xl backdrop-blur transition hover:bg-white hover:text-black lg:left-6"><ChevronLeft size={22}/></button>}
{index < work.images.length - 1 && <button onClick={() => go(index + 1)} aria-label="下一张图片" className="absolute right-3 top-1/2 z-40 grid h-12 w-12 -translate-y-1/2 place-items-center rounded-full border border-white/15 bg-black/70 text-white shadow-xl backdrop-blur transition hover:bg-white hover:text-black lg:right-6"><ChevronRight size={22}/></button>}
</div>
</div>
</section>
</div>;
}

View File

@@ -1,22 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, ImageIcon, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
import type { Note, Project, ReviewStatus, WorkCollection } from '@shared/types';
import { api } from '@/api/client';
import StatusBadge from '@/components/StatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
import { Navigate, useParams } from 'react-router-dom';
export default function CollectionPage(){
const {projectId,collectionId}=useParams(); const pid=Number(projectId),cid=Number(collectionId); const [project,setProject]=useState<Project|null>(null); const [collection,setCollection]=useState<WorkCollection|null>(null); const [works,setWorks]=useState<Note[]>([]); const [q,setQ]=useState(''); const [status,setStatus]=useState<ReviewStatus|''>('');
const [editing,setEditing]=useState(false); const [editName,setEditName]=useState(''); const [editDesc,setEditDesc]=useState('');
const user=useAuthStore(state=>state.user);
useEffect(()=>{Promise.all([api.getProject(pid),api.listCollections(pid),api.listNotes({collectionId:cid})]).then(([p,cs,w])=>{setProject(p);setCollection(cs.find(x=>x.id===cid)||null);setWorks(w)})},[pid,cid]);
const filtered=useMemo(()=>works.filter(w=>(!q||w.title.toLowerCase().includes(q.toLowerCase()))&&(!status||w.review_status===status)),[works,q,status]); if(!project||!collection)return null;
return <main className="mx-auto max-w-[1500px] px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{project.name}</Link>
<section className="mt-8 flex flex-col gap-7 border-b border-black/10 pb-9 md:flex-row md:items-end md:justify-between"><div><div className="flex items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#ef4b2f]">Collection / Review Board</p><CollectionStatusBadge status={collection.status}/></div><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl">{collection.name}</h1><p className="mt-3 text-sm text-black/50">{collection.client_description}</p><p className="mt-3 text-xs text-black/35">{collection.work_count ? `${collection.approved_count}/${collection.work_count} 件作品已通过` : '上传首件作品后自动进入验收'}</p></div>{user&&<div className="flex flex-col gap-2 sm:flex-row"><button onClick={()=>{setEditName(collection.name);setEditDesc(collection.client_description);setEditing(true)}} className="inline-flex items-center justify-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Pencil size={14}/></button><Link to={`/projects/${pid}/collections/${cid}/upload`} className="inline-flex items-center justify-center gap-2 rounded-full bg-black px-6 py-3 text-sm text-white"><Plus size={16}/> </Link></div>}</section>
<section className="sticky top-[66px] z-30 -mx-5 mt-0 flex flex-col gap-3 border-b border-black/10 bg-[#f7f6f2]/90 px-5 py-4 backdrop-blur md:flex-row md:items-center md:justify-between lg:-mx-10 lg:px-10"><div className="flex gap-2 overflow-auto">{([['','全部'],['pending','待验收'],['changes_requested','需修改'],['approved','已通过']] as [ReviewStatus|'',string][]).map(([v,l])=><button key={l} onClick={()=>setStatus(v)} className={`whitespace-nowrap rounded-full px-4 py-2 text-xs ${status===v?'bg-black text-white':'bg-white text-black/55'}`}>{l}</button>)}</div><label className="relative"><Search className="absolute left-3 top-2.5 text-black/35" size={15}/><input value={q} onChange={e=>setQ(e.target.value)} className="w-full rounded-full border border-black/10 bg-white py-2 pl-9 pr-4 text-sm md:w-64" placeholder="搜索作品标题"/></label></section>
<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{filtered.map((work,i)=><Link to={`/works/${work.id}`} key={work.id} className="group"><article><div className={`relative overflow-hidden rounded-2xl bg-[#ebe9e3] ${i%5===0?'aspect-[4/5]':'aspect-square'}`}>{work.cover_image?<img src={work.cover_image} alt={work.title} className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/>:<div className="grid h-full place-items-center"><ImageIcon/></div>}<div className="absolute left-2 top-2"><StatusBadge status={work.review_status}/></div>{work.image_count>1&&<span className="absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] text-white">{work.image_count} </span>}</div><h3 className="mt-3 line-clamp-2 text-[15px] font-semibold leading-5">{work.title}</h3><div className="mt-2 flex items-center gap-3 text-[11px] text-black/40"><span className="flex items-center gap-1"><MessageCircle size={12}/>{work.annotation_count+work.comment_count}</span>{work.tags.slice(0,2).map(t=><span key={t}>#{t}</span>)}</div></article></Link>)}</div>
{editing&&<div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4"><div className="w-full max-w-md rounded-3xl bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><div><Pencil/><h3 className="mt-3 font-display text-3xl"></h3></div><button onClick={()=>setEditing(false)}><X/></button></div><label className="mt-7 block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={editName} onChange={e=>setEditName(e.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={editDesc} onChange={e=>setEditDesc(e.target.value)}/></label><button disabled={!editName.trim()} onClick={async()=>{const updated=await api.updateCollection(pid,cid,{name:editName,client_description:editDesc});setCollection(updated);setEditing(false)}} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></div></div>}
</main>
export default function CollectionPage() {
const { projectId } = useParams();
return <Navigate to={`/projects/${projectId}`} replace />;
}

View File

@@ -1,92 +1,68 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Check, KeyRound, MessageSquareText, Send } from 'lucide-react';
import type { CustomerAccessState, Note, NoteDetail, Project, WorkCollection } from '@shared/types';
import { Link, Navigate, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Check, KeyRound } from 'lucide-react';
import type { CustomerAccessState, Note, NoteDetail, Project } from '@shared/types';
import { api, ApiError } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import AnnotatableText, { type TextSelectionDraft } from '@/components/AnnotatableText';
import CollaborationDrawer, { type DrawerTextDraft } from '@/components/CollaborationDrawer';
import ImageReviewModal from '@/components/ImageReviewModal';
import StatusBadge from '@/components/StatusBadge';
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
import CandidateStatusBadge from '@/components/CandidateStatusBadge';
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string };
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status' | 'review_status'>; works: Note[]; reviewer_name: string };
export default function CustomerReviewPage() {
const { slug = '', collectionId, noteId } = useParams();
const [search] = useSearchParams();
const selectedVersion = search.get('version');
const selectedRound = search.get('round') ? Number(search.get('round')) : undefined;
const [access, setAccess] = useState<CustomerAccessState | null>(null);
const [projectData, setProjectData] = useState<ProjectPayload | null>(null);
const [collectionData, setCollectionData] = useState<{ collection: WorkCollection; works: Note[] } | null>(null);
const [work, setWork] = useState<NoteDetail | null>(null);
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setError('');
try {
const state = await api.getCustomerAccess(slug);
setAccess(state);
if (!state.authenticated) return;
if (noteId) {
const result = await api.getCustomerWork(slug, Number(noteId), selectedVersion ? Number(selectedVersion) : undefined);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}
else if (collectionId) setCollectionData(await api.getCustomerCollection(slug, Number(collectionId)));
else setProjectData(await api.getCustomerProject(slug));
} catch (reason) {
setError(reason instanceof ApiError ? reason.message : '页面加载失败');
}
}, [slug, collectionId, noteId, selectedVersion]);
const [name, setName] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
const load = useCallback(async () => { setError(''); try { const state = await api.getCustomerAccess(slug); setAccess(state); if (!state.authenticated) return; if (noteId) setWork(await api.getCustomerWorkRound(slug, Number(noteId), selectedRound)); else setProjectData(await api.getCustomerProject(slug)); } catch (reason) { setError(reason instanceof ApiError ? reason.message : '页面加载失败'); } }, [slug, noteId, selectedRound]);
useEffect(() => { void load(); }, [load]);
const login = async (event: React.FormEvent) => { event.preventDefault(); setBusy(true); setError(''); try { await api.customerLogin(slug, { reviewer_name: name, password }); await load(); } catch (reason) { setError(reason instanceof ApiError ? reason.message : '验证失败'); } finally { setBusy(false); } };
const login = async (event: React.FormEvent) => {
event.preventDefault(); setBusy(true); setError('');
try { await api.customerLogin(slug, { reviewer_name: name, password }); await load(); }
catch (reason) { setError(reason instanceof ApiError ? reason.message : '验证失败'); }
finally { setBusy(false); }
};
if (collectionId) return <Navigate to={`/review/${slug}`} replace/>;
if (!access && !error) return <Centered text="正在打开验收空间…"/>;
if (!access) return <Centered text={error || '项目不存在'}/>;
if (!access.enabled || access.expired) return <Centered text={access.expired ? '此项目的访问链接已到期' : '此项目暂未开放客户访问'}/>;
if (!access.authenticated) return <AccessGate access={access} name={name} password={password} error={error} busy={busy} setName={setName} setPassword={setPassword} submit={login}/>;
if (noteId) return work ? <WorkReview slug={slug} work={work} reviewer={access.reviewer_name || '客户'} reload={load}/> : <Centered text={error || '正在加载作品…'}/>;
if (collectionId) return collectionData ? <CollectionReview slug={slug} data={collectionData}/> : <Centered text={error || '正在加载作品交付集…'}/>;
return projectData ? <ProjectReview slug={slug} data={projectData}/> : <Centered text={error || '正在加载项目…'}/>;
}
function AccessGate({ access, name, password, error, busy, setName, setPassword, submit }: { access: CustomerAccessState; name: string; password: string; error: string; busy: boolean; setName: (v: string) => void; setPassword: (v: string) => void; submit: (e: React.FormEvent) => void }) {
return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[32px] border border-black/10 bg-white p-7 shadow-2xl shadow-black/5 sm:p-10"><div className="grid h-12 w-12 place-items-center rounded-full bg-[#171714] text-[#f3bd69]"><KeyRound size={18}/></div><p className="mt-8 font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Private review</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]">{access.project_name}</h1><p className="mt-4 text-sm leading-7 text-black/45">{access.client_description || '请输入姓名与项目密码,进入本次作品验收。'}</p><label className="mt-8 block text-xs text-black/50"><input autoFocus value={name} onChange={(e)=>setName(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label><label className="mt-5 block text-xs text-black/50">访<input type="password" value={password} onChange={(e)=>setPassword(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label>{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy||name.trim().length<2||!password} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-4 text-sm text-white disabled:opacity-30">{busy?'正在验证…':<><ArrowRight size={15}/></>}</button><p className="mt-5 text-center text-[11px] text-black/30"> 7 </p></form></main>;
function AccessGate({ access, name, password, error, busy, setName, setPassword, submit }: { access: CustomerAccessState; name: string; password: string; error: string; busy: boolean; setName: (value: string) => void; setPassword: (value: string) => void; submit: (event: React.FormEvent) => void }) {
return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[32px] border border-black/10 bg-white p-7 shadow-2xl shadow-black/5 sm:p-10"><div className="grid h-12 w-12 place-items-center rounded-full bg-[#171714] text-[#f3bd69]"><KeyRound size={18}/></div><p className="mt-8 font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Private review</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]">{access.project_name}</h1><p className="mt-4 text-sm leading-7 text-black/45">{access.client_description || '请输入姓名与项目密码,进入作品验收。'}</p><label className="mt-8 block text-xs text-black/50"><input autoFocus value={name} onChange={(event) => setName(event.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">访<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-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 || name.trim().length < 2 || !password} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-4 text-sm text-white disabled:opacity-30">{busy ? '正在验证…' : <><ArrowRight size={15}/></>}</button></form></main>;
}
function ProjectReview({ slug, data }: { slug: string; data: ProjectPayload }) {
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-6xl px-5 py-12 lg:px-10"><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><div className="mt-10 space-y-3">{data.collections.map((item, index)=><Link key={item.id} to={`/review/${slug}/collections/${item.id}`} className="group grid gap-4 rounded-2xl border border-black/10 bg-white p-5 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#eeeae1] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><div className="flex flex-wrap items-center gap-3"><h2 className="font-display text-2xl">{item.name}</h2><CollectionStatusBadge status={item.status}/></div><p className="mt-1 text-sm text-black/40">{item.client_description || '作品交付集验收'}</p></div><div className="flex items-center gap-5 text-xs text-black/45"><span><b className="text-lg text-black">{item.approved_count}/{item.work_count}</b> </span><ArrowRight className="transition group-hover:translate-x-1"/></div></Link>)}</div></section></main>;
}
function CollectionReview({ slug, data }: { slug: string; data: { collection: WorkCollection; works: Note[] } }) {
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.collection.name} meta={`${data.works.length} 件作品`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/></Link><CollectionStatusBadge status={data.collection.status}/></div>{data.collection.status==='completed'&&<div className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800"></div>}<div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{data.works.map((item)=><Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group overflow-hidden rounded-[24px] border border-black/10 bg-white"><div className="aspect-[4/5] overflow-hidden bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.025]"/></div><div className="p-5"><div className="flex items-center justify-between gap-3"><h2 className="font-display text-2xl leading-tight">{item.title}</h2><StatusBadge status={item.review_status}/></div></div></Link>)}</div></section></main>;
const counts = { pending: data.works.filter((item) => item.review_status === 'pending').length, changes: data.works.filter((item) => item.review_status === 'changes_requested').length, approved: data.works.filter((item) => item.review_status === 'approved').length };
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="flex flex-col gap-6 border-b border-black/10 pb-8 md:flex-row md:items-end md:justify-between"><div><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><span className="mt-4 inline-block rounded-full bg-black px-3 py-1.5 text-[10px] text-white">{data.project.review_status === 'completed' ? '验收完毕' : data.project.review_status === 'reviewing' ? '验收中' : '待提交'}</span></div><div className="flex gap-7 text-right"><Metric n={counts.pending} label="待验收"/><Metric n={counts.changes} label="需修改"/><Metric n={counts.approved} label="已通过"/></div></div>{data.project.review_status === 'completed' && <div className="mt-6 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800"></div>}<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-8 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{data.works.map((item) => <Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group"><div className="relative aspect-[4/5] overflow-hidden rounded-[22px] bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/><div className="absolute left-2 top-2"><StatusBadge status={item.review_status}/></div></div><h2 className="mt-3 font-display text-[22px] leading-6">{item.title}</h2><p className="mt-1 text-[11px] text-black/35"> {item.version_number} </p></Link>)}</div></section></main>;
}
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
const [comment,setComment]=useState(''); const [reason,setReason]=useState(''); const [busy,setBusy]=useState(false); const [actionError,setActionError]=useState('');
const viewed=work.versions.find((version)=>version.version_number===work.version_number);
const activeCandidate=Boolean(viewed&&Number(viewed.review_round_id)===Number(work.active_round_id)&&viewed.round_status==='reviewing');
const readOnly=work.collection.status==='completed'||!activeCandidate;
const canDecide=Boolean(viewed&&activeCandidate&&['pending','changes_requested'].includes(viewed.candidate_status));
const send=async()=>{if(!comment.trim())return;setBusy(true);setActionError('');try{await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload()}catch(reason){setActionError(reason instanceof Error?reason.message:'反馈提交失败')}finally{setBusy(false)}};
const decide=async(decision:'approved'|'changes_requested')=>{if(!viewed||decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm(`确认选择并通过“${viewed.candidate_name}”吗?同轮其他候选稿将标记为未选用。`))return;setBusy(true);setActionError('');try{await api.submitCustomerDecision(slug,work.id,viewed.version_number,decision,reason.trim());setReason('');await reload()}catch(error){setActionError(error instanceof Error?error.message:'验收提交失败')}finally{setBusy(false)}};
const [drawerOpen, setDrawerOpen] = useState(false); const [reason, setReason] = useState(''); const [busy, setBusy] = useState(false); const [actionError, setActionError] = useState('');
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
const [focusImageId, setFocusImageId] = useState<number>();
const [textDraft, setTextDraft] = useState<(DrawerTextDraft & { target: 'title' | 'description' | 'tags' }) | null>(null);
const [focusFeedback, setFocusFeedback] = useState<{ type: 'text_annotation' | 'image_annotation'; id: number } | null>(null);
const viewed = work.rounds.find((round) => round.version_number === work.version_number);
const current = Boolean(viewed && Number(viewed.review_round_id) === Number(work.active_round_id));
const readOnly = work.project.status !== 'active' || work.project.review_status === 'completed' || !current || viewed?.round_status !== 'reviewing';
const tagsText = work.tags.join(' ');
const selectText = (target: 'title' | 'description' | 'tags', label: string, selection: TextSelectionDraft) => { setTextDraft({ target, label, ...selection }); setFocusImageId(undefined); setFocusFeedback(null); setDrawerOpen(true); };
const openTextAnnotation = (annotationId: number) => { setTextDraft(null); setFocusImageId(undefined); setFocusFeedback({ type: 'text_annotation', id: annotationId }); setDrawerOpen(true); };
const openImage = (imageId: number) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setTextDraft(null); setFocusFeedback(null); };
const openImageAnnotation = (imageId: number, annotationId: number) => { setFocusImageId(imageId); setTextDraft(null); setFocusFeedback({ type: 'image_annotation', id: annotationId }); setDrawerOpen(true); };
const openFeedback = work.images.flatMap((image) => image.annotations).filter((item) => item.status === 'open').length + work.text_annotations.filter((item) => item.status === 'open').length + work.comments.filter((item) => item.status === 'open').length;
const decide = async (decision: 'approved' | 'changes_requested') => { if (!viewed || decision === 'changes_requested' && !reason.trim()) return; if (decision === 'approved' && !window.confirm(openFeedback ? `当前还有 ${openFeedback} 条未处理反馈。确认通过并将其标记为“随本轮通过关闭”吗?` : '确认通过当前轮次吗?')) return; setBusy(true); setActionError(''); try { await api.submitCustomerRoundDecision(slug, work.id, viewed.round_number, decision, reason.trim()); setReason(''); await reload(); } catch (error) { setActionError(error instanceof Error ? error.message : '验收提交失败'); } finally { setBusy(false); } };
const footer = !readOnly ? <div className="mt-3"><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 bg-white p-3 text-xs" placeholder="要求修改时,请填写原因"/><div className="mt-2 grid grid-cols-2 gap-2"><button disabled={busy || !reason.trim()} onClick={() => void decide('changes_requested')} className="rounded-full border border-[#ba623c]/30 bg-white py-3 text-xs text-[#a94e2c] disabled:opacity-30"></button><button disabled={busy} onClick={() => void decide('approved')} className="flex items-center justify-center gap-2 rounded-full bg-emerald-600 py-3 text-xs text-white"><Check size={14}/></button></div>{actionError && <p className="mt-3 rounded-xl bg-red-50 p-3 text-xs text-red-700">{actionError}</p>}</div> : null;
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · 第 ${viewed?.round_number??'-'}`}/><div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]"><article className="min-w-0 px-5 py-9 lg:px-10 lg:py-12"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/>{work.collection.name}</Link><CollectionStatusBadge status={work.collection.status}/></div>
<div className="mt-7 rounded-[24px] border border-black/10 bg-white p-4"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-[#ba623c]">Review candidates</p><h2 className="mt-1 font-display text-2xl">稿</h2></div><span className="text-[10px] text-black/35"> {work.versions[0]?.round_number??1} </span></div><div className="mt-4 flex flex-wrap gap-2">{work.versions.map((item)=><Link key={item.version_number} to={`/review/${slug}/works/${work.id}?version=${item.version_number}`} className={`flex items-center gap-2 rounded-full border px-3 py-2 text-[11px] transition ${item.version_number===work.version_number?'border-black bg-black text-white':'border-black/10 bg-[#f7f5ef] text-black/55'}`}><span> {item.round_number} · {item.candidate_name}</span><CandidateStatusBadge status={item.candidate_status}/></Link>)}</div></div>
{readOnly&&<div className="mt-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55">{work.collection.status==='completed'?'本轮已完成,所有候选稿与历史批注均为只读。':'这是历史验收轮次,仅供查看。切换到当前轮次可继续批注和验收。'}</div>}
<p className="mb-5 mt-8 text-xs text-black/45">{work.project.name} <span className="mx-2 text-black/20">/</span> {work.collection.name} <span className="mx-2 text-black/20">/</span> <b className="font-mono text-[#ba623c]">{viewed?.candidate_name??`V${work.version_number}`}</b></p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((img)=><AnnotatableImage key={img.id} image={img} annotations={img.annotations} readOnly={readOnly} onAdd={async(x,y,content)=>{await api.addCustomerAnnotation(slug,img.id,{x,y,content});await reload()}}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation)=>annotation.target==='title')} readOnly={readOnly} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'title',content});await reload()}}><h1 className="font-display text-4xl leading-[1.08] tracking-[-.04em] md:text-5xl">{work.title}</h1></AnnotatableText>{work.description&&<div className="mt-7"><AnnotatableText label="正文" annotations={work.text_annotations.filter((annotation)=>annotation.target==='description')} readOnly={readOnly} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'description',content});await reload()}}><p className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.description}</p></AnnotatableText></div>}{work.tags.length>0&&<p className="mt-7 max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.tags.join(' ')}</p>}</div></article>
<aside className="border-t border-black/10 bg-[#efebe3] lg:sticky lg:top-0 lg:h-screen lg:border-l lg:border-t-0"><div className="flex h-full flex-col"><div className="border-b border-black/10 p-5"><p className="font-mono text-[10px] uppercase tracking-[.25em] text-black/35">Review</p><div className="mt-2 flex items-center justify-between gap-3"><h2 className="font-display text-3xl"></h2>{viewed&&<CandidateStatusBadge status={viewed.candidate_status}/>}</div></div><div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length===0&&<div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/></div>}{work.comments.map((item)=><div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role==='operator'?'ml-5 bg-black text-white':'mr-5 bg-white'}`}><div className="mb-2 text-[10px] opacity-45">{item.author_name}</div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}</div>{readOnly?<div className="border-t border-black/10 p-5 text-center text-xs leading-5 text-black/40">稿</div>:<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(event)=>setComment(event.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="针对整个作品留下意见…"/><button disabled={busy||!comment.trim()} onClick={()=>void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{canDecide&&<><textarea rows={2} value={reason} onChange={(event)=>setReason(event.target.value)} className="mt-3 w-full resize-none rounded-xl border border-black/10 bg-white p-3 text-xs" placeholder="要求修改时,请填写原因"/><div className="mt-2 grid grid-cols-2 gap-2"><button disabled={busy||!reason.trim()} onClick={()=>void decide('changes_requested')} className="rounded-full border border-[#ba623c]/30 bg-white py-3 text-xs text-[#a94e2c] disabled:opacity-30"></button><button disabled={busy} onClick={()=>void decide('approved')} className="flex items-center justify-center gap-2 rounded-full bg-emerald-600 py-3 text-xs text-white"><Check size={14}/></button></div></>}{actionError&&<p className="mt-3 rounded-xl bg-red-50 p-3 text-xs text-red-700">{actionError}</p>}</div>}</div></aside></div></main>;
return <main onClick={() => { if (drawerOpen) setDrawerOpen(false); }} className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · 第 ${viewed?.round_number ?? '-'}`}/><article className="mx-auto max-w-[1280px] px-5 py-9 lg:px-10 lg:py-12"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/></Link><div className="flex flex-wrap gap-2">{work.rounds.map((round) => <Link key={round.round_number} to={`/review/${slug}/works/${work.id}?round=${round.round_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${round.round_number === viewed?.round_number ? 'bg-black text-white' : 'bg-black/5'}`}> {round.round_number} </Link>)}</div></div>{!current && <div className="mt-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55"></div>}<p className="mb-5 mt-8 text-xs text-black/45">{work.project.name}<span className="mx-2 text-black/20">/</span>Work {String(work.id).padStart(3, '0')}<span className="mx-2 text-black/20">/</span> {viewed?.round_number} </p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly onAdd={async () => undefined} onOpen={() => openImage(image.id)} onAnnotationOpen={(annotationId) => openImageAnnotation(image.id, annotationId)}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText text={work.title} label="标题" variant="title" annotations={work.text_annotations.filter((item) => item.target === 'title')} readOnly={readOnly} onSelect={(selection) => selectText('title', '标题', selection)} onOpenAnnotation={openTextAnnotation}/>{work.description && <div className="mt-9"><AnnotatableText text={work.description} label="正文" annotations={work.text_annotations.filter((item) => item.target === 'description')} readOnly={readOnly} onSelect={(selection) => selectText('description', '正文', selection)} onOpenAnnotation={openTextAnnotation}/></div>}{tagsText && <div className="mt-8"><AnnotatableText text={tagsText} label="Tag" annotations={work.text_annotations.filter((item) => item.target === 'tags')} readOnly={readOnly} onSelect={(selection) => selectText('tags', 'Tag', selection)} onOpenAnnotation={openTextAnnotation}/></div>}</div></article><CollaborationDrawer work={work} open={drawerOpen} setOpen={setDrawerOpen} readOnly={readOnly} actorName={reviewer} actorRole="client" focusImageId={focusImageId} textDraft={textDraft} focusFeedback={focusFeedback} onCancelTextAnnotation={() => setTextDraft(null)} onTextAnnotation={async (content) => { if (!textDraft || !viewed) return; await api.addCustomerTextSelectionAnnotation(slug, work.id, { round_number: viewed.round_number, target: textDraft.target, start_offset: textDraft.start, end_offset: textDraft.end, selected_text: textDraft.text, content }); setTextDraft(null); await reload(); }} onComment={async (content) => { await api.addCustomerComment(slug, work.id, content); await reload(); }} onReply={async (type, feedbackId, content) => { await api.replyToCustomerFeedback(slug, work.id, type, feedbackId, content); await reload(); }} onWithdraw={async (type, feedbackId) => { await api.withdrawCustomerFeedback(slug, work.id, type, feedbackId); await reload(); }} footer={footer}/>{selectedImage && <ImageReviewModal work={work} initialImageId={selectedImage.imageId} readOnly={readOnly} slug={slug} onClose={() => { setSelectedImage(null); setDrawerOpen(false); }} onReload={reload} onImageChange={(imageId) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setFocusFeedback(null); }} onOpenAnnotation={openImageAnnotation}/>}</main>;
}
function ReviewHeader({title,meta}:{title:string;meta:string}) { return <header className="border-b border-white/10 bg-[#171714] text-white"><div className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-5 lg:px-10"><div><p className="font-mono text-[9px] uppercase tracking-[.25em] text-[#f3bd69]">Delivery Desk</p><h1 className="mt-1 font-display text-2xl">{title}</h1></div><span className="rounded-full border border-white/15 px-3 py-1.5 text-[10px] text-white/55">{meta}</span></div></header> }
function Centered({text}:{text:string}) { return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 text-center text-sm text-black/45">{text}</main> }
function ReviewHeader({ title, meta }: { title: string; meta: string }) { return <header className="border-b border-white/10 bg-[#171714] text-white"><div className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-5 lg:px-10"><div><p className="font-mono text-[9px] uppercase tracking-[.25em] text-[#f3bd69]">Delivery Desk</p><h1 className="mt-1 font-display text-2xl">{title}</h1></div><span className="rounded-full border border-white/15 px-3 py-1.5 text-[10px] text-white/55">{meta}</span></div></header>; }
function Metric({ n, label }: { n: number; label: string }) { return <div><b className="font-display text-3xl">{n}</b><span className="mt-1 block text-[10px] text-black/35">{label}</span></div>; }
function Centered({ text }: { text: string }) { return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 text-center text-sm text-black/45">{text}</main>; }

View File

@@ -6,29 +6,68 @@ import { api } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
export default function Dashboard() {
const [projects,setProjects]=useState<Project[]>([]); const [groups,setGroups]=useState<OperationGroup[]>([]); const [accounts,setAccounts]=useState<ManagedUser[]>([]); const [storage,setStorage]=useState<StorageConfig[]>([]); const [logs,setLogs]=useState<AuditLogEntry[]>([]);
const [creating,setCreating]=useState(false); const [editingGroup,setEditingGroup]=useState(false); const [groupName,setGroupName]=useState(''); const [form,setForm]=useState({name:'',slug:'',client_description:'',groupId:''});
const {user,initialize}=useAuthStore();
const load=()=>api.listProjects().then(setProjects);
useEffect(()=>{void load();if(user?.role==='platform_admin')void Promise.all([api.listGroups(),api.listManagedUsers(),api.listStorageConfigs(),api.listAuditLogs()]).then(([g,a,s,l])=>{setGroups(g);setAccounts(a);setStorage(s);setLogs(l)});else if(user?.role==='group_admin')void api.listGroups().then(setGroups)},[user?.role]);
const activeStorage=storage.find((item)=>item.status==='active');
const metrics=useMemo(()=>[{label:'运营组',value:groups.filter((item)=>item.status==='active').length,icon:<Users size={18}/>},{label:'有效账号',value:accounts.filter((item)=>item.status==='active').length,icon:<ShieldCheck size={18}/>},{label:'进行中项目',value:projects.filter((item)=>item.status==='active').length,icon:<FolderKanban size={18}/>},{label:'对象存储',value:activeStorage?'已连接':'未配置',icon:<Database size={18}/>}],[groups,accounts,projects,activeStorage]);
const create=async()=>{await api.createProject({...form,groupId:form.groupId?Number(form.groupId):undefined});setCreating(false);setForm({name:'',slug:'',client_description:'',groupId:''});await load()};
const [projects, setProjects] = useState<Project[]>([]);
const [groups, setGroups] = useState<OperationGroup[]>([]);
const [accounts, setAccounts] = useState<ManagedUser[]>([]);
const [storage, setStorage] = useState<StorageConfig[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [creating, setCreating] = useState(false);
const [editingGroup, setEditingGroup] = useState(false);
const [groupName, setGroupName] = useState('');
const [form, setForm] = useState({ name: '', slug: '', client_description: '', groupId: '' });
const { user, initialize } = useAuthStore();
const load = () => api.listProjects().then(setProjects);
useEffect(() => {
void load();
if (user?.role === 'platform_admin') {
void Promise.all([api.listGroups(), api.listManagedUsers(), api.listStorageConfigs(), api.listAuditLogs()])
.then(([groupRows, accountRows, storageRows, logRows]) => {
setGroups(groupRows); setAccounts(accountRows); setStorage(storageRows); setLogs(logRows);
});
} else if (user?.role === 'group_admin') {
void api.listGroups().then(setGroups);
}
}, [user?.role]);
const activeStorage = storage.find((item) => item.status === 'active');
const metrics = useMemo(() => [
{ label: '运营组', value: groups.filter((item) => item.status === 'active').length, icon: <Users size={18} /> },
{ label: '有效账号', value: accounts.filter((item) => item.status === 'active').length, icon: <ShieldCheck size={18} /> },
{ label: '进行中项目', value: projects.filter((item) => item.status === 'active').length, icon: <FolderKanban size={18} /> },
{ label: '对象存储', value: activeStorage ? '已连接' : '未配置', icon: <Database size={18} /> },
], [groups, accounts, projects, activeStorage]);
const create = async () => {
await api.createProject({ ...form, groupId: form.groupId ? Number(form.groupId) : undefined });
setCreating(false);
setForm({ name: '', slug: '', client_description: '', groupId: '' });
await load();
};
return <main className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10 lg:py-14">
{user?.role==='platform_admin'?<>
<section className="grid gap-8 border-b border-black/10 pb-10 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Platform / Governance</p><h1 className="mt-4 max-w-4xl font-display text-5xl leading-[.96] tracking-[-.055em] sm:text-7xl"><br/><em className="font-light text-black/35"></em></h1></div><div className="flex flex-wrap gap-2"><Link to="/management" className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><ShieldCheck size={15}/></Link><button onClick={()=>setCreating(true)} className="inline-flex items-center gap-2 rounded-full bg-black px-5 py-3 text-sm text-white"><Plus size={15}/></button></div></section>
<section className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{metrics.map((item)=><div key={item.label} className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center justify-between text-black/35"><span className="grid h-9 w-9 place-items-center rounded-full bg-[#f0ede6]">{item.icon}</span><span className="font-mono text-[9px] uppercase tracking-[.2em]">Live</span></div><strong className="mt-7 block font-display text-4xl">{item.value}</strong><span className="mt-1 block text-xs text-black/40">{item.label}</span></div>)}</section>
<section className="mt-8 grid gap-5 xl:grid-cols-[1.25fr_.75fr]"><div className="rounded-[28px] border border-black/[.08] bg-white p-6"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-black/35">Projects</p><h2 className="mt-2 font-display text-3xl"></h2></div><span className="text-xs text-black/35">{projects.length} </span></div><div className="mt-6 grid gap-3 sm:grid-cols-2">{projects.slice(0,6).map((project)=><Link key={project.id} to={`/projects/${project.id}`} className="group rounded-2xl border border-black/10 p-4 transition hover:border-black/30"><div className="flex justify-between"><FolderKanban size={16}/><ArrowUpRight size={15} className="text-black/25 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5"/></div><div className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-[#f3ece5] px-2.5 py-1 text-[10px] text-[#9b5236]"><Users size={11}/>{project.group_name}</div><h3 className="mt-3 font-display text-2xl">{project.name}</h3><p className="mt-2 text-xs text-black/40">{project.collection_count} · {project.work_count} </p></Link>)}</div></div><div className="rounded-[28px] bg-[#171714] p-6 text-white"><div className="flex items-center gap-2 text-white/40"><Activity size={15}/><span className="font-mono text-[9px] uppercase tracking-[.22em]">Recent activity</span></div><h2 className="mt-3 font-display text-3xl"></h2><div className="mt-6 space-y-4">{logs.slice(0,6).map((log)=><div key={log.id} className="border-b border-white/10 pb-3"><p className="text-xs text-white/75">{log.action}</p><p className="mt-1 text-[10px] text-white/30">{log.user_name||'系统'} · {new Date(log.created_at).toLocaleString('zh-CN')}</p></div>)}{logs.length===0&&<p className="py-10 text-center text-xs text-white/30"></p>}</div></div></section>
</>:<>
<section className="grid gap-10 border-b border-black/10 pb-12 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-4 flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Operations / {user?.group_name}</p>{user?.role==='group_admin'&&<button onClick={()=>{setGroupName(user.group_name||'');setEditingGroup(true)}} className="inline-flex items-center gap-1 text-[10px] text-black/35 hover:text-black"><Pencil size={10}/></button>}</div><h1 className="max-w-4xl font-display text-5xl leading-[.95] tracking-[-.055em] sm:text-7xl lg:text-[92px]"><br/><em className="font-light text-black/35"></em></h1></div><button onClick={()=>setCreating(true)} className="flex items-center justify-center gap-2 rounded-full bg-[#171714] px-6 py-3.5 text-sm text-white hover:bg-[#ba623c]"><Plus size={17}/></button></section>
<ProjectGrid projects={projects}/>
{user?.role === 'platform_admin' ? <>
<section className="grid gap-8 border-b border-black/10 pb-10 lg:grid-cols-[1fr_auto] lg:items-end">
<div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Platform / Governance</p><h1 className="mt-4 max-w-4xl font-display text-5xl leading-[.96] tracking-[-.055em] sm:text-7xl"><br /><em className="font-light text-black/35"></em></h1></div>
<div className="flex flex-wrap gap-2"><Link to="/management" className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><ShieldCheck size={15} /></Link><button onClick={() => setCreating(true)} className="inline-flex items-center gap-2 rounded-full bg-black px-5 py-3 text-sm text-white"><Plus size={15} /></button></div>
</section>
<section className="mt-8 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">{metrics.map((item) => <div key={item.label} className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center justify-between text-black/35"><span className="grid h-9 w-9 place-items-center rounded-full bg-[#f0ede6]">{item.icon}</span><span className="font-mono text-[9px] uppercase tracking-[.2em]">Live</span></div><strong className="mt-7 block font-display text-4xl">{item.value}</strong><span className="mt-1 block text-xs text-black/40">{item.label}</span></div>)}</section>
<section className="mt-8 grid gap-5 xl:grid-cols-[1.25fr_.75fr]">
<div className="rounded-[28px] border border-black/[.08] bg-white p-6"><div className="flex items-center justify-between"><div><p className="font-mono text-[9px] uppercase tracking-[.22em] text-black/35">Projects</p><h2 className="mt-2 font-display text-3xl"></h2></div><span className="text-xs text-black/35">{projects.length} </span></div><div className="mt-6 grid gap-3 sm:grid-cols-2">{projects.slice(0, 6).map((project) => <Link key={project.id} to={`/projects/${project.id}`} className="group rounded-2xl border border-black/10 p-4 transition hover:border-black/30"><div className="flex justify-between"><FolderKanban size={16} /><ArrowUpRight size={15} className="text-black/25 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" /></div><div className="mt-5 inline-flex items-center gap-1.5 rounded-full bg-[#f3ece5] px-2.5 py-1 text-[10px] text-[#9b5236]"><Users size={11} />{project.group_name}</div><h3 className="mt-3 font-display text-2xl">{project.name}</h3><p className="mt-2 text-xs text-black/40">{project.work_count} · {project.approved_count} </p></Link>)}</div></div>
<div className="rounded-[28px] bg-[#171714] p-6 text-white"><div className="flex items-center gap-2 text-white/40"><Activity size={15} /><span className="font-mono text-[9px] uppercase tracking-[.22em]">Recent activity</span></div><h2 className="mt-3 font-display text-3xl"></h2><div className="mt-6 space-y-4">{logs.slice(0, 6).map((log) => <div key={log.id} className="border-b border-white/10 pb-3"><p className="text-xs text-white/75">{log.action}</p><p className="mt-1 text-[10px] text-white/30">{log.user_name || '系统'} · {new Date(log.created_at).toLocaleString('zh-CN')}</p></div>)}{logs.length === 0 && <p className="py-10 text-center text-xs text-white/30"></p>}</div></div>
</section>
</> : <>
<section className="grid gap-10 border-b border-black/10 pb-12 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="mb-4 flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Operations / {user?.group_name}</p>{user?.role === 'group_admin' && <button onClick={() => { setGroupName(user.group_name || ''); setEditingGroup(true); }} className="inline-flex items-center gap-1 text-[10px] text-black/35 hover:text-black"><Pencil size={10} /></button>}</div><h1 className="max-w-4xl font-display text-5xl leading-[.95] tracking-[-.055em] sm:text-7xl lg:text-[92px]"><br /><em className="font-light text-black/35"></em></h1></div><button onClick={() => setCreating(true)} className="flex items-center justify-center gap-2 rounded-full bg-[#171714] px-6 py-3.5 text-sm text-white hover:bg-[#ba623c]"><Plus size={17} /></button></section>
<ProjectGrid projects={projects} />
</>}
{creating&&<Modal close={()=>setCreating(false)} title="创建项目" icon={<FolderKanban/>}><div className="mt-7 space-y-5">{user?.role==='platform_admin'&&<Field label="所属运营组"><select value={form.groupId} onChange={(e)=>setForm({...form,groupId:e.target.value})}><option value=""></option>{groups.filter((item)=>item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>}<Field label="项目名称"><input value={form.name} onChange={(e)=>setForm({...form,name:e.target.value})}/></Field><Field label="项目标识"><input placeholder="project-slug" value={form.slug} onChange={(e)=>setForm({...form,slug:e.target.value})}/></Field><Field label="客户页简介"><textarea rows={3} value={form.client_description} onChange={(e)=>setForm({...form,client_description:e.target.value})}/></Field></div><button disabled={!form.name||!form.slug||(user?.role==='platform_admin'&&!form.groupId)} onClick={()=>void create()} className="mt-7 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editingGroup&&<Modal close={()=>setEditingGroup(false)} title="修改运营组名称" icon={<Pencil/>}><p className="mt-3 text-sm leading-6 text-black/45"></p><input autoFocus value={groupName} onChange={(e)=>setGroupName(e.target.value)} className="mt-6 w-full rounded-xl border border-black/10 bg-white p-3" placeholder="请输入运营组名称"/><button disabled={groupName.trim().length<2} onClick={async()=>{await api.updateCurrentGroup(groupName.trim());await initialize();setEditingGroup(false)}} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{creating && <Modal close={() => setCreating(false)} title="创建项目" icon={<FolderKanban />}><div className="mt-7 space-y-5">{user?.role === 'platform_admin' && <Field label="所属运营组"><select value={form.groupId} onChange={(event) => setForm({ ...form, groupId: event.target.value })}><option value=""></option>{groups.filter((item) => item.status === 'active').map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>}<Field label="项目名称"><input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Field><Field label="项目标识"><input placeholder="project-slug" value={form.slug} onChange={(event) => setForm({ ...form, slug: event.target.value })} /></Field><Field label="客户页简介"><textarea rows={3} value={form.client_description} onChange={(event) => setForm({ ...form, client_description: event.target.value })} /></Field></div><button disabled={!form.name || !form.slug || (user?.role === 'platform_admin' && !form.groupId)} onClick={() => void create()} className="mt-7 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editingGroup && <Modal close={() => setEditingGroup(false)} title="修改运营组名称" icon={<Pencil />}><p className="mt-3 text-sm leading-6 text-black/45"></p><input autoFocus value={groupName} onChange={(event) => setGroupName(event.target.value)} className="mt-6 w-full rounded-xl border border-black/10 bg-white p-3" placeholder="请输入运营组名称" /><button disabled={groupName.trim().length < 2} onClick={async () => { await api.updateCurrentGroup(groupName.trim()); await initialize(); setEditingGroup(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
</main>;
}
function ProjectGrid({projects}:{projects:Project[]}){return <><div className="mb-6 mt-10 flex items-center justify-between"><h2 className="font-display text-3xl">项目</h2><span className="font-mono text-xs text-black/40">{projects.length} ACTIVE</span></div><section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{projects.map((project,index)=><Link key={project.id} to={`/projects/${project.id}`} className="group relative min-h-64 overflow-hidden rounded-[28px] border border-black/10 bg-white p-7 transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/10"><div className="absolute right-0 top-0 h-36 w-36 translate-x-12 -translate-y-12 rounded-full" style={{background:index%3===0?'#ffddd2':index%3===1?'#dcece6':'#f6e8b9'}}/><div className="relative flex h-full flex-col"><div className="flex items-center justify-between"><FolderKanban size={20}/><ArrowUpRight className="opacity-30 transition group-hover:translate-x-1 group-hover:-translate-y-1 group-hover:opacity-100"/></div><div className="mt-auto"><h3 className="font-display text-3xl tracking-tight">{project.name}</h3><p className="mt-2 line-clamp-2 text-sm leading-6 text-black/50">{project.client_description||'暂无项目说明'}</p><div className="mt-6 flex gap-5 border-t border-black/10 pt-4 text-xs text-black/50"><span><b className="text-black">{project.collection_count}</b> </span><span><b className="text-black">{project.work_count}</b> </span></div></div></div></Link>)}</section></>}
function Modal({close,title,icon,children}:{close:()=>void;title:string;icon:React.ReactNode;children:React.ReactNode}){return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4 backdrop-blur-sm"><div className="w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-7 shadow-2xl"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X/></button></div>{children}</div></div>}
function Field({label,children}:{label:string;children:React.ReactNode}){return <label className="block text-xs font-medium text-black/55">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3">{children}</div></label>}
function ProjectGrid({ projects }: { projects: Project[] }) {
return <><div className="mb-6 mt-10 flex items-center justify-between"><h2 className="font-display text-3xl"></h2><span className="font-mono text-xs text-black/40">{projects.length} ACTIVE</span></div><section className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">{projects.map((project, index) => <Link key={project.id} to={`/projects/${project.id}`} className="group relative min-h-64 overflow-hidden rounded-[28px] border border-black/10 bg-white p-7 transition hover:-translate-y-1 hover:shadow-2xl hover:shadow-black/10"><div className="absolute right-0 top-0 h-36 w-36 translate-x-12 -translate-y-12 rounded-full" style={{ background: index % 3 === 0 ? '#ffddd2' : index % 3 === 1 ? '#dcece6' : '#f6e8b9' }} /><div className="relative flex h-full flex-col"><div className="flex items-center justify-between"><FolderKanban size={20} /><ArrowUpRight className="opacity-30 transition group-hover:translate-x-1 group-hover:-translate-y-1 group-hover:opacity-100" /></div><div className="mt-auto"><h3 className="font-display text-3xl tracking-tight">{project.name}</h3><p className="mt-2 line-clamp-2 text-sm leading-6 text-black/50">{project.client_description || '暂无项目说明'}</p><div className="mt-6 flex gap-5 border-t border-black/10 pt-4 text-xs text-black/50"><span><b className="text-black">{project.work_count}</b> </span><span><b className="text-black">{project.approved_count}</b> </span></div></div></div></Link>)}</section></>;
}
function Modal({ close, title, icon, children }: { close: () => void; title: string; icon: React.ReactNode; children: React.ReactNode }) { return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4 backdrop-blur-sm"><div className="w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-7 shadow-2xl"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X /></button></div>{children}</div></div>; }
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <label className="block text-xs font-medium text-black/55">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3">{children}</div></label>; }

View File

@@ -13,8 +13,8 @@ const actionLabel: Record<string, string> = {
'group.create': '创建运营组', 'group.active': '启用运营组', 'group.disabled': '停用运营组',
'group.admin_replace':'更换组管理员','group.rename':'修改运营组名称','user.create': '创建账号', 'user.active': '启用账号', 'user.disabled': '停用账号', 'user.password_reset': '重置密码','user.name_update':'修改用户名',
'api_key.create': '创建 API Key', 'api_key.revoke': '吊销 API Key',
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '创建作品交付集',
'collection.update': '修改作品交付集', 'work.create': '上传作品',
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '初始化项目兼容容器',
'collection.update': '更新项目兼容容器', 'work.create': '上传作品',
};
export default function ManagementPage() {

View File

@@ -1,47 +1,38 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, GripVertical, ImagePlus, Layers3, Plus, Trash2 } from 'lucide-react';
import { ArrowLeft, GripVertical, ImagePlus } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
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 [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[]>([]);
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 [images, setImages] = useState<ImageItem[]>([]);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const imagesRef = useRef<ImageItem[]>([]);
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>;
useEffect(() => { void api.getWork(id).then((item) => { setWork(item); setTitle(item.title); setDescription(item.description); setTags(item.tags.join(', ')); }).catch((reason) => setError(reason instanceof Error ? reason.message : '作品加载失败')); }, [id]);
useEffect(() => { imagesRef.current = images; }, [images]);
useEffect(() => () => imagesRef.current.forEach((image) => URL.revokeObjectURL(image.url)), []);
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>;
const addImages = (files: FileList | null) => { if (!files) return; setImages((current) => [...current, ...Array.from(files).slice(0, 30 - current.length).map((file) => ({ file, url: URL.createObjectURL(file) }))]); };
const removeImage = (index: number) => { URL.revokeObjectURL(images[index].url); setImages((current) => current.filter((_, itemIndex) => itemIndex !== index)); };
const dropImage = (target: number) => { if (dragIndex === null || dragIndex === target) { setDragIndex(null); return; } setImages((current) => { const next = [...current]; const [moved] = next.splice(dragIndex, 1); next.splice(target, 0, moved); return next; }); setDragIndex(null); };
const nextRound = (work?.rounds[0]?.round_number ?? 0) + 1;
const valid = Boolean(title.trim() && images.length > 0 && !busy);
const submit = async () => { if (!valid) return; setBusy(true); setError(''); try { await api.createRound(id, { title: title.trim(), description, tags: tags.trim() ? [tags.trim()] : [], images: images.map((image) => image.file) }); navigate(`/works/${id}`); } catch (reason) { setError(reason instanceof Error ? reason.message : '验收轮次提交失败'); setBusy(false); } };
if (!work) return <main className="grid min-h-[60vh] place-items-center px-5 text-sm text-black/45">{error || '正在加载作品…'}</main>;
if (work.project.status !== 'active') return <main className="grid min-h-[60vh] place-items-center px-5 text-center text-sm text-black/45"><div><p></p><Link to={`/works/${id}`} className="mt-4 inline-flex items-center gap-2 text-black"><ArrowLeft size={14}/></Link></div></main>;
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><header className="mt-8 border-b border-black/10 pb-8"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Review round {nextRound}</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] md:text-6xl"> {nextRound} </h1><p className="mt-4 max-w-xl text-sm leading-7 text-black/45"></p></header><div className="mt-8 grid gap-8 lg:grid-cols-[.8fr_1.2fr]"><section className="space-y-5"><Field label="作品标题"><input value={title} onChange={(event) => setTitle(event.target.value)}/></Field><Field label="正文"><textarea rows={7} value={description} onChange={(event) => setDescription(event.target.value)}/></Field><Field label="Tag"><input value={tags} onChange={(event) => setTags(event.target.value)}/></Field></section><section><label className="grid min-h-44 cursor-pointer place-items-center rounded-[26px] border border-dashed border-black/20 bg-white 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"> 30 </small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{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)); }} className="group"><div className="relative cursor-grab overflow-hidden rounded-2xl bg-[#eeece6]"><img src={image.url} alt="" className="aspect-[4/5] w-full 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 text-[9px] text-white">{index + 1}</span><GripVertical className="absolute bottom-2 right-2 text-white" size={16}/></div><button onClick={() => removeImage(index)} className="mt-2 w-full text-center text-[10px] text-black/35 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>}<button disabled={!valid} onClick={() => void submit()} className="mt-7 w-full rounded-full bg-black py-4 text-sm text-white disabled:opacity-30">{busy ? '正在提交…' : `提交第 ${nextRound}`}</button></section></div></main>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <label className="block text-xs text-black/50">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3.5 [&_textarea]:w-full [&_textarea]:rounded-xl [&_textarea]:border [&_textarea]:border-black/10 [&_textarea]:bg-white [&_textarea]:p-3.5">{children}</div></label>; }

View File

@@ -1,67 +1,51 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, History, MessageSquareText, RotateCcw, Send, UploadCloud } from 'lucide-react';
import { ArrowLeft, RotateCcw, UploadCloud } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import AnnotatableText, { type TextSelectionDraft } from '@/components/AnnotatableText';
import CollaborationDrawer, { type DrawerTextDraft } from '@/components/CollaborationDrawer';
import ImageReviewModal from '@/components/ImageReviewModal';
import StatusBadge from '@/components/StatusBadge';
import CandidateStatusBadge from '@/components/CandidateStatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function NoteDetailPage() {
const id = Number(useParams().noteId);
const [search] = useSearchParams();
const selected = search.get('version');
const version = selected ? Number(selected) : undefined;
const requestedRound = search.get('round') ? Number(search.get('round')) : undefined;
const user = useAuthStore((state) => state.user);
const [work, setWork] = useState<NoteDetail | null>(null);
const [comment, setComment] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const result = await api.getNote(id, version);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}, [id, version]);
const [selectedImage, setSelectedImage] = useState<{ imageId: number } | null>(null);
const [focusImageId, setFocusImageId] = useState<number>();
const [textDraft, setTextDraft] = useState<(DrawerTextDraft & { target: 'title' | 'description' | 'tags' }) | null>(null);
const [focusFeedback, setFocusFeedback] = useState<{ type: 'text_annotation' | 'image_annotation'; id: number } | null>(null);
const load = useCallback(async () => setWork(await api.getWork(id, requestedRound)), [id, requestedRound]);
useEffect(() => { void load(); }, [load]);
if (!work) return <div className="p-20 text-center text-black/35"></div>;
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');
if (!work) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35"></main>;
const viewed = work.rounds.find((round) => round.version_number === work.version_number);
const current = Boolean(viewed && Number(viewed.review_round_id) === Number(work.active_round_id));
const readOnly = work.project.status !== 'active' || work.project.review_status === 'completed' || !current || viewed?.round_status !== 'reviewing';
const canReopen = current && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
const roundNumber = viewed?.round_number ?? 1;
const tagsText = work.tags.join(' ');
const selectText = (target: 'title' | 'description' | 'tags', label: string, selection: TextSelectionDraft) => { setTextDraft({ target, label, ...selection }); setFocusImageId(undefined); setFocusFeedback(null); setDrawerOpen(true); };
const openTextAnnotation = (annotationId: number) => { setTextDraft(null); setFocusImageId(undefined); setFocusFeedback({ type: 'text_annotation', id: annotationId }); setDrawerOpen(true); };
const openImage = (imageId: number) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setTextDraft(null); setFocusFeedback(null); };
const openImageAnnotation = (imageId: number, annotationId: number) => { setFocusImageId(imageId); setTextDraft(null); setFocusFeedback({ type: 'image_annotation', id: annotationId }); setDrawerOpen(true); };
const reopen = async () => { if (!reason.trim()) return; setBusy(true); setMessage(''); try { await api.reopenWork(id, reason.trim()); setReason(''); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : '操作失败'); } finally { setBusy(false); } };
const drawerFooter = canReopen ? <div className="mt-3 rounded-2xl border border-black/10 bg-white p-3"><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} className="w-full resize-none text-xs outline-none" placeholder="填写重新打开验收的原因"/><button disabled={busy || !reason.trim()} onClick={() => void reopen()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-2.5 text-xs disabled:opacity-30"><RotateCcw size={13}/></button>{message && <p className="mt-2 text-xs text-red-600">{message}</p>}</div> : null;
const send = async () => {
if (!comment.trim()) return;
setBusy(true);
await api.addComment(id, { content: comment.trim(), author_name: user?.display_name || '工作台', author_role: 'operator' });
setComment(''); await load(); setBusy(false);
};
const reopen = async () => {
if (!reason.trim()) return;
setBusy(true); setMessage('');
try { await api.reopenWork(id, reason.trim()); setReason(''); await load(); }
catch (error) { setMessage(error instanceof Error ? error.message : '操作失败'); }
finally { setBusy(false); }
};
return <main>
<header className="border-b border-black/10 bg-white"><div className="mx-auto max-w-[1500px] px-5 py-5 lg:px-10"><div className="flex flex-wrap items-center justify-between gap-3">
<Link to={`/projects/${work.project.id}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{work.collection.name}</Link>
<div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.versions.map((item) => <Link key={item.version_number} to={`/works/${id}?version=${item.version_number}`} className={`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} 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>
<div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length === 0 && <div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/></div>}{work.comments.map((item) => <div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role === 'operator' ? 'ml-5 bg-black text-white' : 'mr-5 bg-white'}`}><div className="mb-2 flex items-center justify-between text-[10px] opacity-50"><span>{item.author_name}</span><span>{new Date(item.created_at).toLocaleString('zh-CN')}</span></div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}{work.review_events.length > 0 && <div className="mt-5 border-t border-black/10 pt-4"><div className="mb-3 flex items-center gap-2 text-xs text-black/45"><History size={13}/></div>{work.review_events.map((event) => <div key={event.id} className="mb-2 rounded-xl bg-white/60 p-3 text-[11px] leading-5 text-black/55">V{event.version_number} · {event.actor_name} · {event.to_status}{event.reason && <p className="mt-1 text-black/70">{event.reason}</p>}</div>)}</div>}</div>
<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e) => setComment(e.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="回复客户或记录处理结果…"/><button disabled={busy || !comment.trim()} onClick={() => void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{canReopen && <div className="mt-3 rounded-2xl border border-black/10 bg-white p-3"><textarea rows={2} value={reason} onChange={(e) => setReason(e.target.value)} className="w-full resize-none text-xs outline-none" placeholder="填写重新打开验收的原因"/><button disabled={busy || !reason.trim()} onClick={() => void reopen()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-2.5 text-xs disabled:opacity-30"><RotateCcw size={13}/></button></div>}{message && <p className="mt-2 text-xs text-red-600">{message}</p>}</div>
</div></aside>
</div>
return <main onClick={() => { if (drawerOpen) setDrawerOpen(false); }}><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}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{work.project.name}</Link><div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.rounds.map((round) => <Link key={round.round_number} to={`/works/${id}?round=${round.round_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${round.round_number === roundNumber ? 'bg-black text-white' : 'bg-black/5 text-black/55'}`}> {round.round_number} </Link>)}{current && work.project.status === 'active' && <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>
<article className="mx-auto max-w-[1280px] px-5 py-10 lg:px-10 lg:py-14"><p className="mb-5 flex flex-wrap items-center gap-2 text-xs text-black/50"><span>{work.project.name}</span><span className="text-black/20">/</span><b className="font-mono uppercase tracking-[.16em] text-[#ba623c]">Work {String(work.id).padStart(3, '0')}</b><span className="text-black/20">/</span><span> {roundNumber} </span></p>{!current && <div className="mb-6 rounded-2xl border border-black/10 bg-black/[.03] px-5 py-4 text-sm text-black/55"></div>}
<div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} readOnly onAdd={async () => undefined} onOpen={() => openImage(image.id)} onAnnotationOpen={(annotationId) => openImageAnnotation(image.id, annotationId)}/>)}</div>
<div className="mt-12 border-t border-black/10 pt-10"><AnnotatableText text={work.title} label="标题" variant="title" annotations={work.text_annotations.filter((item) => item.target === 'title')} readOnly={readOnly} onSelect={(selection) => selectText('title', '标题', selection)} onOpenAnnotation={openTextAnnotation}/>{work.description && <div className="mt-9"><AnnotatableText text={work.description} label="正文" annotations={work.text_annotations.filter((item) => item.target === 'description')} readOnly={readOnly} onSelect={(selection) => selectText('description', '正文', selection)} onOpenAnnotation={openTextAnnotation}/></div>}{tagsText && <div className="mt-8"><AnnotatableText text={tagsText} label="Tag" annotations={work.text_annotations.filter((item) => item.target === 'tags')} readOnly={readOnly} onSelect={(selection) => selectText('tags', 'Tag', selection)} onOpenAnnotation={openTextAnnotation}/></div>}</div>
</article><CollaborationDrawer work={work} open={drawerOpen} setOpen={setDrawerOpen} readOnly={readOnly} actorName={user?.display_name} actorRole="operator" focusImageId={focusImageId} textDraft={textDraft} focusFeedback={focusFeedback} onCancelTextAnnotation={() => setTextDraft(null)} onTextAnnotation={async (content) => { if (!textDraft) return; await api.addTextSelectionAnnotation(id, { round_number: roundNumber, target: textDraft.target, start_offset: textDraft.start, end_offset: textDraft.end, selected_text: textDraft.text, content }); setTextDraft(null); await load(); }} onComment={async (content) => { await api.addComment(id, { content, author_name: user?.display_name || '工作台', author_role: 'operator' }); await load(); }} onReply={async (type, feedbackId, content) => { await api.replyToFeedback(id, type, feedbackId, content); await load(); }} onWithdraw={async (type, feedbackId) => { await api.withdrawFeedback(id, type, feedbackId); await load(); }} footer={drawerFooter}/>
{selectedImage && <ImageReviewModal work={work} initialImageId={selectedImage.imageId} readOnly={readOnly} onClose={() => { setSelectedImage(null); setDrawerOpen(false); }} onReload={load} onImageChange={(imageId) => { setSelectedImage({ imageId }); setFocusImageId(imageId); setFocusFeedback(null); }} onOpenAnnotation={openImageAnnotation}/>}
</main>;
}

View File

@@ -1,19 +1,20 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
import type { Project, WorkCollection } from '@shared/types';
import { ArrowLeft, Copy, ImageIcon, KeyRound, MessageCircle, Pencil, Plus, Search, X } from 'lucide-react';
import type { Note, Project, ReviewStatus } from '@shared/types';
import { api } from '@/api/client';
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
import StatusBadge from '@/components/StatusBadge';
const projectStatus = { draft: '待提交', reviewing: '验收中', completed: '验收完毕', archived: '已归档' } as const;
export default function ProjectPage() {
const id = Number(useParams().projectId);
const [project, setProject] = useState<Project | null>(null);
const [collections, setCollections] = useState<WorkCollection[]>([]);
const [collectionOpen, setCollectionOpen] = useState(false);
const [works, setWorks] = useState<Note[]>([]);
const [q, setQ] = useState('');
const [status, setStatus] = useState<ReviewStatus | ''>('');
const [editOpen, setEditOpen] = useState(false);
const [accessOpen, setAccessOpen] = useState(false);
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [projectName, setProjectName] = useState('');
const [projectDesc, setProjectDesc] = useState('');
const [accessEnabled, setAccessEnabled] = useState(false);
@@ -22,44 +23,30 @@ export default function ProjectPage() {
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const [nextProject, nextCollections] = await Promise.all([api.getProject(id), api.listCollections(id)]);
setProject(nextProject);
setCollections(nextCollections);
const [nextProject, nextWorks] = await Promise.all([api.getProject(id), api.listProjectWorks(id)]);
setProject(nextProject); setWorks(nextWorks);
}, [id]);
useEffect(() => { void load(); }, [load]);
if (!project) return null;
const filtered = useMemo(() => works.filter((work) => (!q || work.title.toLowerCase().includes(q.toLowerCase())) && (!status || work.review_status === status)), [works, q, status]);
if (!project) return <main className="grid min-h-[60vh] place-items-center text-sm text-black/35"></main>;
const reviewUrl = `${window.location.origin}/review/${project.slug}`;
const createCollection = async () => {
await api.createCollection(id, { name, client_description: desc });
setCollectionOpen(false); setName(''); setDesc(''); await load();
};
const editProject = async () => {
setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc }));
setEditOpen(false);
};
const openAccess = () => {
setAccessEnabled(Boolean(project.customer_access_enabled));
setAccessPassword('');
setExpiresAt(project.access_expires_at?.slice(0, 16) || '');
setMessage(''); setAccessOpen(true);
};
const saveAccess = async () => {
try {
const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null });
setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword('');
} catch (reason) { setMessage(reason instanceof Error ? reason.message : '保存失败'); }
};
const openAccess = () => { setAccessEnabled(Boolean(project.customer_access_enabled)); setAccessPassword(''); setExpiresAt(project.access_expires_at?.slice(0, 16) || ''); setMessage(''); setAccessOpen(true); };
return <main>
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-20"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-10 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Client Project / {project.slug}</p><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="flex gap-8"><Metric n={project.collection_count} label="作品交付集"/><Metric n={project.work_count} label="作品"/></div><div className="mt-6 flex flex-wrap gap-2"><button onClick={()=>{setProjectName(project.name);setProjectDesc(project.client_description);setEditOpen(true)}} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><Pencil size={13}/></button><button onClick={openAccess} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><KeyRound size={13}/>访</button></div></div></div></div></section>
<section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="mb-6 flex items-center justify-between"><div><p className="font-mono text-[10px] tracking-[.25em] text-black/35">COLLECTIONS</p><h2 className="mt-1 font-display text-3xl"></h2></div><button onClick={()=>setCollectionOpen(true)} className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Plus size={16}/></button></div><div className="space-y-3">{collections.map((item,index)=><Link key={item.id} to={`/projects/${id}/collections/${item.id}`} className="group grid gap-5 rounded-2xl border border-black/10 bg-white p-5 transition hover:border-black/30 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#f1efe9] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><div className="flex flex-wrap items-center gap-3"><h3 className="font-display text-2xl">{item.name}</h3><CollectionStatusBadge status={item.status}/></div><p className="mt-1 text-sm text-black/45">{item.client_description || '暂无说明'}</p></div><div className="flex items-center gap-6"><div className="text-right text-xs text-black/45"><b className="block text-lg text-black">{item.approved_count}/{item.work_count}</b>{item.work_count ? '已通过' : '尚无待验收作品'}</div><ArrowRight className="text-black/25 transition group-hover:translate-x-1 group-hover:text-black"/></div></Link>)}</div></section>
{collectionOpen&&<Modal close={()=>setCollectionOpen(false)} icon={<CalendarRange/>} title="新建作品交付集"><input className="mt-7 w-full rounded-xl border border-black/10 p-3" placeholder="例如2026 年 8 月任务" value={name} onChange={(e)=>setName(e.target.value)}/><textarea className="mt-3 w-full rounded-xl border border-black/10 p-3" rows={3} placeholder="客户可见的作品交付集说明" value={desc} onChange={(e)=>setDesc(e.target.value)}/><button disabled={!name} onClick={()=>void createCollection()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editOpen&&<Modal close={()=>setEditOpen(false)} icon={<Pencil/>} title="编辑项目"><label className="mt-7 block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(e)=>setProjectName(e.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(e)=>setProjectDesc(e.target.value)}/></label><p className="mt-3 text-[11px] text-black/35"> {project.slug} </p><button disabled={!projectName.trim()} onClick={()=>void editProject()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{accessOpen&&<Modal close={()=>setAccessOpen(false)} icon={<KeyRound/>} title="客户访问"><div className="mt-7 rounded-2xl border border-black/10 bg-white p-4"><p className="break-all text-xs leading-5 text-black/50">{reviewUrl}</p><button onClick={()=>void navigator.clipboard.writeText(reviewUrl).then(()=>setMessage('链接已复制'))} className="mt-3 inline-flex items-center gap-2 text-xs text-[#aa4f2e]"><Copy size={13}/></button></div><label className="mt-5 flex items-center justify-between rounded-xl border border-black/10 bg-white p-4 text-sm">访<input type="checkbox" checked={accessEnabled} onChange={(e)=>setAccessEnabled(e.target.checked)} className="h-4 w-4 accent-black"/></label><label className="mt-4 block text-xs text-black/45">{project.has_access_password?'重置访问密码(不修改可留空)':'设置访问密码'}<input type="password" value={accessPassword} onChange={(e)=>setAccessPassword(e.target.value)} placeholder="至少 6 位" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" value={expiresAt} onChange={(e)=>setExpiresAt(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label>{message&&<p className="mt-3 text-xs text-black/50">{message}</p>}<button onClick={()=>void saveAccess()} className="mt-5 w-full rounded-full bg-black py-3 text-white">访</button></Modal>}
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-16"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-9 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><div className="flex flex-wrap items-center gap-3"><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Project / {project.slug}</p><span className="rounded-full border border-white/15 px-3 py-1 text-[10px] text-white/60">{projectStatus[project.review_status]}</span></div><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="grid grid-cols-4 gap-5"><Metric n={project.work_count} label="全部"/><Metric n={project.pending_count} label="待验收"/><Metric n={project.changes_requested_count} label="需修改"/><Metric n={project.approved_count} label="已通过"/></div><div className="mt-6 flex flex-wrap justify-end gap-2"><button onClick={() => { setProjectName(project.name); setProjectDesc(project.client_description); setEditOpen(true); }} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><Pencil className="mr-2 inline" size={13}/></button><button onClick={openAccess} className="rounded-full border border-white/20 px-4 py-2 text-xs text-white/70"><KeyRound className="mr-2 inline" size={13}/>访</button></div></div></div></div></section>
<section className="mx-auto max-w-[1500px] px-5 pb-16 lg:px-10"><div className="sticky top-[72px] z-30 -mx-5 flex flex-col gap-3 border-b border-black/10 bg-[#f7f6f2]/92 px-5 py-5 backdrop-blur-xl md:flex-row md:items-center md:justify-between lg:-mx-10 lg:px-10"><div className="flex gap-2 overflow-auto">{([['','全部作品'],['pending','待验收'],['changes_requested','需修改'],['approved','已通过']] as [ReviewStatus|'',string][]).map(([value,label]) => <button key={label} onClick={() => setStatus(value)} className={`whitespace-nowrap rounded-full px-4 py-2 text-xs ${status === value ? 'bg-black text-white' : 'border border-black/10 bg-white text-black/55'}`}>{label}</button>)}</div><div className="flex gap-2"><label className="relative"><Search className="absolute left-3 top-2.5 text-black/35" size={15}/><input value={q} onChange={(event) => setQ(event.target.value)} className="w-full rounded-full border border-black/10 bg-white py-2 pl-9 pr-4 text-sm md:w-64" placeholder="搜索作品标题"/></label>{project.status === 'active' && <Link to={`/projects/${id}/upload`} className="inline-flex items-center gap-2 whitespace-nowrap rounded-full bg-[#ef4b2f] px-5 py-2 text-xs text-white"><Plus size={14}/></Link>}</div></div>
{project.review_status === 'completed' && <div className="mt-7 rounded-2xl border border-emerald-200 bg-emerald-50 px-5 py-4 text-sm text-emerald-800"></div>}
<div className="mt-8 grid grid-cols-2 gap-x-4 gap-y-9 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">{filtered.map((work) => <Link to={`/works/${work.id}`} key={work.id} className="group"><article><div className="relative aspect-[4/5] overflow-hidden rounded-[22px] bg-[#ebe9e3]">{work.cover_image ? <img src={work.cover_image} alt={work.title} className="h-full w-full object-cover transition duration-700 group-hover:scale-105"/> : <div className="grid h-full place-items-center text-black/20"><ImageIcon/></div>}<div className="absolute left-2 top-2"><StatusBadge status={work.review_status}/></div>{work.image_count > 1 && <span className="absolute right-2 top-2 rounded-full bg-black/65 px-2 py-1 text-[10px] text-white">{work.image_count} </span>}</div><h2 className="mt-3 line-clamp-2 font-display text-[22px] leading-6">{work.title}</h2><div className="mt-2 flex items-center gap-3 text-[11px] text-black/40"><span className="flex items-center gap-1"><MessageCircle size={12}/>{work.annotation_count + work.comment_count}</span><span> {work.version_number} </span></div></article></Link>)}</div>
{!filtered.length && <div className="py-24 text-center text-sm text-black/35">{works.length ? '没有符合筛选条件的作品' : '项目还没有作品,上传第一件作品开始验收。'}</div>}
</section>
{editOpen && <Modal title="编辑项目" close={() => setEditOpen(false)}><label className="block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(event) => setProjectName(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(event) => setProjectDesc(event.target.value)}/></label><button onClick={async () => { setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc })); setEditOpen(false); }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button></Modal>}
{accessOpen && <Modal title="客户访问" close={() => setAccessOpen(false)}><div className="rounded-xl bg-black/[.04] p-3 text-xs break-all">{reviewUrl}</div><button onClick={() => void navigator.clipboard.writeText(reviewUrl)} className="mt-2 inline-flex items-center gap-2 text-xs text-black/50"><Copy size={12}/></button><label className="mt-5 flex items-center gap-3 text-sm"><input type="checkbox" checked={accessEnabled} onChange={(event) => setAccessEnabled(event.target.checked)}/>访</label><label className="mt-4 block text-xs text-black/45">访<input type="password" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={accessPassword} onChange={(event) => setAccessPassword(event.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={expiresAt} onChange={(event) => setExpiresAt(event.target.value)}/></label><button onClick={async () => { try { const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null }); setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword(''); } catch (error) { setMessage(error instanceof Error ? error.message : '保存失败'); } }} className="mt-5 w-full rounded-full bg-black py-3 text-sm text-white"></button>{message && <p className="mt-3 text-center text-xs text-black/50">{message}</p>}</Modal>}
</main>;
}
function Metric({n,label}:{n:number;label:string}) { return <div><strong className="font-display text-4xl">{String(n).padStart(2,'0')}</strong><span className="ml-2 text-xs text-white/40">{label}</span></div> }
function Modal({close,icon,title,children}:{close:()=>void;icon:React.ReactNode;title:string;children:React.ReactNode}) { return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4"><div className="w-full max-w-md rounded-3xl bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X/></button></div>{children}</div></div> }
function Metric({ n, label }: { n: number; label: string }) { return <div className="text-right"><b className="font-display text-3xl">{n}</b><span className="mt-1 block text-[10px] text-white/40">{label}</span></div>; }
function Modal({ title, close, children }: { title: string; close: () => void; children: React.ReactNode }) { return <div className="fixed inset-0 z-[90] grid place-items-center bg-black/50 p-4"><div className="w-full max-w-md rounded-[28px] bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><h3 className="font-display text-3xl">{title}</h3><button onClick={close}><X/></button></div><div className="mt-6">{children}</div></div></div>; }

View File

@@ -5,13 +5,13 @@ import { api } from '@/api/client';
type Item={file:File;url:string};
export default function UploadPage(){
const {projectId,collectionId}=useParams(); const pid=Number(projectId),cid=Number(collectionId); const nav=useNavigate(); const input=useRef<HTMLInputElement>(null);const draggedUrl=useRef<string|null>(null); const [title,setTitle]=useState('');const [content,setContent]=useState('');const [tags,setTags]=useState('');const [items,setItems]=useState<Item[]>([]);const [dragging,setDragging]=useState<string|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const {projectId}=useParams(); const pid=Number(projectId); const nav=useNavigate(); const input=useRef<HTMLInputElement>(null);const draggedUrl=useRef<string|null>(null); const [title,setTitle]=useState('');const [content,setContent]=useState('');const [tags,setTags]=useState('');const [items,setItems]=useState<Item[]>([]);const [dragging,setDragging]=useState<string|null>(null);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const can=useMemo(()=>title.trim()&&items.length>0&&!busy,[title,items,busy]); const add=(files:FileList|null)=>{if(!files)return;setItems(p=>[...p,...Array.from(files).filter(f=>f.type.startsWith('image/')).slice(0,30-p.length).map(file=>({file,url:URL.createObjectURL(file)}))])};
const startReorder=(event:React.PointerEvent<HTMLDivElement>,url:string)=>{event.preventDefault();event.currentTarget.setPointerCapture(event.pointerId);draggedUrl.current=url;setDragging(url)};
const moveReorder=(event:React.PointerEvent<HTMLDivElement>)=>{const source=draggedUrl.current;if(!source)return;const target=document.elementFromPoint(event.clientX,event.clientY)?.closest<HTMLElement>('[data-image-key]')?.dataset.imageKey;if(!target||target===source)return;setItems(current=>{const from=current.findIndex(item=>item.url===source),to=current.findIndex(item=>item.url===target);if(from<0||to<0||from===to)return current;const next=[...current];const [moved]=next.splice(from,1);next.splice(to,0,moved);return next})};
const finishReorder=(event:React.PointerEvent<HTMLDivElement>)=>{if(event.currentTarget.hasPointerCapture(event.pointerId))event.currentTarget.releasePointerCapture(event.pointerId);draggedUrl.current=null;setDragging(null)};
const submit=async()=>{if(!can)return;setBusy(true);setError('');try{const work=await api.createNote({collectionId:cid,title:title.trim(),description:content.trim(),tags:tags?[tags]:[],images:items.map(x=>x.file)});items.forEach(x=>URL.revokeObjectURL(x.url));nav(`/works/${work.id}`)}catch(e){setError(e instanceof Error?e.message:'上传失败')}finally{setBusy(false)}};
return <main className="mx-auto max-w-6xl px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}/collections/${cid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.8fr_1.2fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ef4b2f]">New work</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><div className="mt-8 space-y-6"><Field label="作品标题 *"><input value={title} onChange={e=>setTitle(e.target.value)} placeholder="一句清晰的作品标题"/></Field><Field label="正文"><textarea rows={7} value={content} onChange={e=>setContent(e.target.value)} placeholder="输入作品正文,支持换行与 Emoji"/></Field><Field label="Tag"><input value={tags} onChange={e=>setTags(e.target.value)} placeholder="品牌, 七月内容, 待发布"/><p className="mt-2 text-[11px] text-black/35">使</p></Field></div></section>
const submit=async()=>{if(!can)return;setBusy(true);setError('');try{const work=await api.createWork(pid,{title:title.trim(),description:content.trim(),tags:tags?[tags]:[],images:items.map(x=>x.file)});items.forEach(x=>URL.revokeObjectURL(x.url));nav(`/works/${work.id}`)}catch(e){setError(e instanceof Error?e.message:'上传失败')}finally{setBusy(false)}};
return <main className="mx-auto max-w-6xl px-5 py-8 lg:px-10 lg:py-12"><Link to={`/projects/${pid}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.8fr_1.2fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ef4b2f]">New work</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><div className="mt-8 space-y-6"><Field label="作品标题 *"><input value={title} onChange={e=>setTitle(e.target.value)} placeholder="一句清晰的作品标题"/></Field><Field label="正文"><textarea rows={7} value={content} onChange={e=>setContent(e.target.value)} placeholder="输入作品正文,支持换行与 Emoji"/></Field><Field label="Tag"><input value={tags} onChange={e=>setTags(e.target.value)} placeholder="品牌, 七月内容, 待发布"/><p className="mt-2 text-[11px] text-black/35">使</p></Field></div></section>
<section><div onClick={()=>input.current?.click()} onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();add(e.dataTransfer.files)}} className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white p-8 text-center hover:border-black"><div><ImagePlus className="mx-auto"/><h2 className="mt-4 font-display text-2xl"></h2><p className="mt-2 text-xs text-black/40"> 30 · </p></div><input ref={input} type="file" accept="image/*" multiple className="hidden" onChange={e=>add(e.target.files)}/></div>
{items.length>0&&<div className="mt-5 grid grid-cols-3 gap-x-3 gap-y-5 sm:grid-cols-4">{items.map((it,i)=><div key={it.url} data-image-key={it.url}><div role="listitem" aria-label={`${i===0?'封面':`${i+1}`},拖动调整顺序`} onPointerDown={event=>startReorder(event,it.url)} onPointerMove={moveReorder} onPointerUp={finishReorder} onPointerCancel={finishReorder} className={`aspect-square touch-none select-none overflow-hidden rounded-xl bg-black/5 transition duration-200 ${dragging===it.url?'scale-[.97] cursor-grabbing opacity-70 ring-2 ring-[#ef4b2f]':'cursor-grab hover:scale-[.99]'}`}><img src={it.url} draggable={false} className="pointer-events-none h-full w-full object-cover"/></div><div className="mt-2 flex items-center justify-between gap-2 px-0.5"><span className="text-[10px] text-black/35">{i===0?'封面':`${i+1}`}</span><button type="button" onClick={()=>setItems(p=>p.filter((_,j)=>j!==i))} className="inline-flex items-center gap-1 text-[10px] text-black/35 transition hover:text-red-600"><X size={11}/></button></div></div>)}</div>}
{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-sm text-red-600">{error}</p>}<button onClick={submit} disabled={!can} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#ef4b2f] py-4 text-sm font-medium text-white disabled:opacity-30">{busy?<><Loader2 className="animate-spin" size={17}/></>:'创建并提交验收'}</button></section></div></main>

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""通过 Delivery Desk API 创建作品或上传作品新版本"""
"""通过 Delivery Desk API 创建作品或提交新的验收轮次"""
from __future__ import annotations
@@ -37,11 +37,10 @@ def request_json(opener, url: str, *, method: str = "GET", data: bytes | None =
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="通过 API 创建测试作品或上传新版本")
parser = argparse.ArgumentParser(description="通过 API 创建测试作品或提交新的验收轮次")
parser.add_argument("--base-url", default=os.getenv("DELIVERY_DESK_BASE_URL", "http://127.0.0.1:3010"))
parser.add_argument("--project-id", type=int, default=None, help="不传时自动选择唯一可访问的项目")
parser.add_argument("--collection-id", type=int, default=None, help="不传时自动选择项目下唯一的作品交付集")
parser.add_argument("--work-id", type=int, default=None, help="传入后为该作品创建新版本")
parser.add_argument("--work-id", type=int, default=None, help="传入后为该作品提交新的验收轮次")
parser.add_argument("--external-id", default=None, help="调用方作品唯一标识,用于幂等创建和找回作品")
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
parser.add_argument("--username", default=os.getenv("DELIVERY_DESK_USERNAME", "operator"))
@@ -99,16 +98,6 @@ def main() -> int:
_, projects, _ = request_json(opener, f"{base_url}/api/projects", headers=auth_headers)
project = choose_item(projects, args.project_id, "项目")
project_id = int(project["id"])
_, collections, _ = request_json(
opener,
f"{base_url}/api/projects/{project_id}/collections",
headers=auth_headers,
)
collection = choose_item(collections, args.collection_id, "作品交付集")
collection_id = int(collection["id"])
if int(collection["project_id"]) != project_id:
raise ApiError(f"作品交付集 {collection_id} 不属于项目 {project_id}")
title = args.title or f"API 测试作品 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
image_urls = args.image_urls or ["https://placehold.co/1200x800/png?text=Delivery+Desk+API+Test"]
upload_headers = {**auth_headers, "Content-Type": "application/json"}
@@ -120,28 +109,29 @@ def main() -> int:
}
if args.work_id is not None:
_, current, _ = request_json(opener, f"{base_url}/api/notes/{args.work_id}", headers=auth_headers)
if int(current["project"]["id"]) != project_id or int(current["collection"]["id"]) != collection_id:
raise ApiError(f"作品 {args.work_id} 不属于选定的项目和作品交付集")
_, current, _ = request_json(opener, f"{base_url}/api/works/{args.work_id}", headers=auth_headers)
if int(current["project"]["id"]) != project_id:
raise ApiError(f"作品 {args.work_id} 不属于选定的项目")
status, work, _ = request_json(
opener,
f"{base_url}/api/notes/{args.work_id}/versions",
f"{base_url}/api/works/{args.work_id}/rounds",
method="POST",
data=json.dumps(payload).encode("utf-8"),
headers=upload_headers,
)
if status != 201 or not work or int(work.get("id", 0)) != args.work_id:
raise ApiError("版本接口没有返回目标作品")
action = "version_created"
raise ApiError("验收轮次接口没有返回目标作品")
action = "round_created"
external_id = work.get("external_id")
else:
external_id = args.external_id or f"api-smoke-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
create_payload = {**payload, "collectionId": collection_id, "externalId": external_id}
create_payload = {**payload, "externalId": external_id}
body = json.dumps(create_payload).encode("utf-8")
status, work, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
if status not in (200, 201) or not work or int(work.get("collection_id", 0)) != collection_id:
raise ApiError("接口未返回属于目标作品交付集的作品")
_, repeated, _ = request_json(opener, f"{base_url}/api/notes", method="POST", data=body, headers=upload_headers)
works_url = f"{base_url}/api/projects/{project_id}/works"
status, work, _ = request_json(opener, works_url, method="POST", data=body, headers=upload_headers)
if status not in (200, 201) or not work or int(work.get("project_id", 0)) != project_id:
raise ApiError("接口未返回属于目标项目的作品")
_, repeated, _ = request_json(opener, works_url, method="POST", data=body, headers=upload_headers)
if int(repeated.get("id", 0)) != int(work["id"]) or not repeated.get("idempotent"):
raise ApiError("相同 externalId 的重复请求未通过幂等校验")
action = "work_created" if status == 201 else "existing_work_returned"
@@ -153,7 +143,6 @@ def main() -> int:
"action": action,
"group": project.get("group_name"),
"project": project.get("name"),
"collection": collection.get("name"),
"work_id": work.get("id"),
"external_id": external_id,
"version_number": work.get("version_number"),

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""获取指定作品验收轮次的全部批注与反馈。"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
from http.cookiejar import CookieJar
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import HTTPCookieProcessor, Request, build_opener
class ApiError(RuntimeError):
"""Delivery Desk API 请求失败。"""
def request_json(opener, url: str, *, method: str = "GET", data: bytes | None = None, headers: dict[str, str] | None = None):
request = Request(url, data=data, method=method, headers=headers or {})
try:
with opener.open(request, timeout=15) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body) if body else None
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
try:
parsed = json.loads(body)
detail = parsed.get("error", body) if isinstance(parsed, dict) else body
except json.JSONDecodeError:
detail = body
raise ApiError(f"{method} {url} 返回 {error.code}: {detail}") from error
except URLError as error:
raise ApiError(f"无法连接 {url}: {error.reason}") from error
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="获取作品指定验收轮次的全部批注")
parser.add_argument("--base-url", default=os.getenv("DELIVERY_DESK_BASE_URL", "http://127.0.0.1:3010"))
parser.add_argument("--project-name", default="光影内容计划", help="用于校验作品所属项目")
parser.add_argument("--work-id", type=int, default=13)
parser.add_argument("--round", type=int, default=2, dest="round_number")
parser.add_argument("--include-history", action="store_true", help="同时返回已关闭或已撤回的历史反馈")
parser.add_argument("--api-key", default=os.getenv("DELIVERY_DESK_API_KEY"))
parser.add_argument("--username", default=os.getenv("DELIVERY_DESK_USERNAME", "operator"))
parser.add_argument("--password", default=os.getenv("DELIVERY_DESK_PASSWORD"))
return parser.parse_args()
def authenticate(opener, base_url: str, args: argparse.Namespace) -> dict[str, str]:
if args.api_key:
return {"Authorization": f"Bearer {args.api_key}"}
password = args.password or getpass.getpass(f"请输入账号 {args.username} 的密码: ")
payload = json.dumps({"username": args.username, "password": password}).encode("utf-8")
status, _ = request_json(
opener,
f"{base_url}/api/auth/login",
method="POST",
data=payload,
headers={"Content-Type": "application/json"},
)
if status != 200:
raise ApiError("登录接口未返回成功状态")
return {}
def main() -> int:
args = parse_args()
if args.work_id < 1 or args.round_number < 1:
raise ApiError("作品 ID 和轮次必须是正整数")
base_url = args.base_url.rstrip("/")
opener = build_opener(HTTPCookieProcessor(CookieJar()))
auth_headers = authenticate(opener, base_url, args)
query = urlencode({"round": args.round_number, "include_history": str(args.include_history).lower()})
_, context = request_json(
opener,
f"{base_url}/api/works/{args.work_id}/optimization-context?{query}",
headers=auth_headers,
)
if not isinstance(context, dict):
raise ApiError("内容优化接口返回格式无效")
project = context.get("project")
actual_project_name = project.get("name") if isinstance(project, dict) else None
if actual_project_name != args.project_name:
raise ApiError(f"Work {args.work_id:03d} 属于项目“{actual_project_name}”,不是“{args.project_name}")
print(json.dumps(context, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (ApiError, ValueError) as error:
print(f"测试失败: {error}", file=sys.stderr)
raise SystemExit(1)