feat(collections): 自动同步作品交付集验收状态
This commit is contained in:
@@ -25,7 +25,11 @@ if (databaseUrl) {
|
|||||||
pool = new pg.Pool({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined, max: Number(process.env.PG_POOL_MAX || 10) });
|
pool = new pg.Pool({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined, max: Number(process.env.PG_POOL_MAX || 10) });
|
||||||
}
|
}
|
||||||
let schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8');
|
let schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8');
|
||||||
if (databaseUrl === 'pg-mem://') schema = schema.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
|
if (databaseUrl === 'pg-mem://') {
|
||||||
|
schema = schema
|
||||||
|
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
|
||||||
|
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '');
|
||||||
|
}
|
||||||
await pool.query(schema);
|
await pool.query(schema);
|
||||||
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
|
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
|
||||||
if (userCount === 0) {
|
if (userCount === 0) {
|
||||||
|
|||||||
19
api/db.ts
19
api/db.ts
@@ -109,7 +109,8 @@ db.exec(`
|
|||||||
project_id INTEGER NOT NULL,
|
project_id INTEGER NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
client_description TEXT NOT NULL DEFAULT '',
|
client_description TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'reviewing',
|
status TEXT NOT NULL DEFAULT 'draft',
|
||||||
|
completed_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -211,6 +212,7 @@ addColumn('images', "storage_provider TEXT NOT NULL DEFAULT 'local'");
|
|||||||
addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
|
addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
|
||||||
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
|
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
|
||||||
addColumn('users', 'last_login_at TEXT');
|
addColumn('users', 'last_login_at TEXT');
|
||||||
|
addColumn('collections', 'completed_at TEXT');
|
||||||
|
|
||||||
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 != ''");
|
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 != ''");
|
||||||
|
|
||||||
@@ -254,6 +256,21 @@ db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_descrip
|
|||||||
WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run();
|
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)
|
db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status)
|
||||||
SELECT id, version_number, title, description, tags, review_status FROM notes`).run();
|
SELECT id, version_number, title, description, tags, review_status FROM notes`).run();
|
||||||
|
db.exec(`
|
||||||
|
UPDATE collections
|
||||||
|
SET status = CASE
|
||||||
|
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft') THEN 'draft'
|
||||||
|
WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft' AND n.review_status != 'approved') THEN 'completed'
|
||||||
|
ELSE 'reviewing'
|
||||||
|
END,
|
||||||
|
completed_at = CASE
|
||||||
|
WHEN EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft' AND n.review_status != 'approved')
|
||||||
|
THEN COALESCE(completed_at, datetime('now'))
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
WHERE status != 'archived';
|
||||||
|
`);
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
|
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Router, type Response, type NextFunction } from 'express';
|
import { Router, type Response, type NextFunction } from 'express';
|
||||||
import { upload } from '../upload.js';
|
import { upload } from '../upload.js';
|
||||||
import { notesService } from '../services/notesService.js';
|
import { notesService } from '../services/notesService.js';
|
||||||
|
import { recalculateCollectionStatus } from '../services/collectionsService.js';
|
||||||
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
|
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
|
||||||
import { database, withTransaction } from '../database.js';
|
import { database, withTransaction } from '../database.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
@@ -181,8 +182,9 @@ router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Res
|
|||||||
res.status(400).json({ error: '无效的验收状态' });
|
res.status(400).json({ error: '无效的验收状态' });
|
||||||
return;
|
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; 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]);
|
||||||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||||
|
if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; }
|
||||||
if (!await notesService.setStatus(id, status)) {
|
if (!await notesService.setStatus(id, status)) {
|
||||||
res.status(404).json({ error: '作品不存在' });
|
res.status(404).json({ error: '作品不存在' });
|
||||||
return;
|
return;
|
||||||
@@ -194,7 +196,7 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
|
|||||||
const id = Number(req.params.noteId);
|
const id = Number(req.params.noteId);
|
||||||
const reason = String(req.body?.reason ?? '').trim();
|
const reason = String(req.body?.reason ?? '').trim();
|
||||||
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
|
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
|
||||||
const note = await database.one<{ review_status: string; version_number: number; project_id: number }>('SELECT n.review_status, n.version_number, 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 }>('SELECT n.review_status, n.version_number, n.collection_id, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||||||
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
|
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||||
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||||
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
|
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
|
||||||
@@ -203,6 +205,7 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
|
|||||||
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
|
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
|
||||||
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
|
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
|
||||||
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]);
|
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 audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
|
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
|
||||||
res.json({ success: true, status: 'pending' });
|
res.json({ success: true, status: 'pending' });
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
|
|||||||
|
|
||||||
router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{
|
router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{
|
||||||
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
|
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
|
||||||
const rows=await database.all<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
|
const rows=await database.all<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status!='draft' GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson));
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
|
router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{
|
||||||
@@ -83,12 +83,12 @@ router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,r
|
|||||||
|
|
||||||
router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{
|
router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{
|
||||||
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
|
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
|
||||||
try{const id=await database.insertId('INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)',[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
|
try{const id=await database.insertId("INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, ?, 'draft')",[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one<CollectionRow>('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{
|
router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{
|
||||||
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
|
const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return}
|
||||||
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
|
try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one<CollectionRow>(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status!='draft' GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createCustomerSession, customerSessionCookie, optionalCustomer, require
|
|||||||
import { verifyPassword } from '../auth.js';
|
import { verifyPassword } from '../auth.js';
|
||||||
import { notesService } from '../services/notesService.js';
|
import { notesService } from '../services/notesService.js';
|
||||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||||
|
import { recalculateCollectionStatus } from '../services/collectionsService.js';
|
||||||
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
|
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
|
||||||
|
|
||||||
const router=Router();router.use(optionalCustomer);
|
const router=Router();router.use(optionalCustomer);
|
||||||
@@ -15,18 +16,18 @@ router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const proje
|
|||||||
|
|
||||||
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:'请填写 2–30 个字符的姓名'});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.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:'请填写 2–30 个字符的姓名'});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/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.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.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/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/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 * FROM collections WHERE id=? AND project_id=? AND status IN ('reviewing','completed')",[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,works})});
|
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.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/works/:noteId',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const belongs=await database.one<{review_status:string}>('SELECT n.review_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=?',[noteId,project.id]);if(!belongs||belongs.review_status==='draft'){res.status(404).json({error:'作品不存在或尚未提交'});return}const version=req.query.version?Number(req.query.version):undefined;const detail=await notesService.getDetail(noteId,version);if(!detail){res.status(404).json({error:'作品版本不存在'});return}res.json(detail)});
|
||||||
|
|
||||||
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one('SELECT n.id FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 1–2000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
|
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string}>('SELECT c.status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(belongs.status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 1–2000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
|
||||||
|
|
||||||
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}const belongs=await database.one('SELECT v.id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
|
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}const belongs=await database.one<{status:string}>('SELECT c.status FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}if(belongs.status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
|
||||||
|
|
||||||
router.post('/:slug/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(`SELECT i.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=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).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:'批注内容须为 1–1000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
|
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string}>(`SELECT c.status FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(belongs.status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
|
||||||
|
|
||||||
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string}>(`SELECT n.version_number,n.review_status FROM notes n WHERE n.id=? AND n.review_status!='draft' AND n.collection_id IN (SELECT id FROM collections WHERE project_id=?)`,[noteId,project.id]);if(!current){res.status(404).json({error:'作品不存在或尚未提交'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE notes SET review_status=? WHERE id=?',[decision,noteId]);await tx.execute('UPDATE work_versions SET review_status=? WHERE note_id=? AND version_number=?',[decision,noteId,current.version_number]);await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)',[noteId,current.version_number,decision,current.review_status,decision,reason,req.customer!.reviewer_name,'client']);if(reason)await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,reason,req.customer!.reviewer_name])});res.json({success:true,status:decision})});
|
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string;collection_id:number;collection_status:string}>(`SELECT n.version_number,n.review_status,n.collection_id,c.status AS collection_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND n.review_status!='draft' AND c.project_id=?`,[noteId,project.id]);if(!current){res.status(404).json({error:'作品不存在或尚未提交'});return}if(current.collection_status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE notes SET review_status=? WHERE id=?',[decision,noteId]);await tx.execute('UPDATE work_versions SET review_status=? WHERE note_id=? AND version_number=?',[decision,noteId,current.version_number]);await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)',[noteId,current.version_number,decision,current.review_status,decision,reason,req.customer!.reviewer_name,'client']);if(reason)await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,reason,req.customer!.reviewer_name]);await recalculateCollectionStatus(Number(current.collection_id),tx)});res.json({success:true,status:decision})});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
46
api/services/collectionsService.ts
Normal file
46
api/services/collectionsService.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { CollectionStatus } from '../../shared/types.js';
|
||||||
|
import { database, databaseDialect, type QueryContext } from '../database.js';
|
||||||
|
|
||||||
|
export interface CollectionStatusResult {
|
||||||
|
status: CollectionStatus;
|
||||||
|
workCount: number;
|
||||||
|
approvedCount: number;
|
||||||
|
completedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveCollectionStatus(workCount: number, approvedCount: number): Exclude<CollectionStatus, 'archived'> {
|
||||||
|
if (workCount === 0) return 'draft';
|
||||||
|
if (approvedCount === workCount) return 'completed';
|
||||||
|
return 'reviewing';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recalculateCollectionStatus(
|
||||||
|
collectionId: number,
|
||||||
|
tx: QueryContext = database,
|
||||||
|
): Promise<CollectionStatusResult | null> {
|
||||||
|
const collection = await tx.one<{ status: CollectionStatus; completed_at: string | null }>(
|
||||||
|
`SELECT status, completed_at FROM collections WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`,
|
||||||
|
[collectionId],
|
||||||
|
);
|
||||||
|
if (!collection) 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 collection_id = ? AND review_status != 'draft'`,
|
||||||
|
[collectionId],
|
||||||
|
);
|
||||||
|
const workCount = Number(counts?.work_count ?? 0);
|
||||||
|
const approvedCount = Number(counts?.approved_count ?? 0);
|
||||||
|
|
||||||
|
if (collection.status === 'archived') {
|
||||||
|
return { status: 'archived', workCount, approvedCount, completedAt: collection.completed_at };
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = deriveCollectionStatus(workCount, approvedCount);
|
||||||
|
const completedAt = status === 'completed'
|
||||||
|
? collection.completed_at ?? new Date().toISOString()
|
||||||
|
: null;
|
||||||
|
await tx.execute('UPDATE collections SET status = ?, completed_at = ? WHERE id = ?', [status, completedAt, collectionId]);
|
||||||
|
return { status, workCount, approvedCount, completedAt };
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { imagesRepository } from '../repositories/imagesRepository.js';
|
|||||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||||
import { database, withTransaction } from '../database.js';
|
import { database, withTransaction } from '../database.js';
|
||||||
import { storeUploadedFile } from '../storage.js';
|
import { storeUploadedFile } from '../storage.js';
|
||||||
|
import { recalculateCollectionStatus } from './collectionsService.js';
|
||||||
|
|
||||||
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
|
export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string }
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export const notesService = {
|
|||||||
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
|
if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null;
|
||||||
const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current;
|
const 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 images = await imagesRepository.listByNote(id, note.version_number);
|
||||||
const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]);
|
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;
|
if (!workContext) return null;
|
||||||
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>('SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]);
|
const versionRows = await database.all<Array<Omit<NoteDetail['versions'][number], 'tags'> & { tags: string }>[number]>('SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]);
|
||||||
const result: NoteDetail = {
|
const result: NoteDetail = {
|
||||||
@@ -42,7 +43,7 @@ export const notesService = {
|
|||||||
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), tags: JSON.parse(item.tags || '[]') as string[] })),
|
versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), 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]),
|
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 },
|
project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug },
|
||||||
collection: { id: Number(workContext.collection_id), name: workContext.collection_name },
|
collection: { id: Number(workContext.collection_id), name: workContext.collection_name, status: workContext.collection_status },
|
||||||
};
|
};
|
||||||
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
|
for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) });
|
||||||
return result;
|
return result;
|
||||||
@@ -54,6 +55,7 @@ export const notesService = {
|
|||||||
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
||||||
await imagesRepository.createMany(id, prepared, 1, tx);
|
await imagesRepository.createMany(id, prepared, 1, tx);
|
||||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
||||||
|
await recalculateCollectionStatus(collectionId, tx);
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
return (await notesRepository.findById(noteId))!;
|
return (await notesRepository.findById(noteId))!;
|
||||||
@@ -64,6 +66,7 @@ export const notesService = {
|
|||||||
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']);
|
||||||
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
|
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx);
|
||||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]);
|
||||||
|
await recalculateCollectionStatus(collectionId, tx);
|
||||||
return id;
|
return id;
|
||||||
});
|
});
|
||||||
return (await notesRepository.findById(noteId))!;
|
return (await notesRepository.findById(noteId))!;
|
||||||
@@ -83,6 +86,7 @@ export const notesService = {
|
|||||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
||||||
await imagesRepository.createMany(id, prepared, nextVersion, tx);
|
await imagesRepository.createMany(id, prepared, nextVersion, tx);
|
||||||
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
|
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
|
||||||
|
await recalculateCollectionStatus(current.collection_id, tx);
|
||||||
});
|
});
|
||||||
return (await notesRepository.findById(id))!;
|
return (await notesRepository.findById(id))!;
|
||||||
},
|
},
|
||||||
@@ -96,10 +100,28 @@ export const notesService = {
|
|||||||
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]);
|
||||||
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, tx);
|
await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, tx);
|
||||||
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
|
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']);
|
||||||
|
await recalculateCollectionStatus(current.collection_id, tx);
|
||||||
});
|
});
|
||||||
return (await notesRepository.findById(id))!;
|
return (await notesRepository.findById(id))!;
|
||||||
},
|
},
|
||||||
|
|
||||||
async remove(id: number) { return notesRepository.remove(id); },
|
async remove(id: number) {
|
||||||
async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); },
|
return withTransaction(async (tx) => {
|
||||||
|
const note = await tx.one<{ collection_id: number }>('SELECT collection_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);
|
||||||
|
return removed;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
async setStatus(id: number, status: ReviewStatus) {
|
||||||
|
return withTransaction(async (tx) => {
|
||||||
|
const note = await tx.one<{ collection_id: number; version_number: number }>('SELECT collection_id, version_number FROM notes WHERE id = ?', [id]);
|
||||||
|
if (!note) return false;
|
||||||
|
await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]);
|
||||||
|
await tx.execute('UPDATE work_versions SET review_status = ? WHERE note_id = ? AND version_number = ?', [status, id, note.version_number]);
|
||||||
|
await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,11 +40,14 @@ CREATE TABLE IF NOT EXISTS collections (
|
|||||||
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
client_description TEXT NOT NULL DEFAULT '',
|
client_description TEXT NOT NULL DEFAULT '',
|
||||||
status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')),
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
UNIQUE (project_id, name)
|
UNIQUE (project_id, name)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||||
@@ -204,4 +207,24 @@ CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions
|
|||||||
CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number);
|
CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number);
|
||||||
CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id);
|
CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id);
|
||||||
|
|
||||||
|
-- COLLECTION_STATUS_REPAIR_START
|
||||||
|
UPDATE collections
|
||||||
|
SET status = 'draft', completed_at = NULL
|
||||||
|
WHERE status != 'archived'
|
||||||
|
AND id NOT IN (SELECT collection_id FROM notes WHERE review_status != 'draft');
|
||||||
|
|
||||||
|
UPDATE collections AS c
|
||||||
|
SET status = CASE WHEN s.approved_count = s.work_count THEN 'completed' ELSE 'reviewing' END,
|
||||||
|
completed_at = CASE WHEN s.approved_count = s.work_count THEN COALESCE(c.completed_at, NOW()) ELSE NULL END
|
||||||
|
FROM (
|
||||||
|
SELECT collection_id,
|
||||||
|
COUNT(*) AS work_count,
|
||||||
|
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
|
||||||
|
FROM notes
|
||||||
|
WHERE review_status != 'draft'
|
||||||
|
GROUP BY collection_id
|
||||||
|
) AS s
|
||||||
|
WHERE c.id = s.collection_id AND c.status != 'archived';
|
||||||
|
-- COLLECTION_STATUS_REPAIR_END
|
||||||
|
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
|||||||
@@ -79,3 +79,4 @@ flowchart LR
|
|||||||
|
|
||||||
作品状态为 `draft`、`pending`、`changes_requested`、`approved`。客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。
|
作品状态为 `draft`、`pending`、`changes_requested`、`approved`。客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。
|
||||||
|
|
||||||
|
作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed`。`archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新版本或重新打开作品会自动恢复为验收中。
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ curl -X POST http://localhost:3010/api/projects/1/collections \
|
|||||||
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
|
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
新建作品交付集的 `status` 为 `draft`。上传首件作品后自动变为 `reviewing`;全部非草稿作品通过后自动变为 `completed`。响应中的 `work_count`、`approved_count` 和 `completed_at` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新版本、修改验收状态和删除作品都会触发服务端重算。
|
||||||
|
|
||||||
## 上传作品
|
## 上传作品
|
||||||
|
|
||||||
外部客户端使用 JSON 创建作品,`images` 直接传入 1–30 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL,不会下载图片或再次上传到 COS。数组顺序就是展示顺序,第一张为封面。
|
外部客户端使用 JSON 创建作品,`images` 直接传入 1–30 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL,不会下载图片或再次上传到 COS。数组顺序就是展示顺序,第一张为封面。
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts",
|
"db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts",
|
||||||
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
|
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
|
||||||
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
|
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
|
||||||
|
"test:collection-status": "tsx scripts/test-sqlite-collection-status.ts",
|
||||||
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
|
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ const client = new pg.Client({ connectionString: databaseUrl, ssl: process.env.P
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
await client.query(fs.readFileSync(schemaPath, 'utf8'));
|
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||||
|
await client.query(schema);
|
||||||
const existing = Number((await client.query('SELECT COUNT(*)::int AS count FROM operation_groups')).rows[0].count);
|
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');
|
if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace');
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
@@ -45,6 +46,9 @@ try {
|
|||||||
}
|
}
|
||||||
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
|
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
|
||||||
}
|
}
|
||||||
|
const collectionStatusRepair = schema.match(/-- COLLECTION_STATUS_REPAIR_START([\s\S]+?)-- COLLECTION_STATUS_REPAIR_END/)?.[1];
|
||||||
|
if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本');
|
||||||
|
await client.query(collectionStatusRepair);
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
|
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ try {
|
|||||||
const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie);
|
const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie);
|
||||||
expectStatus(collection.response.status, 201, '创建作品交付集');
|
expectStatus(collection.response.status, 201, '创建作品交付集');
|
||||||
const collectionId=Number((collection.body as {id:number}).id);
|
const collectionId=Number((collection.body as {id:number}).id);
|
||||||
|
if((collection.body as {status:string}).status!=='draft')throw new Error('空作品交付集未初始化为待提交');
|
||||||
|
|
||||||
const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie);
|
const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie);
|
||||||
expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body);
|
expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body);
|
||||||
@@ -104,6 +105,9 @@ try {
|
|||||||
const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
||||||
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
|
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
|
||||||
const workId=Number((createdWork.body as {id:number}).id);
|
const workId=Number((createdWork.body as {id:number}).id);
|
||||||
|
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 repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
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);
|
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 重复请求创建了不同作品');
|
if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品');
|
||||||
@@ -117,22 +121,80 @@ try {
|
|||||||
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
|
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
|
||||||
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
|
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 保存');
|
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
|
||||||
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
|
|
||||||
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
|
|
||||||
|
|
||||||
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
||||||
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
||||||
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
||||||
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
||||||
expectStatus(reviewProject.response.status, 200, '客户项目读取');
|
expectStatus(reviewProject.response.status, 200, '客户项目读取');
|
||||||
|
|
||||||
|
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||||
|
expectStatus(approveV2.response.status,200,'客户通过作品',approveV2.body);
|
||||||
|
const completedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
|
const completedCollection=(completedCollections.body as Array<{id:number;status:string;completed_at:string|null;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||||||
|
if(completedCollection?.status!=='completed'||!completedCollection.completed_at||Number(completedCollection.approved_count)!==1)throw new Error('全部作品通过后,作品交付集未自动完成');
|
||||||
|
const readonlyComment=await request(`/api/review/postgres-runtime-test/works/${workId}/comments`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'完成后不应写入'})},reviewCookie);
|
||||||
|
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('作品详情未返回作品交付集完成状态');
|
||||||
|
|
||||||
|
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);
|
||||||
|
const reopenedByAdmin=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
|
if((reopenedByAdmin.body as Array<{id:number;status:string}>).find((item)=>Number(item.id)===collectionId)?.status!=='reviewing')throw new Error('管理员重新打开作品后,作品交付集未回到验收中');
|
||||||
|
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||||
|
expectStatus(approveReopened.response.status,200,'客户通过重新打开的作品',approveReopened.body);
|
||||||
|
|
||||||
|
const versionThree=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V3',description:'完成后追加版本',tags:['API 测试'],images:['https://cdn.example.com/runtime-v3.jpg']})});
|
||||||
|
expectStatus(versionThree.response.status,201,'完成后创建新版本',versionThree.body);
|
||||||
|
const reopenedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
|
const reopenedCollection=(reopenedCollections.body as Array<{id:number;status:string;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
|
||||||
|
if(reopenedCollection?.status!=='reviewing'||reopenedCollection.completed_at!==null)throw new Error('新版本未将作品交付集重新打开为验收中');
|
||||||
|
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'changes_requested',reason:'请调整第三版'})},reviewCookie);
|
||||||
|
expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body);
|
||||||
|
const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||||
|
expectStatus(approveV3.response.status,200,'客户再次通过',approveV3.body);
|
||||||
|
const secondWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({collectionId,externalId:'runtime-client-work-002',title:'完成后新增作品',description:'验证部分通过',tags:['API 测试'],images:['https://cdn.example.com/runtime-second.jpg']})});
|
||||||
|
expectStatus(secondWork.response.status,201,'完成后新增作品',secondWork.body);
|
||||||
|
const secondWorkId=Number((secondWork.body as {id:number}).id);
|
||||||
|
const partialCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
|
const partialCollection=(partialCollections.body as Array<{id:number;status:string;work_count:number;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||||||
|
if(partialCollection?.status!=='reviewing'||Number(partialCollection.work_count)!==2||Number(partialCollection.approved_count)!==1)throw new Error('完成后新增作品未恢复验收中或进度统计错误');
|
||||||
|
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||||
|
expectStatus(approveSecond.response.status,200,'客户通过新增作品',approveSecond.body);
|
||||||
|
const forbiddenDraft=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||||||
|
expectStatus(forbiddenDraft.response.status,409,'普通写入不能绕过重新打开规则',forbiddenDraft.body);
|
||||||
|
const reopenForDraft=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证退回草稿后的集合状态'})},newAdminCookie);
|
||||||
|
expectStatus(reopenForDraft.response.status,200,'组管理员重新打开第一件作品',reopenForDraft.body);
|
||||||
|
const reopenSecondForDraft=await request(`/api/notes/${secondWorkId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证空集合状态'})},newAdminCookie);
|
||||||
|
expectStatus(reopenSecondForDraft.response.status,200,'组管理员重新打开第二件作品',reopenSecondForDraft.body);
|
||||||
|
const draftWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||||||
|
expectStatus(draftWork.response.status,200,'作品退回草稿',draftWork.body);
|
||||||
|
const draftSecondWork=await request(`/api/notes/${secondWorkId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||||||
|
expectStatus(draftSecondWork.response.status,200,'新增作品退回草稿',draftSecondWork.body);
|
||||||
|
const draftCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
|
const draftCollection=(draftCollections.body as Array<{id:number;status:string;work_count:number;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
|
||||||
|
if(draftCollection?.status!=='draft'||Number(draftCollection.work_count)!==0||draftCollection.completed_at!==null)throw new Error('无已提交作品时未回到待提交');
|
||||||
|
const resubmitWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'pending'})});
|
||||||
|
expectStatus(resubmitWork.response.status,200,'重新提交作品',resubmitWork.body);
|
||||||
|
const deleteWork=await request(`/api/notes/${workId}`,{method:'DELETE',headers:bearerHeaders});
|
||||||
|
expectStatus(deleteWork.response.status,204,'删除最后一件作品',deleteWork.body);
|
||||||
|
const deleteSecondWork=await request(`/api/notes/${secondWorkId}`,{method:'DELETE',headers:bearerHeaders});
|
||||||
|
expectStatus(deleteSecondWork.response.status,204,'删除第二件作品',deleteSecondWork.body);
|
||||||
|
const emptyCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||||
|
const emptyCollection=(emptyCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||||||
|
if(emptyCollection?.status!=='draft'||Number(emptyCollection.work_count)!==0)throw new Error('删除最后一件作品后未回到待提交');
|
||||||
|
|
||||||
|
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
|
||||||
|
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
|
||||||
|
|
||||||
const apiKey = await request('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '运行时测试 Key' }) }, adminCookie);
|
const apiKey = await request('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '运行时测试 Key' }) }, adminCookie);
|
||||||
expectStatus(apiKey.response.status, 201, '创建平台 API Key');
|
expectStatus(apiKey.response.status, 201, '创建平台 API Key');
|
||||||
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
|
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
|
||||||
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
||||||
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
|
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 {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||||
await closeDatabase();
|
await closeDatabase();
|
||||||
|
|||||||
63
scripts/test-sqlite-collection-status.ts
Normal file
63
scripts/test-sqlite-collection-status.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
process.env.NODE_ENV = 'test';
|
||||||
|
delete process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
const { db } = await import('../api/db.js');
|
||||||
|
const { database, closeDatabase } = await import('../api/database.js');
|
||||||
|
const { deriveCollectionStatus, recalculateCollectionStatus } = await import('../api/services/collectionsService.js');
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function current(collectionId: number) {
|
||||||
|
return database.one<{ status: string; completed_at: string | null }>('SELECT status, completed_at FROM collections WHERE id = ?', [collectionId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(deriveCollectionStatus(0, 0) === 'draft', '空作品交付集应为待提交');
|
||||||
|
assert(deriveCollectionStatus(2, 1) === 'reviewing', '存在未通过作品时应为验收中');
|
||||||
|
assert(deriveCollectionStatus(2, 2) === 'completed', '全部作品通过后应为验收完毕');
|
||||||
|
|
||||||
|
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 (?, ?, 'draft')", [project.id, `状态测试-${Date.now()}`]);
|
||||||
|
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
assert((await current(collectionId))?.status === 'draft', '新建空作品交付集状态错误');
|
||||||
|
|
||||||
|
const noteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, 'SQLite 状态测试作品']);
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
assert((await current(collectionId))?.status === 'reviewing', '新增待验收作品后未进入验收中');
|
||||||
|
|
||||||
|
await database.execute("UPDATE notes SET review_status = 'approved' WHERE id = ?", [noteId]);
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
const completed = await current(collectionId);
|
||||||
|
assert(completed?.status === 'completed' && completed.completed_at, '全部通过后未完成或缺少完成时间');
|
||||||
|
|
||||||
|
const secondNoteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, 'SQLite 状态测试作品二']);
|
||||||
|
const partial = await recalculateCollectionStatus(collectionId);
|
||||||
|
assert(partial?.status === 'reviewing' && partial.workCount === 2 && partial.approvedCount === 1, '部分作品通过时应保持验收中');
|
||||||
|
await database.execute("UPDATE notes SET review_status = 'approved' WHERE id = ?", [secondNoteId]);
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
assert((await current(collectionId))?.status === 'completed', '多件作品全部通过后应验收完毕');
|
||||||
|
|
||||||
|
await database.execute("UPDATE collections SET status = 'archived' WHERE id = ?", [collectionId]);
|
||||||
|
await database.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [noteId]);
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
assert((await current(collectionId))?.status === 'archived', '自动重算不应覆盖手动归档状态');
|
||||||
|
|
||||||
|
await database.execute("UPDATE collections SET status = 'reviewing' WHERE id = ?", [collectionId]);
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
assert((await current(collectionId))?.status === 'reviewing', '恢复归档后应按作品状态重算');
|
||||||
|
|
||||||
|
await database.execute("UPDATE notes SET review_status = 'draft' WHERE id IN (?, ?)", [noteId, secondNoteId]);
|
||||||
|
await recalculateCollectionStatus(collectionId);
|
||||||
|
const draft = await current(collectionId);
|
||||||
|
assert(draft?.status === 'draft' && draft.completed_at === null, '全部作品退回草稿后应回到待提交并清除完成时间');
|
||||||
|
|
||||||
|
process.stdout.write('SQLite 作品交付集状态验证通过:待提交、验收中、验收完毕、归档保护与恢复重算\n');
|
||||||
|
} finally {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
await closeDatabase();
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ try {
|
|||||||
await client.connect();
|
await client.connect();
|
||||||
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
|
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
|
||||||
.replace(/^BEGIN;|COMMIT;$/gm, '')
|
.replace(/^BEGIN;|COMMIT;$/gm, '')
|
||||||
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
|
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
|
||||||
|
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '');
|
||||||
await client.query(schema);
|
await client.query(schema);
|
||||||
for (const table of tables) {
|
for (const table of tables) {
|
||||||
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export interface WorkCollection {
|
|||||||
status: CollectionStatus;
|
status: CollectionStatus;
|
||||||
work_count: number;
|
work_count: number;
|
||||||
approved_count: number;
|
approved_count: number;
|
||||||
|
completed_at: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +191,7 @@ export interface NoteDetail extends Note {
|
|||||||
text_annotations: TextAnnotation[];
|
text_annotations: TextAnnotation[];
|
||||||
comments: WorkComment[];
|
comments: WorkComment[];
|
||||||
project: Pick<Project, 'id' | 'name' | 'slug'>;
|
project: Pick<Project, 'id' | 'name' | 'slug'>;
|
||||||
collection: Pick<WorkCollection, 'id' | 'name'>;
|
collection: Pick<WorkCollection, 'id' | 'name' | 'status'>;
|
||||||
versions: WorkVersion[];
|
versions: WorkVersion[];
|
||||||
review_events: ReviewEvent[];
|
review_events: ReviewEvent[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { useRef, useState } from 'react';
|
|||||||
import { Check, MessageCircle, X } from 'lucide-react';
|
import { Check, MessageCircle, X } from 'lucide-react';
|
||||||
import type { Annotation, NoteImage } from '@shared/types';
|
import type { Annotation, NoteImage } from '@shared/types';
|
||||||
|
|
||||||
export default function AnnotatableImage({image,annotations,onAdd}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>}){
|
export default function AnnotatableImage({image,annotations,onAdd,readOnly=false}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>;readOnly?:boolean}){
|
||||||
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('');
|
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('');
|
||||||
const selected=annotations.find(annotation=>annotation.id===selectedId);
|
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}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(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('')};
|
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 cursor-crosshair overflow-hidden rounded-2xl bg-[#e9e7e0]" 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>)}
|
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>}
|
{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>
|
{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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|||||||
import { Check, MessageCircle, Plus, X } from 'lucide-react';
|
import { Check, MessageCircle, Plus, X } from 'lucide-react';
|
||||||
import type { TextAnnotation } from '@shared/types';
|
import type { TextAnnotation } from '@shared/types';
|
||||||
|
|
||||||
export default function AnnotatableText({ label, annotations, onAdd, children }: { label: string; annotations: TextAnnotation[]; onAdd: (content: string) => Promise<void>; children: React.ReactNode }) {
|
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 [adding, setAdding] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||||
const [text, setText] = useState('');
|
const [text, setText] = useState('');
|
||||||
@@ -19,7 +19,7 @@ export default function AnnotatableText({ label, annotations, onAdd, children }:
|
|||||||
return <div className="relative">
|
return <div className="relative">
|
||||||
{children}
|
{children}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<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>
|
{!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>)}
|
{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>
|
</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>}
|
{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>}
|
||||||
|
|||||||
19
src/components/CollectionStatusBadge.tsx
Normal file
19
src/components/CollectionStatusBadge.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { CollectionStatus } from '@shared/types';
|
||||||
|
|
||||||
|
const labels: Record<CollectionStatus, string> = {
|
||||||
|
draft: '待提交',
|
||||||
|
reviewing: '验收中',
|
||||||
|
completed: '验收完毕',
|
||||||
|
archived: '已归档',
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles: Record<CollectionStatus, string> = {
|
||||||
|
draft: 'bg-black/5 text-black/50',
|
||||||
|
reviewing: 'bg-amber-50 text-amber-700',
|
||||||
|
completed: 'bg-emerald-50 text-emerald-700',
|
||||||
|
archived: 'bg-slate-100 text-slate-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CollectionStatusBadge({ status }: { status: CollectionStatus }) {
|
||||||
|
return <span className={`rounded-full px-2.5 py-1 text-[10px] font-medium ${styles[status]}`}>{labels[status]}</span>;
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import type { Note, Project, ReviewStatus, WorkCollection } from '@shared/types'
|
|||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
import StatusBadge from '@/components/StatusBadge';
|
import StatusBadge from '@/components/StatusBadge';
|
||||||
import { useAuthStore } from '@/store/useAuthStore';
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
|
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
|
||||||
|
|
||||||
export default function CollectionPage(){
|
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 {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|''>('');
|
||||||
@@ -13,7 +14,7 @@ export default function CollectionPage(){
|
|||||||
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]);
|
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;
|
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>
|
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><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#ef4b2f]">Collection / Review Board</p><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></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="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>
|
<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>
|
<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>}
|
{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>}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { Link, useParams } from 'react-router-dom';
|
|||||||
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
|
||||||
import type { Project, WorkCollection } from '@shared/types';
|
import type { Project, WorkCollection } from '@shared/types';
|
||||||
import { api } from '@/api/client';
|
import { api } from '@/api/client';
|
||||||
|
import CollectionStatusBadge from '@/components/CollectionStatusBadge';
|
||||||
|
|
||||||
export default function ProjectPage() {
|
export default function ProjectPage() {
|
||||||
const id = Number(useParams().projectId);
|
const id = Number(useParams().projectId);
|
||||||
@@ -53,7 +54,7 @@ export default function ProjectPage() {
|
|||||||
|
|
||||||
return <main>
|
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="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><span className="rounded-full bg-emerald-50 px-2 py-1 text-[10px] text-emerald-700">验收中</span></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>已通过</div><ArrowRight className="text-black/25 transition group-hover:translate-x-1 group-hover:text-black"/></div></Link>)}</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>}
|
{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>}
|
{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>}
|
{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>}
|
||||||
|
|||||||
Reference in New Issue
Block a user