feat(auth): 添加认证模块和图片批注功能(项目初始化)

- 实现用户登录、登出、密码修改等认证功能
- 添加会话管理和权限控制中间件
- 创建图片批注组件和相关API路由
- 实现批注的增删改查功能
- 添加Docker和Git忽略配置文件
- 创建系统架构文档和开发约定说明
- 集成认证模块到前端应用路由中
This commit is contained in:
yuzhe
2026-07-21 15:28:55 +08:00
commit b0c498fbb6
81 changed files with 10826 additions and 0 deletions

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
node_modules
dist
.pnpm-store
.cache
coverage
.nyc_output
playwright-report
test-results
data
uploads
.env
.env.*
*.db
*.db-journal
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
*.sqlite-shm
*.sqlite-wal
*.key
*.pem
*.p12
*.pfx
*.log
*.tmp
*.bak
tmp
temp
.git
.idea
.trae
.vercel
atelier-notes.zip
=

19
.env.example Normal file
View File

@@ -0,0 +1,19 @@
PORT=3001
NODE_ENV=development
CORS_ORIGIN=http://localhost:5173
# 正式环境必须填写;本地 SQLite 模式可暂时留空。
DATABASE_URL=postgresql://delivery_desk:local_delivery_desk@127.0.0.1:5432/delivery_desk
PGSSL=disable
PG_POOL_MAX=10
# docker compose 使用;不要在仓库中填写真实密码。
POSTGRES_PASSWORD=replace-with-a-random-database-password
# 正式环境必须使用随机生成的长密钥,切换环境后保持不变,否则无法解密已保存的 COS 凭证。
COS_CONFIG_ENCRYPTION_KEY=replace-with-at-least-32-random-characters
# 仅首次初始化空数据库时使用。
INITIAL_ADMIN_PASSWORD=replace-before-first-start
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_DISPLAY_NAME=平台管理员

69
.gitignore vendored Normal file
View File

@@ -0,0 +1,69 @@
# Dependencies and build output
node_modules/
.pnpm-store/
dist/
dist-ssr/
.vite/
coverage/
.nyc_output/
playwright-report/
test-results/
.cache/
.eslintcache
*.tsbuildinfo
# Environment files and local secrets
.env
.env.*
!.env.example
*.local
# Runtime data and user uploads
/data/
/uploads/
*.db
*.db-journal
*.db-shm
*.db-wal
*.sqlite
*.sqlite3
*.sqlite-shm
*.sqlite-wal
# Private keys and local certificates
*.key
*.pem
*.p12
*.pfx
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
*.pid
*.tmp
*.bak
tmp/
temp/
# Editors and local tooling
.vscode/*
!.vscode/extensions.json
.idea/
.trae/
.vercel/
.DS_Store
Thumbs.db
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Local source archives and accidental files
/atelier-notes.zip
/=

8
AGENTS.md Normal file
View File

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

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:22-bookworm-slim AS build
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
FROM node:22-bookworm-slim AS runtime
RUN corepack enable
ENV NODE_ENV=production
WORKDIR /app
COPY --from=build /app/package.json /app/pnpm-lock.yaml ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/api ./api
COPY --from=build /app/shared ./shared
COPY --from=build /app/db ./db
COPY --from=build /app/dist ./dist
EXPOSE 3001
CMD ["pnpm", "server:prod"]

92
README.md Normal file
View File

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

102
api/app.ts Normal file
View File

@@ -0,0 +1,102 @@
import 'express-async-errors';
/**
* Express 应用主入口
*/
import express, {
type Request,
type Response,
} from 'express';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import cors from 'cors';
import dotenv from 'dotenv';
import notesRoutes from './routes/notes.js';
import imagesRoutes from './routes/images.js';
import annotationsRoutes from './routes/annotations.js';
import projectsRoutes from './routes/projects.js';
import commentsRoutes from './routes/comments.js';
import authRoutes from './routes/auth.js';
import { optionalAuth } from './auth.js';
import groupsRoutes from './routes/groups.js';
import managementRoutes from './routes/management.js';
import storageRoutes from './routes/storage.js';
import reviewRoutes from './routes/review.js';
import { UPLOADS_DIR } from './upload.js';
import { database, databaseDialect } from './database.js';
dotenv.config();
const app: express.Application = express();
const __dirname = path.dirname(fileURLToPath(import.meta.url));
app.set('trust proxy', 1);
const allowedOrigins = process.env.CORS_ORIGIN?.split(',').map((item) => item.trim()).filter(Boolean);
app.use(cors({ origin: allowedOrigins?.length ? allowedOrigins : true, credentials: true }));
app.use(express.json({ limit: '20mb' }));
app.use(express.urlencoded({ extended: true, limit: '20mb' }));
app.use(optionalAuth);
// 静态图片资源
app.use(
'/uploads',
express.static(UPLOADS_DIR, {
maxAge: '7d',
immutable: true,
}),
);
/**
* API 路由
*/
app.use('/api/notes', notesRoutes);
app.use('/api/images', imagesRoutes);
app.use('/api/annotations', annotationsRoutes);
app.use('/api/projects', projectsRoutes);
app.use('/api', commentsRoutes);
app.use('/api/auth', authRoutes);
app.use('/api/groups', groupsRoutes);
app.use('/api/management', managementRoutes);
app.use('/api/management/storage-configs', storageRoutes);
app.use('/api/review', reviewRoutes);
/**
* health
*/
app.use('/api/health', async (_req: Request, res: Response) => {
await database.one('SELECT 1 AS ready');
res.status(200).json({ success: true, message: 'ok', database: databaseDialect });
});
const distDir = path.resolve(__dirname, '..', 'dist');
if (process.env.NODE_ENV === 'production' && fs.existsSync(distDir)) {
app.use(express.static(distDir, { maxAge: '1h' }));
app.get('*', (req, res, next) => {
if (req.path.startsWith('/api/')) { next(); return; }
res.sendFile(path.join(distDir, 'index.html'));
});
}
/**
* 错误处理
*/
app.use((err: Error, _req: Request, res: Response, _next: unknown) => {
void _next;
console.error('[API Error]', err);
// multer 文件类型/大小错误
const message = err.message || '服务器内部错误';
if (err.message?.includes('不支持的文件类型')) {
res.status(400).json({ success: false, error: message });
return;
}
res.status(500).json({ success: false, error: message });
});
/**
* 404
*/
app.use((_req: Request, res: Response) => {
res.status(404).json({ success: false, error: 'API not found' });
});
export default app;

114
api/auth.ts Normal file
View File

@@ -0,0 +1,114 @@
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import type { CurrentUser, UserRole } from '../shared/types.js';
import { database } from './database.js';
export interface ApiKeyPrincipal {
id: number;
group_id: number | null;
project_id: number | null;
scope: 'platform' | 'project';
}
export type AuthRequest = Request & { authUser?: CurrentUser; apiKey?: ApiKeyPrincipal };
export function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export function verifyPassword(password: string, stored: string): boolean {
const [salt, expected] = stored.split(':');
if (!salt || !expected) return false;
const actual = scryptSync(password, salt, 64);
const expectedBuffer = Buffer.from(expected, 'hex');
return actual.length === expectedBuffer.length && timingSafeEqual(actual, expectedBuffer);
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}:${scryptSync(password, salt, 64).toString('hex')}`;
}
export async function createSession(userId: number): Promise<string> {
const token = randomBytes(32).toString('base64url');
await database.execute('INSERT INTO sessions (user_id, token_hash, expires_at) VALUES (?, ?, ?)', [userId, sha256(token), new Date(Date.now() + 7 * 86400000).toISOString()]);
return token;
}
function readCookie(req: Request, name: string): string | undefined {
return req.headers.cookie?.split(';').map((item) => item.trim()).find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1);
}
export async function optionalAuth(req: AuthRequest, _res: Response, next: NextFunction) {
const bearer = req.headers.authorization?.startsWith('Bearer ') ? req.headers.authorization.slice(7) : undefined;
if (bearer?.startsWith('dd_')) {
const key = await database.one<ApiKeyPrincipal>(`
SELECT k.id, k.group_id, k.project_id, k.scope
FROM api_keys k
LEFT JOIN operation_groups g ON g.id = k.group_id
LEFT JOIN projects p ON p.id = k.project_id
WHERE k.key_hash = ? AND k.status = 'active'
AND (k.scope = 'platform' OR (g.status = 'active' AND p.status = 'active'))
`, [sha256(bearer)]);
if (key) {
req.apiKey = key;
await database.execute('UPDATE api_keys SET last_used_at = ? WHERE id = ?', [new Date().toISOString(), key.id]);
}
next();
return;
}
const token = bearer || readCookie(req, 'proofing_session');
if (token) {
const row = await database.one<Omit<CurrentUser, 'must_change_password'> & { must_change_password: boolean | number }>(`
SELECT u.id, u.group_id, g.name AS group_name, u.username, u.display_name, u.role, u.must_change_password
FROM sessions s JOIN users u ON u.id = s.user_id
LEFT JOIN operation_groups g ON g.id = u.group_id
WHERE s.token_hash = ? AND s.expires_at > ? AND u.status = 'active'
`, [sha256(token), new Date().toISOString()]);
if (row) req.authUser = { ...row, must_change_password: Boolean(row.must_change_password) };
}
next();
}
export function requireRole(...roles: UserRole[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.authUser) { res.status(401).json({ error: '请先登录运营账号' }); return; }
if (!roles.includes(req.authUser.role)) { res.status(403).json({ error: '当前账号没有执行此操作的权限' }); return; }
next();
};
}
export function requireWriter(req: AuthRequest, res: Response, next: NextFunction) {
if (req.authUser && ['platform_admin', 'group_admin', 'operator'].includes(req.authUser.role)) {
next();
return;
}
if (req.apiKey) {
next();
return;
}
res.status(401).json({ error: '请登录工作台账号或提供有效的 API Key' });
}
export async function canWriteProject(req: AuthRequest, projectId: number): Promise<boolean> {
if (req.authUser?.role === 'platform_admin') return true;
if (req.authUser) {
const project = await database.one<{ group_id: number | null }>('SELECT group_id FROM projects WHERE id = ?', [projectId]);
return Boolean(project && project.group_id === req.authUser.group_id);
}
if (req.apiKey?.scope === 'platform') return true;
return req.apiKey?.scope === 'project' && req.apiKey.project_id === projectId;
}
export async function audit(req: AuthRequest, action: string, entityType: string, entityId?: number, detail: Record<string, unknown> = {}) {
await database.execute('INSERT INTO audit_logs (group_id, user_id, action, entity_type, entity_id, detail) VALUES (?, ?, ?, ?, ?, ?)', [req.authUser?.group_id ?? req.apiKey?.group_id ?? null, req.authUser?.id ?? null, action, entityType, entityId ?? null, JSON.stringify(req.apiKey ? { ...detail, apiKeyId: req.apiKey.id } : detail)]);
}
export async function clearSession(token: string | undefined) {
if (token) await database.execute('DELETE FROM sessions WHERE token_hash = ?', [sha256(token)]);
}
export function sessionCookie(token: string): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `proofing_session=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=604800${secure}`;
}

38
api/configCrypto.ts Normal file
View File

@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
dotenv.config();
const dataDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'data');
const localKeyPath = path.join(dataDir, '.config-encryption-key');
function loadKey(): Buffer {
const configured = process.env.COS_CONFIG_ENCRYPTION_KEY?.trim();
if (configured) return createHash('sha256').update(configured, 'utf8').digest();
fs.mkdirSync(dataDir, { recursive: true });
if (!fs.existsSync(localKeyPath)) {
fs.writeFileSync(localKeyPath, randomBytes(32).toString('base64'), { encoding: 'utf8', mode: 0o600, flag: 'wx' });
}
return Buffer.from(fs.readFileSync(localKeyPath, 'utf8').trim(), 'base64');
}
const key = loadKey();
export function encryptSecret(value: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
}
export function decryptSecret(value: string): string {
const [version, iv, tag, encrypted] = value.split(':');
if (version !== 'v1' || !iv || !tag || !encrypted) throw new Error('无法读取已保存的存储凭证');
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(iv, 'base64'));
decipher.setAuthTag(Buffer.from(tag, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64')), decipher.final()]).toString('utf8');
}

48
api/customerAuth.ts Normal file
View File

@@ -0,0 +1,48 @@
import { randomBytes } from 'crypto';
import type { NextFunction, Request, Response } from 'express';
import { database } from './database.js';
import { sha256 } from './auth.js';
export type CustomerRequest = Request & {
customer?: { project_id: number; reviewer_name: string };
};
function readCookie(req: Request, name: string): string | undefined {
return req.headers.cookie?.split(';').map((item) => item.trim()).find((item) => item.startsWith(`${name}=`))?.slice(name.length + 1);
}
export async function createCustomerSession(projectId: number, reviewerName: string): Promise<string> {
const token = randomBytes(32).toString('base64url');
await database.execute('INSERT INTO customer_sessions (project_id, reviewer_name, token_hash, expires_at) VALUES (?, ?, ?, ?)', [projectId, reviewerName, sha256(token), new Date(Date.now() + 7 * 86400000).toISOString()]);
return token;
}
export function customerSessionCookie(token: string): string {
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
return `review_session=${token}; HttpOnly; SameSite=Lax; Path=/api/review; Max-Age=604800${secure}`;
}
export async function optionalCustomer(req: CustomerRequest, _res: Response, next: NextFunction) {
const token = readCookie(req, 'review_session');
if (token) {
const row = await database.one<NonNullable<CustomerRequest['customer']>>(`
SELECT s.project_id, s.reviewer_name
FROM customer_sessions s
JOIN projects p ON p.id = s.project_id
WHERE s.token_hash = ? AND s.expires_at > ?
AND p.customer_access_enabled = TRUE AND p.status != 'archived'
AND (p.access_expires_at IS NULL OR p.access_expires_at > ?)
`, [sha256(token), new Date().toISOString(), new Date().toISOString()]);
if (row) req.customer = row;
}
next();
}
export async function requireCustomerProject(req: CustomerRequest, res: Response, next: NextFunction) {
const project = await database.one<{ id: number }>('SELECT id FROM projects WHERE slug = ?', [req.params.slug]);
if (!project || !req.customer || req.customer.project_id !== project.id) {
res.status(401).json({ error: '请先输入项目访问密码' });
return;
}
next();
}

116
api/database.ts Normal file
View File

@@ -0,0 +1,116 @@
import fs from 'node:fs';
import path from 'node:path';
import pg, { type PoolClient } from 'pg';
import type Database from 'better-sqlite3';
import dotenv from 'dotenv';
import { randomBytes, scryptSync } from 'node:crypto';
dotenv.config();
type SqliteDatabase = Database.Database;
type Connection = PoolClient | SqliteDatabase;
const databaseUrl = process.env.DATABASE_URL?.trim();
export const databaseDialect = databaseUrl ? 'postgres' : 'sqlite';
let sqlite: SqliteDatabase | undefined;
let pool: pg.Pool | undefined;
if (databaseUrl) {
if (databaseUrl === 'pg-mem://' && process.env.NODE_ENV === 'test') {
const { newDb } = await import('pg-mem');
const adapter = newDb({ autoCreateForeignKeyIndices: true }).adapters.createPg();
pool = new adapter.Pool() as unknown as pg.Pool;
} else {
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');
if (databaseUrl === 'pg-mem://') schema = schema.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
await pool.query(schema);
const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count);
if (userCount === 0) {
const initialPassword = process.env.INITIAL_ADMIN_PASSWORD;
if (!initialPassword) throw new Error('空 PostgreSQL 数据库需要配置 INITIAL_ADMIN_PASSWORD');
const salt = randomBytes(16).toString('hex');
const passwordHash = `${salt}:${scryptSync(initialPassword, salt, 64).toString('hex')}`;
await pool.query('INSERT INTO users (group_id, username, display_name, password_hash, role, must_change_password) VALUES (NULL, $1, $2, $3, $4, TRUE)', [process.env.INITIAL_ADMIN_USERNAME || 'admin', process.env.INITIAL_ADMIN_DISPLAY_NAME || '平台管理员', passwordHash, 'platform_admin']);
}
} else {
sqlite = (await import('./db.js')).db;
}
function postgresSql(sql: string): string {
let index = 0;
return sql.replace(/\?/g, () => `$${++index}`);
}
async function connectionQuery<T>(connection: Connection, sql: string, params: unknown[]): Promise<{ rows: T[]; rowCount: number }> {
if (databaseDialect === 'postgres') {
const result = await (connection as PoolClient).query(postgresSql(sql), params);
return { rows: result.rows as T[], rowCount: result.rowCount ?? 0 };
}
const statement = (connection as SqliteDatabase).prepare(sql);
const sqliteParams = params.map((value) => typeof value === 'boolean' ? Number(value) : value);
if (/^\s*(SELECT|WITH|PRAGMA)/i.test(sql) || /\bRETURNING\b/i.test(sql)) {
return { rows: statement.all(...sqliteParams) as T[], rowCount: 0 };
}
const result = statement.run(...sqliteParams);
return { rows: [], rowCount: result.changes };
}
export interface QueryContext {
all<T>(sql: string, params?: unknown[]): Promise<T[]>;
one<T>(sql: string, params?: unknown[]): Promise<T | undefined>;
execute(sql: string, params?: unknown[]): Promise<{ changes: number }>;
insertId(sql: string, params?: unknown[]): Promise<number>;
}
function context(connection: Connection): QueryContext {
return {
async all<T>(sql, params = []) { return (await connectionQuery<T>(connection, sql, params)).rows; },
async one<T>(sql, params = []) { return (await connectionQuery<T>(connection, sql, params)).rows[0]; },
async execute(sql, params = []) { return { changes: (await connectionQuery(connection, sql, params)).rowCount }; },
async insertId(sql, params = []) {
if (databaseDialect === 'postgres') {
const result = await connectionQuery<{ id: number }>(connection, `${sql.replace(/;\s*$/, '')} RETURNING id`, params);
return Number(result.rows[0]?.id);
}
const sqliteParams = params.map((value) => typeof value === 'boolean' ? Number(value) : value);
const result = (connection as SqliteDatabase).prepare(sql).run(...sqliteParams);
return Number(result.lastInsertRowid);
},
};
}
export const database: QueryContext = context((pool ?? sqlite) as Connection);
export async function withTransaction<T>(work: (tx: QueryContext) => Promise<T>): Promise<T> {
if (databaseDialect === 'postgres') {
const client = await pool!.connect();
try {
await client.query('BEGIN');
const result = await work(context(client));
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
sqlite!.exec('BEGIN IMMEDIATE');
try {
const result = await work(context(sqlite!));
sqlite!.exec('COMMIT');
return result;
} catch (error) {
sqlite!.exec('ROLLBACK');
throw error;
}
}
export async function closeDatabase(): Promise<void> {
if (pool) await pool.end();
if (sqlite) sqlite.close();
}

273
api/db.ts Normal file
View File

@@ -0,0 +1,273 @@
import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { randomBytes, scryptSync } from 'crypto';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const dataDir = path.resolve(__dirname, '..', 'data');
fs.mkdirSync(dataDir, { recursive: true });
export const db = new Database(path.join(dataDir, 'app.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE IF NOT EXISTS operation_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
must_change_password INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (group_id) REFERENCES operation_groups(id) ON DELETE RESTRICT
);
CREATE UNIQUE INDEX IF NOT EXISTS one_group_admin_per_group ON users(group_id) WHERE role = 'group_admin';
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS customer_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
reviewer_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
user_id INTEGER,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id INTEGER,
detail TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER,
project_id INTEGER,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_by INTEGER NOT NULL,
last_used_at TEXT,
revoked_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (group_id) REFERENCES operation_groups(id) ON DELETE RESTRICT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS storage_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL DEFAULT 'tencent_cos',
region TEXT NOT NULL,
bucket TEXT NOT NULL,
public_base_url TEXT NOT NULL DEFAULT '',
cdn_domain TEXT NOT NULL DEFAULT '',
path_prefix TEXT NOT NULL DEFAULT 'delivery-desk',
secret_id_encrypted TEXT NOT NULL,
secret_key_encrypted TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
test_status TEXT NOT NULL DEFAULT 'untested',
test_message TEXT NOT NULL DEFAULT '',
last_tested_at TEXT,
created_by INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
activated_at TEXT,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE RESTRICT
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'reviewing',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
url TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
order_index INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
image_id INTEGER NOT NULL,
x REAL NOT NULL,
y REAL NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS work_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS text_annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
target TEXT NOT NULL,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS work_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending',
created_by INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(note_id, version_number),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS review_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
note_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_status TEXT,
to_status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
actor_name TEXT NOT NULL,
actor_role TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
);
`);
function addColumn(table: string, definition: string) {
const name = definition.split(/\s+/)[0];
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
if (!columns.some((column) => column.name === name)) {
db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`);
}
}
addColumn('notes', "collection_id INTEGER");
addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'");
addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'");
addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'");
addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'");
addColumn('projects', 'group_id INTEGER');
addColumn('projects', "access_password_hash TEXT NOT NULL DEFAULT ''");
addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0');
addColumn('projects', 'access_expires_at TEXT');
addColumn('images', "storage_provider TEXT NOT NULL DEFAULT 'local'");
addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''");
addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1');
addColumn('users', 'last_login_at TEXT');
const seedGroup = db.prepare('SELECT id FROM operation_groups ORDER BY id LIMIT 1').get() as { id: number } | undefined;
let groupId = seedGroup?.id;
if (!groupId) {
groupId = Number(db.prepare('INSERT INTO operation_groups (name) VALUES (?)').run('默认运营组').lastInsertRowid);
}
function passwordHash(password: string): string {
const salt = randomBytes(16).toString('hex');
return `${salt}:${scryptSync(password, salt, 64).toString('hex')}`;
}
const userCount = (db.prepare('SELECT COUNT(*) AS count FROM users').get() as { count: number }).count;
if (userCount === 0) {
const insert = db.prepare('INSERT INTO users (group_id, username, display_name, password_hash, role) VALUES (?, ?, ?, ?, ?)');
insert.run(null, 'admin', '平台管理员', passwordHash(process.env.INITIAL_ADMIN_PASSWORD || 'ChangeMe123!'), 'platform_admin');
insert.run(groupId, 'manager', '组管理员', passwordHash(process.env.INITIAL_MANAGER_PASSWORD || 'Manager123!'), 'group_admin');
insert.run(groupId, 'operator', '光影叙事', passwordHash(process.env.INITIAL_OPERATOR_PASSWORD || 'Operator123!'), 'operator');
}
db.prepare("UPDATE operation_groups SET name = '默认运营组' WHERE name IN ('默认内容团队', '默认创作空间')").run();
db.prepare("UPDATE users SET display_name = '组管理员' WHERE username = 'manager' AND display_name IN ('团队管理员', '空间管理员')").run();
db.prepare("UPDATE users SET display_name = '光影叙事' WHERE username = 'operator' AND display_name IN ('内容运营', '内容专员', '内容编辑')").run();
const seedProject = db.prepare('SELECT id FROM projects ORDER BY id LIMIT 1').get() as { id: number } | undefined;
let projectId = seedProject?.id;
if (!projectId) {
projectId = Number(db.prepare(`INSERT INTO projects (name, slug, client_description) VALUES (?, ?, ?)`)
.run('光影内容计划', 'light-notes', '阶段作品、反馈与验收记录').lastInsertRowid);
}
const seedCollection = db.prepare('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1').get(projectId) as { id: number } | undefined;
let collectionId = seedCollection?.id;
if (!collectionId) {
collectionId = Number(db.prepare(`INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)`)
.run(projectId, '2026 年 7 月任务', '本月内容作品交付集').lastInsertRowid);
}
db.prepare('UPDATE notes SET collection_id = ? WHERE collection_id IS NULL').run(collectionId);
db.prepare('UPDATE projects SET group_id = ? WHERE group_id IS NULL').run(groupId);
db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集'
WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run();
db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status)
SELECT id, version_number, title, description, tags, review_status FROM notes`).run();
db.exec(`
CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id);
CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes(collection_id);
CREATE INDEX IF NOT EXISTS idx_images_note_id ON images(note_id);
CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations(image_id);
CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id);
CREATE INDEX IF NOT EXISTS idx_audit_group_id ON audit_logs(group_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_group_id ON api_keys(group_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_project_id ON api_keys(project_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs(status);
CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions(note_id, version_number);
CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number);
`);
export default db;

9
api/index.ts Normal file
View File

@@ -0,0 +1,9 @@
/**
* Vercel deploy entry handler, for serverless deployment, please don't modify this file
*/
import type { VercelRequest, VercelResponse } from '@vercel/node';
import app from './app.js';
export default function handler(req: VercelRequest, res: VercelResponse) {
return app(req, res);
}

View File

@@ -0,0 +1,16 @@
import { database } from '../database.js';
import type { Annotation, CreateAnnotationRequest } from '../../shared/types.js';
type AnnotationRow = Annotation & { id: number | string; image_id: number | string };
function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Number(row.id), image_id: Number(row.image_id) }; }
export const annotationsRepository = {
async listByImage(imageId: number): Promise<Annotation[]> {
return (await database.all<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation);
},
async create(imageId: number, data: CreateAnnotationRequest): Promise<Annotation> {
const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name) VALUES (?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户']);
return toAnnotation((await database.one<AnnotationRow>('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE id = ?', [id]))!);
},
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; },
};

View File

@@ -0,0 +1,28 @@
import { database, withTransaction, type QueryContext } from '../database.js';
import type { NoteImage } from '../../shared/types.js';
interface ImageRow extends Omit<NoteImage, 'id' | 'note_id' | 'order_index'> { id: number | string; note_id: number | string; order_index: number | string }
function toImage(row: ImageRow): NoteImage { return { ...row, id: Number(row.id), note_id: Number(row.note_id), order_index: Number(row.order_index), url: /^https?:\/\//.test(row.url) || row.url.startsWith('/') ? row.url : `/uploads/${row.url}` }; }
export const imagesRepository = {
async listByNote(noteId: number, versionNumber?: number): Promise<NoteImage[]> {
const rows = await database.all<ImageRow>(`SELECT id, note_id, url, width, height, order_index, storage_provider, storage_key FROM images
WHERE note_id = ?${versionNumber ? ' AND version_number = ?' : ''} ORDER BY order_index ASC, id ASC`, versionNumber ? [noteId, versionNumber] : [noteId]);
return rows.map(toImage);
},
async createMany(noteId: number, images: { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos'; storageKey: string }[], versionNumber = 1, existing?: QueryContext): Promise<number[]> {
const insert = async (tx: QueryContext) => {
const ids: number[] = [];
for (let index = 0; index < images.length; index += 1) {
const image = images[index];
ids.push(await tx.insertId('INSERT INTO images (note_id, url, width, height, order_index, storage_provider, storage_key, version_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [noteId, image.url, image.width, image.height, index, image.storageProvider, image.storageKey, versionNumber]));
}
return ids;
};
return existing ? insert(existing) : withTransaction(insert);
},
async findById(id: number): Promise<NoteImage | null> {
const row = await database.one<ImageRow>('SELECT id, note_id, url, width, height, order_index, storage_provider, storage_key FROM images WHERE id = ?', [id]);
return row ? toImage(row) : null;
},
};

View File

@@ -0,0 +1,53 @@
import { database } from '../database.js';
import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js';
interface NoteRow {
id: number; collection_id: number; title: string; description: string; tags: string;
review_status: ReviewStatus; version_number: number; created_at: string;
image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null;
}
function toNote(row: NoteRow): Note {
return {
...row,
id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number),
image_count: Number(row.image_count), annotation_count: Number(row.annotation_count), comment_count: Number(row.comment_count),
tags: JSON.parse(row.tags || '[]') as string[],
cover_image: row.cover_url ? (/^https?:\/\//.test(row.cover_url) || row.cover_url.startsWith('/') ? row.cover_url : `/uploads/${row.cover_url}`) : '',
};
}
const select = `
SELECT n.id, n.collection_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at,
(SELECT COUNT(*) FROM images i WHERE i.note_id = n.id AND i.version_number = n.version_number) AS image_count,
(SELECT COUNT(*) FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = n.id AND i.version_number = n.version_number) AS annotation_count,
(SELECT COUNT(*) FROM work_comments wc WHERE wc.note_id = n.id) AS comment_count,
(SELECT url FROM images WHERE note_id = n.id AND version_number = n.version_number ORDER BY order_index, id LIMIT 1) AS cover_url
FROM notes n`;
export const notesRepository = {
async list(query: NoteListQuery = {}): Promise<Note[]> {
const conditions: string[] = []; const params: unknown[] = [];
if (query.collectionId) { conditions.push('n.collection_id = ?'); params.push(query.collectionId); }
if (query.status) { conditions.push('n.review_status = ?'); params.push(query.status); }
if (query.q?.trim()) { conditions.push('(n.title LIKE ? OR n.description LIKE ?)'); params.push(`%${query.q.trim()}%`, `%${query.q.trim()}%`); }
if (query.tag?.trim()) { conditions.push('n.tags LIKE ?'); params.push(`%"${query.tag.trim()}"%`); }
if (query.projectId) { conditions.push('n.collection_id IN (SELECT id FROM collections WHERE project_id = ?)'); params.push(query.projectId); }
if (query.groupId) { conditions.push('n.collection_id IN (SELECT c.id FROM collections c JOIN projects p ON p.id = c.project_id WHERE p.group_id = ?)'); params.push(query.groupId); }
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
const order = query.order === 'asc' ? 'ASC' : 'DESC';
const sort = query.sort === 'annotations' ? 'annotation_count' : 'n.created_at';
return (await database.all<NoteRow>(`${select}${where} ORDER BY ${sort} ${order}, n.id DESC`, params)).map(toNote);
},
async findById(id: number): Promise<Note | null> {
const row = await database.one<NoteRow>(`${select} WHERE n.id = ?`, [id]);
return row ? toNote(row) : null;
},
async create(title: string, description: string, collectionId: number, tags: string[]): Promise<number> {
return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']);
},
async setStatus(id: number, status: ReviewStatus): Promise<boolean> {
return (await database.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id])).changes > 0;
},
async remove(id: number): Promise<boolean> { return (await database.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0; },
};

18
api/routes/annotations.ts Normal file
View File

@@ -0,0 +1,18 @@
import { Router, type Response } from 'express';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
const router = Router();
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.annotationId);
if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM annotations a JOIN images i ON i.id = a.image_id JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE a.id = ?', [id]);
if (!context) { res.status(404).json({ error: '批注不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该批注' }); return; }
await annotationsRepository.remove(id);
res.status(204).end();
});
export default router;

43
api/routes/auth.ts Normal file
View File

@@ -0,0 +1,43 @@
import { Router, type Request, type Response } from 'express';
import { database } from '../database.js';
import { clearSession, createSession, hashPassword, sessionCookie, type AuthRequest, verifyPassword } from '../auth.js';
const router = Router();
router.post('/login', async (req: Request, res: Response) => {
const username = String(req.body?.username ?? '').trim();
const password = String(req.body?.password ?? '');
const row = await database.one<{ id: number; password_hash: string }>('SELECT id, password_hash FROM users WHERE username = ? AND status = ?', [username, 'active']);
if (!row || !verifyPassword(password, row.password_hash)) { res.status(401).json({ error: '账号或密码错误' }); return; }
await database.execute('UPDATE users SET last_login_at = ? WHERE id = ?', [new Date().toISOString(), row.id]);
const token = await createSession(row.id);
res.setHeader('Set-Cookie', sessionCookie(token));
res.json({ success: true });
});
router.post('/logout', async (req: Request, res: Response) => {
const token = req.headers.cookie?.match(/(?:^|; )proofing_session=([^;]+)/)?.[1];
await clearSession(token);
res.setHeader('Set-Cookie', 'proofing_session=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0');
res.status(204).end();
});
router.get('/me', (req: AuthRequest, res: Response) => {
if (!req.authUser) { res.status(401).json({ error: '未登录' }); return; }
res.json(req.authUser);
});
router.post('/change-password', async (req: AuthRequest, res: Response) => {
if (!req.authUser) { res.status(401).json({ error: '未登录' }); return; }
const currentPassword = String(req.body?.currentPassword ?? '');
const newPassword = String(req.body?.newPassword ?? '');
if (newPassword.length < 8 || !/[A-Za-z]/.test(newPassword) || !/\d/.test(newPassword)) {
res.status(400).json({ error: '新密码至少 8 位,并同时包含字母和数字' }); return;
}
const row = await database.one<{ password_hash: string }>('SELECT password_hash FROM users WHERE id = ?', [req.authUser.id]);
if (!row || !verifyPassword(currentPassword, row.password_hash)) { res.status(400).json({ error: '当前密码错误' }); return; }
await database.execute('UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?', [hashPassword(newPassword), false, req.authUser.id]);
res.json({ success: true });
});
export default router;

28
api/routes/comments.ts Normal file
View File

@@ -0,0 +1,28 @@
import { Router, type Response } from 'express';
import { database } from '../database.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import type { WorkComment } from '../../shared/types.js';
const router = Router();
router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId); const content = String(req.body?.content ?? '').trim();
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [noteId]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (!content || content.length > 2000) { res.status(400).json({ error: '回复内容须为 12000 个字符' }); return; }
const id = await database.insertId("INSERT INTO work_comments (note_id, content, author_name, author_role) VALUES (?, ?, ?, 'operator')", [noteId, content, req.authUser?.display_name || 'API']);
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.commentId); const status = req.body?.status;
if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_comments wc JOIN notes n ON n.id = wc.note_id JOIN collections c ON c.id = n.collection_id WHERE wc.id = ?', [id]);
if (!context) { res.status(404).json({ error: '反馈不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该反馈' }); return; }
await database.execute('UPDATE work_comments SET status = ? WHERE id = ?', [status, id]);
res.json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
});
export default router;

7
api/routes/groups.ts Normal file
View File

@@ -0,0 +1,7 @@
import { Router, type Response } from 'express';
import { audit, requireRole, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
const router=Router();
router.patch('/current',requireRole('group_admin'),async(req:AuthRequest,res:Response)=>{const groupId=req.authUser?.group_id;const name=String(req.body?.name??'').trim();if(!groupId){res.status(400).json({error:'当前账号未归属运营组'});return}if(name.length<2||name.length>40){res.status(400).json({error:'组名长度需要在 240 个字符之间'});return}try{await database.execute('UPDATE operation_groups SET name=? WHERE id=?',[name,groupId]);await audit(req,'group.rename','operation_group',groupId,{name});res.json({id:groupId,name})}catch{res.status(409).json({error:'该运营组名称已存在'})}});
export default router;

39
api/routes/images.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Router, type Response, type NextFunction } from 'express';
import { imagesRepository } from '../repositories/imagesRepository.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
import { database } from '../database.js';
const router = Router();
async function imageProjectId(imageId: number): Promise<number | undefined> {
return (await database.one<{ project_id: number }>('SELECT c.project_id FROM images i JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE i.id = ?', [imageId]))?.project_id;
}
router.get('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const imageId = Number(req.params.imageId);
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
const projectId = await imageProjectId(imageId);
if (!projectId) { res.status(404).json({ error: '图片不存在' }); return; }
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该图片' }); return; }
res.json(await annotationsRepository.listByImage(imageId));
} catch (error) { next(error); }
});
router.post('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const imageId = Number(req.params.imageId);
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); return; }
const projectId = await imageProjectId(imageId);
if (!projectId || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权操作该图片' }); return; }
const { x, y } = req.body ?? {};
const content = String(req.body?.content ?? '').trim();
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }
if (!content) { res.status(400).json({ error: '批注内容不能为空' }); return; }
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API' }));
} catch (error) { next(error); }
});
export default router;

57
api/routes/management.ts Normal file
View File

@@ -0,0 +1,57 @@
import { randomBytes } from 'crypto';
import { Router, type Response } from 'express';
import { audit, hashPassword, requireRole, sha256, type AuthRequest } from '../auth.js';
import { database, withTransaction } from '../database.js';
const router=Router();const passwordValid=(password:string)=>password.length>=8&&/[A-Za-z]/.test(password)&&/\d/.test(password);
router.get('/groups', requireRole('platform_admin'), async (_req, res) => {
const rows = await database.all<Record<string, unknown>>(`SELECT g.*,COALESCE(us.user_count,0) AS user_count,COALESCE(us.active_user_count,0) AS active_user_count,COALESCE(us.operator_count,0) AS operator_count,COALESCE(us.disabled_user_count,0) AS disabled_user_count,us.group_admin_name,us.group_admin_status,COALESCE(ps.project_count,0) AS project_count,COALESCE(ps.customer_link_count,0) AS customer_link_count FROM operation_groups g LEFT JOIN (SELECT group_id,COUNT(*) AS user_count,COUNT(CASE WHEN status='active' THEN 1 END) AS active_user_count,COUNT(CASE WHEN role='operator' THEN 1 END) AS operator_count,COUNT(CASE WHEN status='disabled' THEN 1 END) AS disabled_user_count,MAX(CASE WHEN role='group_admin' THEN display_name END) AS group_admin_name,MAX(CASE WHEN role='group_admin' THEN status END) AS group_admin_status FROM users WHERE group_id IS NOT NULL GROUP BY group_id) us ON us.group_id=g.id LEFT JOIN (SELECT group_id,COUNT(*) AS project_count,COUNT(CASE WHEN customer_access_enabled=TRUE THEN 1 END) AS customer_link_count FROM projects GROUP BY group_id) ps ON ps.group_id=g.id ORDER BY g.id DESC`);
res.json(rows.map((row) => ({ ...row, id: Number(row.id), user_count: Number(row.user_count), active_user_count:Number(row.active_user_count),operator_count:Number(row.operator_count),disabled_user_count:Number(row.disabled_user_count),project_count: Number(row.project_count),customer_link_count:Number(row.customer_link_count) })));
});
router.post('/groups',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const name=String(req.body?.name??'').trim();const username=String(req.body?.username??'').trim();const displayName=String(req.body?.display_name??'').trim();const password=String(req.body?.password??'');if(name.length<2||name.length>40){res.status(400).json({error:'运营组名称需为 240 个字符'});return}if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 332 位字母、数字、点、横线或下划线'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});return}if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}try{const created=await withTransaction(async(tx)=>{const groupId=await tx.insertId('INSERT INTO operation_groups (name) VALUES (?)',[name]);const userId=await tx.insertId("INSERT INTO users (group_id,username,display_name,password_hash,role) VALUES (?,?,?,?,'group_admin')",[groupId,username,displayName,hashPassword(password)]);return{groupId,userId}});await audit(req,'group.create','group',created.groupId,{name,firstAdminId:created.userId});res.status(201).json({id:created.groupId,name,status:'active',user_count:1,project_count:0})}catch{res.status(409).json({error:'运营组名称或账号已经存在'})}});
router.patch('/groups/:groupId/status',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const status=req.body?.status;if(!Number.isFinite(groupId)||!['active','disabled'].includes(status)){res.status(400).json({error:'无效的运营组或状态'});return}const changed=await withTransaction(async(tx)=>{const result=await tx.execute('UPDATE operation_groups SET status=? WHERE id=?',[status,groupId]);if(status==='disabled'&&result.changes){await tx.execute("UPDATE users SET status='disabled' WHERE group_id=?",[groupId]);await tx.execute('DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE group_id=?)',[groupId])}return result.changes});if(!changed){res.status(404).json({error:'运营组不存在'});return}await audit(req,`group.${status}`,'group',groupId);res.json({success:true,status})});
router.patch('/groups/:groupId',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const name=String(req.body?.name??'').trim();if(!Number.isFinite(groupId)){res.status(400).json({error:'无效的运营组'});return}if(name.length<2||name.length>40){res.status(400).json({error:'运营组名称需为 240 个字符'});return}try{const result=await database.execute('UPDATE operation_groups SET name=? WHERE id=?',[name,groupId]);if(!result.changes){res.status(404).json({error:'运营组不存在'});return}await audit(req,'group.rename','group',groupId,{name});res.json({id:groupId,name})}catch{res.status(409).json({error:'该运营组名称已经存在'})}});
router.get('/users', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
const isPlatform = req.authUser?.role === 'platform_admin';
const requestedGroup = Number(req.query.groupId);
const where = isPlatform && !Number.isFinite(requestedGroup) ? '' : 'WHERE u.group_id=?';
const params = where ? [isPlatform ? requestedGroup : req.authUser?.group_id] : [];
const rows = await database.all<Record<string, unknown>>(`SELECT u.id,u.group_id,g.name AS group_name,u.username,u.display_name,u.role,u.status,u.must_change_password,u.last_login_at,u.created_at,a.last_operation_at FROM users u LEFT JOIN operation_groups g ON g.id=u.group_id LEFT JOIN (SELECT user_id,MAX(created_at) AS last_operation_at FROM audit_logs GROUP BY user_id) a ON a.user_id=u.id ${where} ORDER BY CASE u.role WHEN 'platform_admin' THEN 0 WHEN 'group_admin' THEN 1 ELSE 2 END,g.name,u.display_name`, params);
res.json(rows.map((row) => ({ ...row, id: Number(row.id), group_id: row.group_id == null ? null : Number(row.group_id), must_change_password: Boolean(row.must_change_password) })));
});
router.post('/users',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{
const isPlatform=req.authUser?.role==='platform_admin';
const requestedRole=String(req.body?.role??'operator');
const role=isPlatform&&['platform_admin','group_admin','operator'].includes(requestedRole)?requestedRole:'operator';
const groupId=role==='platform_admin'?null:(isPlatform?Number(req.body?.group_id):req.authUser?.group_id);
const username=String(req.body?.username??'').trim();const displayName=String(req.body?.display_name??'').trim();const password=String(req.body?.password??'');
if(role!=='platform_admin'&&!Number.isFinite(groupId)){res.status(400).json({error:'请选择运营组'});return}
if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 332 位字母、数字、点、横线或下划线'});return}
if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});return}
if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}
if(role!=='platform_admin'&&!await database.one('SELECT id FROM operation_groups WHERE id=? AND status=?',[groupId,'active'])){res.status(400).json({error:'运营组不存在或已停用'});return}
if(role==='group_admin'&&await database.one('SELECT id FROM users WHERE group_id=? AND role=?',[groupId,'group_admin'])){res.status(409).json({error:'每个运营组只能有一位组管理员'});return}
try{const id=await database.insertId('INSERT INTO users (group_id,username,display_name,password_hash,role) VALUES (?,?,?,?,?)',[groupId,username,displayName,hashPassword(password),role]);await audit(req,'user.create','user',id,{groupId,username,role});res.status(201).json({id,group_id:groupId,username,display_name:displayName,role,status:'active',must_change_password:true})}catch{res.status(409).json({error:role==='group_admin'?'每个运营组只能有一位组管理员':'账号已经存在'})}
});
async function manageableUser(req:AuthRequest,userId:number){const row=await database.one<{id:number;group_id:number|null;role:string}>('SELECT id,group_id,role FROM users WHERE id=?',[userId]);if(!row||Number(row.id)===req.authUser?.id)return undefined;if(req.authUser?.role==='platform_admin')return row;return Number(row.group_id)===req.authUser?.group_id&&row.role==='operator'?row:undefined}
async function renameableUser(req:AuthRequest,userId:number){const row=await database.one<{id:number;group_id:number|null;role:string}>('SELECT id,group_id,role FROM users WHERE id=?',[userId]);if(!row)return undefined;if(req.authUser?.role==='platform_admin')return row;if(req.authUser?.role==='group_admin'&&Number(row.group_id)===req.authUser.group_id&&(Number(row.id)===req.authUser.id||row.role==='operator'))return row;return undefined}
router.patch('/users/:userId/name',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const displayName=String(req.body?.display_name??'').trim();const target=await renameableUser(req,userId);if(!target){res.status(403).json({error:'不能修改该账号姓名'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 240 个字符'});return}await database.execute('UPDATE users SET display_name=? WHERE id=?',[displayName,userId]);await audit(req,'user.name_update','user',userId,{displayName});res.json({success:true,display_name:displayName})});
router.patch('/users/:userId/status',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const status=req.body?.status;if(!['active','disabled'].includes(status)||!await manageableUser(req,userId)){res.status(403).json({error:'不能修改该账号'});return}await database.execute('UPDATE users SET status=? WHERE id=?',[status,userId]);if(status==='disabled')await database.execute('DELETE FROM sessions WHERE user_id=?',[userId]);await audit(req,`user.${status}`,'user',userId);res.json({success:true,status})});
router.post('/users/:userId/reset-password',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const userId=Number(req.params.userId);const password=String(req.body?.password??'');if(!await manageableUser(req,userId)){res.status(403).json({error:'不能重置该账号密码'});return}if(!passwordValid(password)){res.status(400).json({error:'临时密码至少 8 位,并同时包含字母和数字'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE users SET password_hash=?,must_change_password=? WHERE id=?',[hashPassword(password),true,userId]);await tx.execute('DELETE FROM sessions WHERE user_id=?',[userId])});await audit(req,'user.password_reset','user',userId);res.json({success:true})});
router.post('/groups/:groupId/replace-admin',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const groupId=Number(req.params.groupId);const userId=Number(req.body?.user_id);const previousAction=req.body?.previous_action==='disable'?'disable':'demote';const next=await database.one<{id:number;display_name:string}>('SELECT id,display_name FROM users WHERE id=? AND group_id=? AND role=? AND status=?',[userId,groupId,'operator','active']);const current=await database.one<{id:number;display_name:string}>('SELECT id,display_name FROM users WHERE group_id=? AND role=?',[groupId,'group_admin']);if(!next||!current){res.status(400).json({error:'请选择本组一位已启用的光影叙事'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE users SET role=?,status=? WHERE id=?',['operator',previousAction==='disable'?'disabled':'active',current.id]);await tx.execute('UPDATE users SET role=? WHERE id=?',['group_admin',next.id])});await audit(req,'group.admin_replace','group',groupId,{previousAdminId:Number(current.id),nextAdminId:Number(next.id),previousAction});res.json({success:true,previous_admin:current.display_name,next_admin:next.display_name})});
router.get('/api-keys', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
const platform = req.authUser?.role === 'platform_admin';
const where = platform ? "k.scope='platform'" : "k.scope='project' AND k.group_id=?";
const params = platform ? [] : [req.authUser?.group_id];
const rows = await database.all<Record<string, unknown>>(`SELECT k.id,k.group_id,k.project_id,p.name AS project_name,k.name,k.key_prefix,k.scope,k.status,u.display_name AS created_by_name,k.last_used_at,k.created_at FROM api_keys k LEFT JOIN projects p ON p.id=k.project_id JOIN users u ON u.id=k.created_by WHERE ${where} ORDER BY k.id DESC`, params);
res.json(rows.map((row) => ({ ...row, id: Number(row.id), group_id: row.group_id == null ? null : Number(row.group_id), project_id: row.project_id == null ? null : Number(row.project_id) })));
});
router.post('/api-keys',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const platform=req.authUser?.role==='platform_admin';const scope=platform?'platform':'project';const name=String(req.body?.name??'').trim();const projectId=scope==='project'?Number(req.body?.project_id):null;if(name.length<2||name.length>50){res.status(400).json({error:'Key 名称需为 250 个字符'});return}if(scope==='project'&&!await database.one('SELECT id FROM projects WHERE id=? AND group_id=?',[projectId,req.authUser?.group_id])){res.status(400).json({error:'请选择本组项目'});return}const token=`dd_live_${randomBytes(32).toString('base64url')}`;const prefix=`${token.slice(0,16)}`;const id=await database.insertId('INSERT INTO api_keys (group_id,project_id,name,key_prefix,key_hash,scope,created_by) VALUES (?,?,?,?,?,?,?)',[scope==='project'?req.authUser?.group_id:null,projectId,name,prefix,sha256(token),scope,req.authUser?.id]);await audit(req,'api_key.create','api_key',id,{name,scope,projectId});res.status(201).json({token,item:{id,group_id:scope==='project'?req.authUser?.group_id:null,project_id:projectId,name,key_prefix:prefix,scope,status:'active',created_by_name:req.authUser?.display_name,last_used_at:null,created_at:new Date().toISOString()}})});
router.delete('/api-keys/:keyId',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const keyId=Number(req.params.keyId);const platform=req.authUser?.role==='platform_admin';const key=await database.one<{id:number;group_id:number|null;scope:string}>('SELECT id,group_id,scope FROM api_keys WHERE id=?',[keyId]);const allowed=key&&(platform?key.scope==='platform':key.scope==='project'&&Number(key.group_id)===req.authUser?.group_id);if(!allowed){res.status(404).json({error:'API Key 不存在'});return}await database.execute("UPDATE api_keys SET status='revoked',revoked_at=? WHERE id=?",[new Date().toISOString(),keyId]);await audit(req,'api_key.revoke','api_key',keyId);res.status(204).end()});
router.get('/audit-logs',requireRole('platform_admin','group_admin'),async(req:AuthRequest,res:Response)=>{const conditions:string[]=[];const params:unknown[]=[];if(req.authUser?.role!=='platform_admin'){conditions.push('l.group_id=?');params.push(req.authUser?.group_id)}const userId=Number(req.query.userId);if(Number.isFinite(userId)){conditions.push('l.user_id=?');params.push(userId)}const where=conditions.length?`WHERE ${conditions.join(' AND ')}`:'';const rows=await database.all<Record<string,unknown>&{detail:string}>(`SELECT l.*,g.name AS group_name,u.display_name AS user_name FROM audit_logs l LEFT JOIN operation_groups g ON g.id=l.group_id LEFT JOIN users u ON u.id=l.user_id ${where} ORDER BY l.id DESC LIMIT 200`,params);res.json(rows.map((row)=>{try{return{...row,id:Number(row.id),group_id:row.group_id==null?null:Number(row.group_id),user_id:row.user_id==null?null:Number(row.user_id),detail:typeof row.detail==='string'?JSON.parse(row.detail):row.detail}}catch{return{...row,id:Number(row.id),detail:{}}}}))});
export default router;

183
api/routes/notes.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* 笔记路由
*/
import { Router, type Response, type NextFunction } from 'express';
import { upload } from '../upload.js';
import { notesService } from '../services/notesService.js';
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
import { database, withTransaction } from '../database.js';
import fs from 'fs';
import type { TextAnnotation } from '../../shared/types.js';
const router = Router();
// GET /api/notes - 笔记列表
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
const { sort, order, q, collectionId, status, tag } = req.query as {
sort?: string;
order?: string;
q?: string;
collectionId?: string;
status?: 'draft' | 'pending' | 'changes_requested' | 'approved';
tag?: string;
};
const groupId = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform' ? undefined : req.authUser?.group_id ?? undefined;
const projectId = req.apiKey?.scope === 'project' ? req.apiKey.project_id ?? undefined : undefined;
const list = await notesService.list({ sort, order, q, collectionId: collectionId ? Number(collectionId) : undefined, status, tag, groupId, projectId });
res.json(list);
});
// GET /api/notes/:noteId - 笔记详情
router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const id = Number(req.params.noteId);
if (!Number.isFinite(id)) {
res.status(400).json({ error: '无效的笔记 ID' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权查看该作品' }); return; }
const version = req.query.version ? Number(req.query.version) : undefined;
const detail = await notesService.getDetail(id, version);
if (!detail) {
res.status(404).json({ error: '笔记不存在' });
return;
}
res.json(detail);
} catch (err) {
next(err);
}
});
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
const noteId = Number(req.params.noteId);
const versionNumber = Number(req.body?.version_number);
const target = req.body?.target;
const content = String(req.body?.content ?? '').trim();
if (!Number.isFinite(noteId) || !Number.isFinite(versionNumber) || !['title', 'description'].includes(target)) { res.status(400).json({ error: '批注目标无效' }); return; }
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 11000 个字符' }); return; }
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
if (!context) { res.status(404).json({ error: '作品版本不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权批注该作品' }); return; }
const id = await database.insertId('INSERT INTO text_annotations (note_id, version_number, target, content, author_name) VALUES (?, ?, ?, ?, ?)', [noteId, versionNumber, target, content, req.authUser?.display_name || 'API']);
await audit(req, 'text_annotation.create', 'text_annotation', id, { noteId, versionNumber, target });
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
});
// POST /api/notes - 上传新笔记 (multipart/form-data)
router.post(
'/',
requireWriter,
upload.array('images', 30),
async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const title = (req.body.title || '').toString().trim();
const description = (req.body.description || '').toString().trim();
const collectionId = Number(req.body.collectionId);
const tagsText = String(req.body.tags || '');
const tags = tagsText ? [tagsText] : [];
if (!title) {
res.status(400).json({ error: '标题不能为空' });
return;
}
if (!Number.isFinite(collectionId)) {
res.status(400).json({ error: '请选择作品交付集' });
return;
}
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]);
if (!collection || !await canWriteProject(req, collection.project_id)) {
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
return;
}
if (files.length === 0) {
res.status(400).json({ error: '请至少上传一张图片' });
return;
}
const note = await notesService.create(
title,
description,
files.map((f) => ({ filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, path: f.path })),
collectionId,
tags,
);
await audit(req, 'work.create', 'work', note.id, { collectionId, imageCount: files.length });
res.status(201).json(note);
} catch (err) {
next(err);
}
},
);
router.post('/:noteId/versions', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
try {
const id = Number(req.params.noteId);
const context = await database.one<{ title: string; description: string; tags: string; project_id: number }>('SELECT n.title, n.description, n.tags, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (!files.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; }
const title = String(req.body?.title ?? context.title).trim();
const description = String(req.body?.description ?? context.description).trim();
const tagsText = String(req.body?.tags ?? '');
const tags = tagsText ? [tagsText] : [];
if (!title) { res.status(400).json({ error: '标题不能为空' }); return; }
const note = await notesService.createVersion(id, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, req.authUser?.id);
await audit(req, 'work.version_create', 'work', id, { versionNumber: note.version_number, imageCount: files.length });
res.status(201).json(note);
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
});
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
const status = req.body?.status;
if (!['draft', 'pending'].includes(status)) {
res.status(400).json({ error: '无效的验收状态' });
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]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (!await notesService.setStatus(id, status)) {
res.status(404).json({ error: '作品不存在' });
return;
}
res.json({ success: true, status });
});
router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
const reason = String(req.body?.reason ?? '').trim();
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
const note = await database.one<{ review_status: string; version_number: number; project_id: number }>('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]);
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
const actor = req.authUser!;
await withTransaction(async (tx) => {
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
await tx.execute("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 audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
res.json({ success: true, status: 'pending' });
});
// DELETE /api/notes/:noteId - 删除笔记
router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response) => {
const id = Number(req.params.noteId);
if (!Number.isFinite(id)) {
res.status(400).json({ error: '无效的笔记 ID' });
return;
}
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
const ok = await notesService.remove(id);
if (!ok) {
res.status(404).json({ error: '笔记不存在' });
return;
}
res.status(204).end();
});
export default router;

85
api/routes/projects.ts Normal file
View File

@@ -0,0 +1,85 @@
import { Router, type Response } from 'express';
import { database } from '../database.js';
import { audit, canWriteProject, hashPassword, requireRole, requireWriter, type AuthRequest } from '../auth.js';
import type { Project, WorkCollection } from '../../shared/types.js';
const router = Router();
const reader = requireRole('platform_admin', 'group_admin', 'operator');
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
function projectSelect(where: string) {
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
p.customer_access_enabled, p.access_expires_at,
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
COALESCE(cc.collection_count, 0) AS collection_count,
COALESCE(wc.work_count, 0) AS work_count
FROM projects p
JOIN operation_groups g ON g.id = p.group_id
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
${where}`;
}
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; }
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
router.get('/', reader, async (req: AuthRequest, res: Response) => {
const platform = req.authUser?.role === 'platform_admin';
const where = platform ? 'WHERE p.status != ?' : 'WHERE p.group_id = ? AND p.status != ?';
const params = platform ? ['archived'] : [req.authUser?.group_id, 'archived'];
res.json((await database.all<ProjectRow>(`${projectSelect(where)} ORDER BY p.id DESC`, params)).map(projectJson));
});
router.post('/', requireWriter, async (req: AuthRequest, res: Response) => {
const name=String(req.body?.name??'').trim(); const slug=String(req.body?.slug??'').trim().toLowerCase(); const description=String(req.body?.client_description??'').trim();
if(!name||!/^[a-z0-9-]+$/.test(slug)){res.status(400).json({error:'请填写项目名称,项目标识仅支持小写字母、数字和连字符'});return}
if(req.apiKey&&req.apiKey.scope!=='platform'){res.status(403).json({error:'项目级 API Key 不能创建项目'});return}
const groupId=req.authUser?.group_id??Number(req.body?.groupId??req.body?.group_id);
if(!await database.one('SELECT id FROM operation_groups WHERE id = ? AND status = ?', [groupId,'active'])){res.status(400).json({error:'请选择有效的运营组'});return}
let id: number;
try {
id=await database.insertId('INSERT INTO projects (name, slug, client_description, group_id) VALUES (?, ?, ?, ?)',[name,slug,description,groupId]);
} catch {
res.status(409).json({error:'项目标识已存在'});return;
}
await audit(req,'project.create','project',id,{name,slug});
res.status(201).json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
router.get('/:projectId', reader, async (req: AuthRequest,res:Response)=>{
const id=Number(req.params.projectId); if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return}
const row=await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]); if(!row){res.status(404).json({error:'项目不存在'});return} res.json(projectJson(row));
});
router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){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}
if(!(await database.execute('UPDATE projects SET name = ?, client_description = ? WHERE id = ?',[name,description,id])).changes){res.status(404).json({error:'项目不存在'});return}
await audit(req,'project.update','project',id,{name});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
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 rows=await database.all<CollectionRow>(`SELECT c.*,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id) AS work_count,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id AND n.review_status='approved') AS approved_count FROM collections c 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)=>{
const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权修改该项目'});return}
const enabled=req.body?.enabled===true;const password=String(req.body?.password??'');const expiresAt=String(req.body?.expires_at??'').trim()||null;
const current=await database.one<{access_password_hash:string}>('SELECT access_password_hash FROM projects WHERE id = ?',[id]);if(!current){res.status(404).json({error:'项目不存在'});return}
if(password&&password.length<6){res.status(400).json({error:'客户访问密码至少 6 位'});return}if(enabled&&!password&&!current.access_password_hash){res.status(400).json({error:'启用客户访问前请设置访问密码'});return}if(expiresAt&&!Number.isFinite(new Date(expiresAt).getTime())){res.status(400).json({error:'到期时间格式无效'});return}
await database.execute('UPDATE projects SET customer_access_enabled = ?, access_password_hash = ?, access_expires_at = ? WHERE id = ?',[enabled,password?hashPassword(password):current.access_password_hash,expiresAt,id]);if(!enabled)await database.execute('DELETE FROM customer_sessions WHERE project_id = ?',[id]);
await audit(req,'project.customer_access_update','project',id,{enabled,passwordReset:Boolean(password),expiresAt});res.json(projectJson((await database.one<ProjectRow>(projectSelect('WHERE p.id = ?'),[id]))!));
});
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}
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:'同一项目内作品交付集名称不能重复'})}
});
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}
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.*,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id) AS work_count,(SELECT COUNT(*) FROM notes n WHERE n.collection_id=c.id AND n.review_status='approved') AS approved_count FROM collections c WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})}
});
export default router;

32
api/routes/review.ts Normal file
View File

@@ -0,0 +1,32 @@
import { Router, type Response } from 'express';
import { database, withTransaction } from '../database.js';
import { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js';
import { verifyPassword } from '../auth.js';
import { notesService } from '../services/notesService.js';
import { annotationsRepository } from '../repositories/annotationsRepository.js';
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
const router=Router();router.use(optionalCustomer);
type ProjectAccess={id:number;name:string;slug:string;client_description:string;status:string;customer_access_enabled:boolean|number;access_password_hash:string;access_expires_at:string|Date|null};
const projectBySlug=(slug:string)=>database.one<ProjectAccess>(`SELECT id,name,slug,client_description,status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?`,[slug]);
const expired=(value:string|Date|null)=>Boolean(value&&new Date(value).getTime()<=Date.now());
router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}res.json({project_name:project.name,client_description:project.client_description,enabled:Boolean(project.customer_access_enabled),expired:expired(project.access_expires_at),authenticated:Boolean(req.customer?.project_id===Number(project.id)),reviewer_name:req.customer?.project_id===Number(project.id)?req.customer.reviewer_name:null})});
router.post('/:slug/login',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);const reviewerName=String(req.body?.reviewer_name??'').trim();const password=String(req.body?.password??'');if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}if(!project.customer_access_enabled){res.status(403).json({error:'该项目暂未开放客户访问'});return}if(expired(project.access_expires_at)){res.status(403).json({error:'项目访问链接已到期'});return}if(reviewerName.length<2||reviewerName.length>30){res.status(400).json({error:'请填写 230 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})});
router.get('/:slug/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/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/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:'反馈内容须为 12000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 11000 个字符'});return}const belongs=await database.one('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/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:'批注内容须为 11000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string}>(`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})});
export default router;

16
api/routes/storage.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Router, type Response } from 'express';
import { audit, requireRole, type AuthRequest } from '../auth.js';
import { encryptSecret } from '../configCrypto.js';
import { database, withTransaction } from '../database.js';
import { testStorageConfig, type StorageConfigRecord } from '../storage.js';
const router=Router();
type PublicRow=Record<string,unknown>&{id:number|string;has_credentials:boolean|number};
async function publicRows(){return(await database.all<PublicRow>(`SELECT s.id,s.provider,s.region,s.bucket,s.public_base_url,s.cdn_domain,s.path_prefix,s.status,s.test_status,s.test_message,s.last_tested_at,u.display_name AS created_by_name,s.created_at,s.activated_at,1 AS has_credentials FROM storage_configs s JOIN users u ON u.id=s.created_by ORDER BY CASE s.status WHEN 'active' THEN 0 WHEN 'draft' THEN 1 ELSE 2 END,s.id DESC`)).map((item)=>({...item,id:Number(item.id),has_credentials:Boolean(item.has_credentials)}))}
function validUrl(value:string){if(!value)return true;try{return['http:','https:'].includes(new URL(value).protocol)}catch{return false}}
router.get('/',requireRole('platform_admin'),async(_req,res)=>res.json(await publicRows()));
router.post('/',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const region=String(req.body?.region??'').trim().toLowerCase();const bucket=String(req.body?.bucket??'').trim().toLowerCase();const publicBaseUrl=String(req.body?.public_base_url??'').trim();const cdnDomain=String(req.body?.cdn_domain??'').trim();const pathPrefix=String(req.body?.path_prefix??'delivery-desk').trim().replace(/^\/+|\/+$/g,'');const secretId=String(req.body?.secret_id??'').trim();const secretKey=String(req.body?.secret_key??'').trim();if(!/^[a-z0-9-]+$/.test(region)){res.status(400).json({error:'COS 地域格式不正确,例如 ap-guangzhou'});return}if(!/^[a-z0-9][a-z0-9-]+-\d+$/.test(bucket)){res.status(400).json({error:'存储桶名称需要包含 APPID'});return}if(!secretId||!secretKey){res.status(400).json({error:'SecretId 和 SecretKey 均为必填项'});return}if(!validUrl(publicBaseUrl)||!validUrl(cdnDomain)){res.status(400).json({error:'访问域名必须是有效的 HTTP 或 HTTPS 地址'});return}if(pathPrefix.includes('..')||pathPrefix.startsWith('/')){res.status(400).json({error:'文件路径前缀格式不正确'});return}const id=await database.insertId('INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,created_by) VALUES (?,?,?,?,?,?,?,?)',[region,bucket,publicBaseUrl,cdnDomain,pathPrefix,encryptSecret(secretId),encryptSecret(secretKey),req.authUser?.id]);await audit(req,'storage_config.create','storage_config',id,{region,bucket,pathPrefix});res.status(201).json((await publicRows()).find((item)=>item.id===id))});
router.post('/:configId/test',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<StorageConfigRecord>('SELECT id,region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted FROM storage_configs WHERE id=? AND status!=?',[id,'archived']);if(!config){res.status(404).json({error:'存储配置不存在'});return}try{const message=await testStorageConfig(config);await database.execute("UPDATE storage_configs SET test_status='passed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_passed','storage_config',id,{bucket:config.bucket});res.json({success:true,test_status:'passed',test_message:message})}catch(error){const message=error instanceof Error?error.message:'COS 连接测试失败';await database.execute("UPDATE storage_configs SET test_status='failed',test_message=?,last_tested_at=? WHERE id=?",[message,new Date().toISOString(),id]);await audit(req,'storage_config.test_failed','storage_config',id,{bucket:config.bucket,message});res.status(400).json({error:message,test_status:'failed'})}});
router.post('/:configId/activate',requireRole('platform_admin'),async(req:AuthRequest,res:Response)=>{const id=Number(req.params.configId);const config=await database.one<{id:number;bucket:string;test_status:string}>('SELECT id,bucket,test_status FROM storage_configs WHERE id=? AND status=?',[id,'draft']);if(!config){res.status(404).json({error:'待启用的存储配置不存在'});return}if(config.test_status!=='passed'){res.status(409).json({error:'连接测试通过后才能启用该配置'});return}await withTransaction(async(tx)=>{await tx.execute("UPDATE storage_configs SET status='archived' WHERE status='active'");await tx.execute("UPDATE storage_configs SET status='active',activated_at=? WHERE id=?",[new Date().toISOString(),id])});await audit(req,'storage_config.activate','storage_config',id,{bucket:config.bucket});res.json({success:true})});
export default router;

37
api/server.ts Normal file
View File

@@ -0,0 +1,37 @@
/**
* local server entry file, for local development
*/
import app from './app.js';
import { closeDatabase } from './database.js';
/**
* start server with port
*/
const PORT = process.env.PORT || 3001;
const server = app.listen(PORT, () => {
console.log(`Server ready on port ${PORT}`);
});
/**
* close server
*/
process.on('SIGTERM', () => {
console.log('SIGTERM signal received');
server.close(async () => {
await closeDatabase();
console.log('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('SIGINT signal received');
server.close(async () => {
await closeDatabase();
console.log('Server closed');
process.exit(0);
});
});
export default app;

View File

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

90
api/storage.ts Normal file
View File

@@ -0,0 +1,90 @@
import COS from 'cos-nodejs-sdk-v5';
import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';
import { database } from './database.js';
import { decryptSecret } from './configCrypto.js';
export interface StorageConfigRecord {
id: number;
region: string;
bucket: string;
public_base_url: string;
cdn_domain: string;
path_prefix: string;
secret_id_encrypted: string;
secret_key_encrypted: string;
}
export interface StoredUpload {
url: string;
storageProvider: 'local' | 'tencent_cos';
storageKey: string;
}
export async function getActiveStorageConfig(): Promise<StorageConfigRecord | undefined> {
return database.one<StorageConfigRecord>(`
SELECT id, region, bucket, public_base_url, cdn_domain, path_prefix,
secret_id_encrypted, secret_key_encrypted
FROM storage_configs WHERE status = 'active' ORDER BY id DESC LIMIT 1
`);
}
function createClient(config: StorageConfigRecord) {
return new COS({
SecretId: decryptSecret(config.secret_id_encrypted),
SecretKey: decryptSecret(config.secret_key_encrypted),
});
}
function cleanPrefix(value: string): string {
return value.trim().replace(/^\/+|\/+$/g, '').replace(/\/{2,}/g, '/');
}
function objectUrl(config: StorageConfigRecord, key: string): string {
const base = (config.cdn_domain || config.public_base_url || `https://${config.bucket}.cos.${config.region}.myqcloud.com`).replace(/\/+$/, '');
return `${base}/${key.split('/').map(encodeURIComponent).join('/')}`;
}
function safeError(error: unknown): string {
if (!error || typeof error !== 'object') return 'COS 连接失败';
const item = error as { code?: string; statusCode?: number; message?: string };
return [item.code, item.statusCode, item.message].filter(Boolean).join(' · ').slice(0, 300) || 'COS 连接失败';
}
export async function testStorageConfig(config: StorageConfigRecord): Promise<string> {
const cos = createClient(config);
const prefix = cleanPrefix(config.path_prefix);
const key = `${prefix ? `${prefix}/` : ''}.delivery-desk-check/${randomUUID()}.txt`;
try {
await cos.putObject({ Bucket: config.bucket, Region: config.region, Key: key, Body: Buffer.from('delivery-desk storage check'), ContentType: 'text/plain' });
await cos.getObject({ Bucket: config.bucket, Region: config.region, Key: key });
await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key });
return '上传、读取和删除测试均已通过';
} catch (error) {
try { await cos.deleteObject({ Bucket: config.bucket, Region: config.region, Key: key }); } catch { /* best-effort cleanup */ }
throw new Error(safeError(error));
}
}
export async function storeUploadedFile(file: { filename: string; originalname?: string; mimetype?: string; path: string }): Promise<StoredUpload> {
const config = await getActiveStorageConfig();
if (!config) return { url: file.filename, storageProvider: 'local', storageKey: file.filename };
const prefix = cleanPrefix(config.path_prefix);
const now = new Date();
const extension = path.extname(file.originalname || file.filename).toLowerCase() || '.jpg';
const month = `${now.getUTCFullYear()}/${String(now.getUTCMonth() + 1).padStart(2, '0')}`;
const key = `${prefix ? `${prefix}/` : ''}originals/${month}/${randomUUID()}${extension}`;
const cos = createClient(config);
await cos.putObject({
Bucket: config.bucket,
Region: config.region,
Key: key,
Body: fs.createReadStream(file.path),
ContentLength: fs.statSync(file.path).size,
ContentType: file.mimetype || 'application/octet-stream',
});
fs.unlinkSync(file.path);
return { url: objectUrl(config, key), storageProvider: 'tencent_cos', storageKey: key };
}

51
api/upload.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* multer 文件上传配置
*/
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const uploadsDir = path.resolve(__dirname, '..', 'uploads');
if (!fs.existsSync(uploadsDir)) {
fs.mkdirSync(uploadsDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: (_req, _file, cb) => {
cb(null, uploadsDir);
},
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase() || '.jpg';
const unique = `${Date.now()}_${Math.round(Math.random() * 1e6)}${ext}`;
cb(null, unique);
},
});
const allowedMime = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
]);
export const upload = multer({
storage,
limits: {
fileSize: 20 * 1024 * 1024, // 20MB / 单图
files: 30, // 最多 30 张
},
fileFilter: (_req, file, cb) => {
if (allowedMime.has(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`不支持的文件类型: ${file.mimetype}`));
}
},
});
export const UPLOADS_DIR = uploadsDir;

40
compose.yaml Normal file
View File

@@ -0,0 +1,40 @@
services:
app:
build: .
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
environment:
NODE_ENV: production
PORT: 3001
DATABASE_URL: postgresql://delivery_desk:${POSTGRES_PASSWORD}@postgres:5432/delivery_desk
PGSSL: disable
COS_CONFIG_ENCRYPTION_KEY: ${COS_CONFIG_ENCRYPTION_KEY:?COS_CONFIG_ENCRYPTION_KEY is required}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:?INITIAL_ADMIN_PASSWORD is required}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_DISPLAY_NAME: ${INITIAL_ADMIN_DISPLAY_NAME:-平台管理员}
ports:
- "3001:3001"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3001/api/health').then(r=>{if(!r.ok)process.exit(1)})"]
interval: 10s
timeout: 5s
retries: 10
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: delivery_desk
POSTGRES_USER: delivery_desk
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
- delivery_desk_postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U delivery_desk -d delivery_desk"]
interval: 5s
timeout: 3s
retries: 10
volumes:
delivery_desk_postgres:

203
db/postgres/schema.sql Normal file
View File

@@ -0,0 +1,203 @@
BEGIN;
CREATE TABLE IF NOT EXISTS operation_groups (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT REFERENCES operation_groups(id) ON DELETE RESTRICT,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('platform_admin', 'group_admin', 'operator')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS one_group_admin_per_group ON users(group_id) WHERE role = 'group_admin';
CREATE TABLE IF NOT EXISTS projects (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT NOT NULL REFERENCES operation_groups(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')),
access_password_hash TEXT NOT NULL DEFAULT '',
customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE,
access_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS collections (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
client_description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
CREATE TABLE IF NOT EXISTS notes (
id BIGSERIAL PRIMARY KEY,
collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')),
version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS images (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
url TEXT NOT NULL,
width INTEGER NOT NULL DEFAULT 0,
height INTEGER NOT NULL DEFAULT 0,
order_index INTEGER NOT NULL DEFAULT 0,
storage_provider TEXT NOT NULL DEFAULT 'local' CHECK (storage_provider IN ('local', 'tencent_cos')),
storage_key TEXT NOT NULL DEFAULT '',
version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0)
);
CREATE TABLE IF NOT EXISTS annotations (
id BIGSERIAL PRIMARY KEY,
image_id BIGINT NOT NULL REFERENCES images(id) ON DELETE CASCADE,
x DOUBLE PRECISION NOT NULL CHECK (x BETWEEN 0 AND 1),
y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1),
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS work_comments (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS text_annotations (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
target TEXT NOT NULL CHECK (target IN ('title', 'description')),
content TEXT NOT NULL,
author_name TEXT NOT NULL DEFAULT '客户',
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number);
CREATE TABLE IF NOT EXISTS work_versions (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL CHECK (version_number > 0),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')),
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (note_id, version_number)
);
CREATE TABLE IF NOT EXISTS review_events (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
event_type TEXT NOT NULL CHECK (event_type IN ('submitted', 'approved', 'changes_requested', 'reopened')),
from_status TEXT,
to_status TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
actor_name TEXT NOT NULL,
actor_role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS customer_sessions (
id BIGSERIAL PRIMARY KEY,
project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
reviewer_name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT REFERENCES operation_groups(id) ON DELETE SET NULL,
user_id BIGINT REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
entity_type TEXT NOT NULL,
entity_id BIGINT,
detail TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS api_keys (
id BIGSERIAL PRIMARY KEY,
group_id BIGINT REFERENCES operation_groups(id) ON DELETE RESTRICT,
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_prefix TEXT NOT NULL UNIQUE,
key_hash TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL CHECK (scope IN ('platform', 'project')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS storage_configs (
id BIGSERIAL PRIMARY KEY,
provider TEXT NOT NULL DEFAULT 'tencent_cos',
region TEXT NOT NULL,
bucket TEXT NOT NULL,
public_base_url TEXT NOT NULL DEFAULT '',
cdn_domain TEXT NOT NULL DEFAULT '',
path_prefix TEXT NOT NULL DEFAULT 'delivery-desk',
secret_id_encrypted TEXT NOT NULL,
secret_key_encrypted TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
test_status TEXT NOT NULL DEFAULT 'untested' CHECK (test_status IN ('untested', 'passed', 'failed')),
test_message TEXT NOT NULL DEFAULT '',
last_tested_at TIMESTAMPTZ,
created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
activated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config ON storage_configs ((status)) WHERE status = 'active';
CREATE INDEX IF NOT EXISTS collections_project_id_idx ON collections(project_id);
CREATE INDEX IF NOT EXISTS notes_collection_id_idx ON notes(collection_id);
CREATE INDEX IF NOT EXISTS images_note_version_idx ON images(note_id, version_number, order_index);
CREATE INDEX IF NOT EXISTS annotations_image_id_idx ON annotations(image_id);
CREATE INDEX IF NOT EXISTS comments_note_id_idx ON work_comments(note_id);
CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions(project_id);
CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number);
CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id);
COMMIT;

81
docs/architecture.md Normal file
View File

@@ -0,0 +1,81 @@
# 架构与数据模型
## 系统边界
Delivery Desk 是单体 Web 应用React 前端调用 Express API前后端共享 `shared/types.ts` 类型。开发环境使用 SQLite 和本地上传目录;正式环境使用 PostgreSQL 和腾讯云 COS。
```mermaid
flowchart LR
browser["浏览器"] --> app["Express / React 应用"]
app --> database["SQLite 或 PostgreSQL"]
app --> local["本地 uploads开发"]
app --> cos["腾讯云 COS正式"]
customer["客户验收链接"] --> app
client["外部 API 客户端"] --> app
```
## 业务层级
```text
运营组
└── 项目
└── 作品交付集
└── 作品
└── 版本
```
- 一个运营组只能有一位组管理员,可以有多位光影叙事。
- 平台管理员可以有多位,不属于固定运营组。
- 普通工作台账号只能读写所属运营组的数据;平台管理员可跨组管理。
- 客户会话只绑定一个项目,不能跨项目浏览。
- 平台级 API Key 可创建项目;项目级 API Key 只能操作指定项目。
## 运行结构
- `src/`React 页面、组件、状态和 API 客户端。
- `api/routes/`HTTP 路由与输入校验。
- `api/services/`:作品、存储等业务编排。
- `api/repositories/`:查询封装。
- `api/database.ts`SQLite/PostgreSQL 统一查询接口和事务。
- `api/db.ts`SQLite 初始化及增量迁移。
- `db/postgres/schema.sql`PostgreSQL 当前完整 schema。
- `shared/types.ts`:前后端共享领域类型。
`DATABASE_URL` 存在时使用 PostgreSQL否则使用 SQLite。两套数据库必须保持相同业务约束涉及表或字段的修改必须同时更新 `api/db.ts``db/postgres/schema.sql` 及迁移验证脚本。
## 主要数据表
| 表 | 用途 |
|---|---|
| `operation_groups` | 运营组及启停状态 |
| `users` / `sessions` | 工作台账号、角色和登录会话 |
| `customer_sessions` | 客户项目级验收会话 |
| `projects` | 项目、客户访问密码和访问期限 |
| `collections` | 项目下的作品交付集 |
| `notes` | 作品当前状态和当前版本 |
| `work_versions` | 各版本标题、正文、标签和状态快照 |
| `images` | 版本图片、顺序、存储提供方和对象 Key |
| `annotations` | 图片坐标批注 |
| `text_annotations` | 标题或正文的版本级批注 |
| `work_comments` | 作品总体反馈与回复 |
| `review_events` | 提交、修改、通过、重新打开等验收记录 |
| `api_keys` | 平台级或项目级 API Key 的哈希与状态 |
| `storage_configs` | 加密后的 COS 配置及启用状态 |
| `audit_logs` | 管理和业务操作审计 |
## 存储流程
平台管理员在管理页新增 COS 配置。SecretId 和 SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密后写入数据库,读取配置的接口不会返回明文。
启用配置前会在目标桶的 `.delivery-desk-check/` 路径依次上传、读取并删除一个临时对象。启用后,新上传文件写入:
```text
<path-prefix>/originals/YYYY/MM/<uuid>.<ext>
```
未启用 COS 时,上传文件保存在本地 `uploads/`。图片 URL 按产品约定为公开随机地址,不提供对象级访问鉴权。
## 验收状态
作品状态为 `draft``pending``changes_requested``approved`。客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。

37
docs/handoff.md Normal file
View File

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

94
docs/integration-guide.md Normal file
View File

@@ -0,0 +1,94 @@
# API 接入指南
## 认证方式
工作台网页使用 HttpOnly Cookie 会话。外部客户端使用:
```http
Authorization: Bearer dd_live_xxx
```
API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。平台管理员创建平台级 Key组管理员创建本组项目级 Key。失效或越权请求会返回 `401``403`
## 主要路由
| 路由组 | 用途 |
|---|---|
| `/api/auth/*` | 登录、退出、当前账号、修改密码 |
| `/api/management/groups` | 运营组创建、改名、启停和管理员更换 |
| `/api/management/users` | 账号创建、改名、启停和重置密码 |
| `/api/management/api-keys` | API Key 创建、查询和吊销 |
| `/api/management/audit-logs` | 审计日志查询 |
| `/api/management/storage-configs` | COS 配置、连接测试和启用 |
| `/api/projects` | 项目创建、查询和编辑 |
| `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 |
| `/api/notes` | 作品查询与 multipart 上传 |
| `/api/notes/:noteId/versions` | 上传作品新版本 |
| `/api/notes/:noteId/status` | 草稿与待验收状态切换 |
| `/api/notes/:noteId/text-annotations` | 标题/正文批注 |
| `/api/images/:imageId/annotations` | 图片坐标批注 |
| `/api/review/:slug/*` | 客户登录、浏览、反馈与验收 |
| `/api/health` | 数据库就绪检查 |
## 创建项目
平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。
```bash
curl -X POST http://localhost:3001/api/projects \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"7 月内容计划","slug":"july-content","groupId":1,"client_description":"客户可见说明"}'
```
`slug` 仅支持小写字母、数字和连字符,并作为客户验收链接的一部分。
## 创建作品交付集
```bash
curl -X POST http://localhost:3001/api/projects/1/collections \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}'
```
## 上传作品
作品上传使用 `multipart/form-data`,至少一张、最多 30 张图片,单图最大 20 MB。图片数组顺序就是初始展示顺序第一张为封面。
```bash
curl -X POST http://localhost:3001/api/notes \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-F "collectionId=1" \
-F "title=作品标题" \
-F "description=正文内容" \
-F "tags=用户填写的标签原文" \
-F "images=@./01.jpg" \
-F "images=@./02.jpg"
```
当前接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按一段原文保存和展示不会自动添加 `#` 或拆分为标签库。
## 创建新版本
```bash
curl -X POST http://localhost:3001/api/notes/12/versions \
-H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \
-F "title=修改后的标题" \
-F "description=修改后的正文" \
-F "tags=修改后的标签原文" \
-F "images=@./v2-01.jpg"
```
批注绑定作品版本或具体图片,不会因新版本覆盖历史验收证据。
## 错误响应
错误统一以 JSON 返回:
```json
{ "error": "错误说明" }
```
常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 资源不存在、`409` 唯一性或状态冲突、`500` 服务端错误。

70
docs/operator-runbook.md Normal file
View File

@@ -0,0 +1,70 @@
# 部署与运维手册
## 环境变量
| 变量 | 必需 | 说明 |
|---|---|---|
| `NODE_ENV` | 是 | 正式环境设为 `production` |
| `PORT` | 否 | API 与生产静态站端口,默认 `3001` |
| `DATABASE_URL` | 正式环境 | PostgreSQL 连接串;缺省时使用 SQLite |
| `PGSSL` | 否 | 内网 PostgreSQL 可设为 `disable` |
| `PG_POOL_MAX` | 否 | 连接池上限,默认 `10` |
| `CORS_ORIGIN` | 公网分离部署 | 允许来源,多个值以逗号分隔 |
| `POSTGRES_PASSWORD` | Docker | `compose.yaml` 使用的数据库密码 |
| `COS_CONFIG_ENCRYPTION_KEY` | 正式环境 | 至少 32 字符的稳定随机密钥 |
| `INITIAL_ADMIN_USERNAME` | 首次初始化 | 平台管理员账号,默认 `admin` |
| `INITIAL_ADMIN_DISPLAY_NAME` | 首次初始化 | 平台管理员显示名 |
| `INITIAL_ADMIN_PASSWORD` | 首次初始化 | 空 PostgreSQL 必须提供的临时密码 |
真实环境变量只放在部署平台或未提交的 `.env` 中。
## 部署
```bash
cp .env.example .env
# 编辑 .env 后:
docker compose up -d --build
```
在 HTTPS 反向代理后公开应用。不要直接提交 TLS 私钥、数据库文件、`.env``data/``uploads/`
## 冒烟检查
```bash
curl http://127.0.0.1:3001/api/health
docker compose ps
docker compose logs --tail=100 app
```
健康接口应返回 `success: true`,且正式环境的 `database` 应为 `postgres`
## COS 配置
1. 使用平台管理员进入“平台管理 → 腾讯云 COS”。
2. 填写地域、带 APPID 的存储桶名、公开访问域名、可选 CDN 域名、路径前缀及密钥。
3. 保存后执行连接测试。
4. 测试通过后启用配置。
连接测试会真实执行一次上传、读取和删除,因此密钥至少需要目标前缀的这三项权限。测试对象会尽力清理;请求中断时可检查 `<path-prefix>/.delivery-desk-check/` 是否残留临时文件。
COS 使用公开 URL。必须关闭桶列表功能并使用不可枚举对象名拿到 URL 的人可以直接访问文件。
## 数据备份与恢复
- PostgreSQL 使用托管备份或定期 `pg_dump`,恢复流程需在预发布环境演练。
- COS 开启版本控制或生命周期策略前先评估成本。
- 本地 SQLite 的 `data/` 只用于开发,不作为正式备份方案。
- `COS_CONFIG_ENCRYPTION_KEY` 必须与数据库备份一同安全托管;遗失后无法解密已保存的 COS 凭证。
## 发布前检查
```bash
pnpm install --frozen-lockfile
pnpm check
pnpm lint
pnpm build
pnpm test:postgres-runtime
```
正式切换前还应验证管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。

28
eslint.config.js Normal file
View File

@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

30
index.html Normal file
View File

@@ -0,0 +1,30 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>交付工作台 · Delivery Desk</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600;9..144,700;9..144,800&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
>
<script type="module">
if (import.meta.hot?.on) {
import.meta.hot.on('vite:error', (error) => {
if (error.err) {
console.error(
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
)
}
})
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

10
nodemon.json Normal file
View File

@@ -0,0 +1,10 @@
{
"watch": ["api"],
"ext": "ts,mts,js,json",
"ignore": ["api/dist/*"],
"exec": "tsx api/server.ts",
"env": {
"NODE_ENV": "development"
},
"delay": 1000
}

66
package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "delivery-desk",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"client:dev": "vite --host 0.0.0.0",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"check": "tsc --noEmit",
"server:dev": "nodemon",
"server:prod": "tsx api/server.ts",
"db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts",
"db:postgres:validate": "tsx scripts/validate-postgres-migration.ts",
"test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts",
"dev": "concurrently \"npm run client:dev\" \"npm run server:dev\""
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"cos-nodejs-sdk-v5": "^3.0.0",
"dotenv": "^17.2.1",
"express": "^4.21.2",
"express-async-errors": "^3.1.1",
"lucide-react": "^0.511.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.22.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.3.0",
"sharp": "^0.33.5",
"tailwind-merge": "^3.0.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.30",
"@types/pg": "^8.20.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vercel/node": "^5.3.6",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"babel-plugin-react-dev-locator": "^1.0.0",
"concurrently": "^9.2.0",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"nodemon": "^3.1.10",
"pg-mem": "^3.0.14",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"tsx": "^4.20.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4"
}
}

6079
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
allowBuilds:
better-sqlite3: true
esbuild: true
sharp: true

10
postcss.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** WARNING: DON'T EDIT THIS FILE */
/** WARNING: DON'T EDIT THIS FILE */
/** WARNING: DON'T EDIT THIS FILE */
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

4
public/favicon.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" fill="#0A0B0D"/>
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,56 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import pg from 'pg';
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('缺少 DATABASE_URL迁移未执行');
const replace = process.argv.includes('--replace');
const sqlitePath = path.resolve(process.env.SQLITE_PATH || 'data/app.db');
const schemaPath = path.resolve('db/postgres/schema.sql');
if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${sqlitePath}`);
const tables = [
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
users: new Set(['must_change_password']),
projects: new Set(['customer_access_enabled']),
};
const sqlite = new Database(sqlitePath, { readonly: true });
const client = new pg.Client({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined });
try {
await client.connect();
await client.query(fs.readFileSync(schemaPath, 'utf8'));
const existing = Number((await client.query('SELECT COUNT(*)::int AS count FROM operation_groups')).rows[0].count);
if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace');
await client.query('BEGIN');
if (replace) await client.query(`TRUNCATE ${[...tables].reverse().map((table) => `"${table}"`).join(', ')} RESTART IDENTITY CASCADE`);
for (const table of tables) {
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
if (!exists) continue;
const rows = sqlite.prepare(`SELECT * FROM "${table}" ORDER BY id`).all() as Record<string, unknown>[];
for (const row of rows) {
const columns = Object.keys(row);
const values = columns.map((column) => booleanColumns[table]?.has(column) ? Boolean(row[column]) : row[column]);
const names = columns.map((column) => `"${column}"`).join(', ');
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
await client.query(`INSERT INTO "${table}" (${names}) VALUES (${placeholders})`, values);
}
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
}
await client.query('COMMIT');
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
} catch (error) {
await client.query('ROLLBACK').catch(() => undefined);
throw error;
} finally {
sqlite.close();
await client.end().catch(() => undefined);
}

View File

@@ -0,0 +1,100 @@
process.env.NODE_ENV = 'test';
process.env.DATABASE_URL = 'pg-mem://';
process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!';
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
const { default: app } = await import('../api/app.js');
const { closeDatabase } = await import('../api/database.js');
const server = app.listen(0, '127.0.0.1');
await new Promise<void>((resolve) => server.once('listening', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('测试服务启动失败');
const base = `http://127.0.0.1:${address.port}`;
async function request(path: string, init: RequestInit = {}, cookie?: string) {
const response = await fetch(`${base}${path}`, { ...init, headers: { ...(init.headers || {}), ...(cookie ? { Cookie: cookie } : {}) } });
const body = response.status === 204 ? null : await response.json();
return { response, body };
}
function expectStatus(actual: number, expected: number, label: string, body?: unknown) {
if (actual !== expected) throw new Error(`${label}: 期望 ${expected},实际 ${actual},响应 ${JSON.stringify(body)}`);
}
try {
const health = await request('/api/health'); expectStatus(health.response.status, 200, '健康检查');
if ((health.body as { database?: string }).database !== 'postgres') throw new Error('测试未运行在 PostgreSQL 查询层');
const login = await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'AdminTest123!' }) });
expectStatus(login.response.status, 200, '平台管理员登录');
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
const group = await request('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '测试运营组', username: 'test_manager', display_name: '测试管理员', password: 'Manager123!' }) }, adminCookie);
expectStatus(group.response.status, 201, '创建运营组');
const groupId = Number((group.body as { id: number }).id);
const duplicateGroupAdmin = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'second_manager', display_name: '林溪', password: 'Manager123!', role: 'group_admin' }) }, adminCookie);
expectStatus(duplicateGroupAdmin.response.status, 409, '拒绝第二位组管理员', duplicateGroupAdmin.body);
const firstOperator = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'first_operator', display_name: '林溪', password: 'Operator123!', role: 'operator' }) }, adminCookie);
expectStatus(firstOperator.response.status, 201, '创建第一位光影叙事', firstOperator.body);
const secondOperator = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'second_operator', display_name: '周宁', password: 'Operator123!', role: 'operator' }) }, adminCookie);
expectStatus(secondOperator.response.status, 201, '创建第二位光影叙事', secondOperator.body);
const secondPlatformAdmin = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'second_admin', display_name: '平台管理员乙', password: 'AdminTest123!', role: 'platform_admin' }) }, adminCookie);
expectStatus(secondPlatformAdmin.response.status, 201, '创建第二位平台管理员', secondPlatformAdmin.body);
const renameOperator = await request(`/api/management/users/${Number((firstOperator.body as { id: number }).id)}/name`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: '林清溪' }) }, adminCookie);
expectStatus(renameOperator.response.status, 200, '修改光影叙事用户名', renameOperator.body);
const groupAccounts = await request(`/api/management/users?groupId=${groupId}`, {}, adminCookie);
expectStatus(groupAccounts.response.status, 200, '读取组内账号', groupAccounts.body);
const groupUsers = groupAccounts.body as Array<{ display_name: string; role: string }>;
if (groupUsers.filter((item) => item.role === 'group_admin').length !== 1 || groupUsers.filter((item) => item.role === 'operator').length !== 2) throw new Error('运营组账号数量规则未正确执行');
const allAccounts = await request('/api/management/users', {}, adminCookie);
expectStatus(allAccounts.response.status, 200, '读取平台账号', allAccounts.body);
if ((allAccounts.body as Array<{ role: string }>).filter((item) => item.role === 'platform_admin').length !== 2) throw new Error('多个平台管理员未正确创建');
const replaceAdmin = await request(`/api/management/groups/${groupId}/replace-admin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: Number((firstOperator.body as { id: number }).id), previous_action: 'demote' }) }, adminCookie);
expectStatus(replaceAdmin.response.status, 200, '更换组管理员', replaceAdmin.body);
const replacedAccounts = await request(`/api/management/users?groupId=${groupId}`, {}, adminCookie);
const replacedUsers = replacedAccounts.body as Array<{ id:number;role:string;last_login_at:string|null }>;
if (replacedUsers.filter((item)=>item.role==='group_admin').length!==1 || replacedUsers.find((item)=>item.role==='group_admin')?.id!==Number((firstOperator.body as {id:number}).id)) throw new Error('组管理员更换未保持唯一身份');
const groupsAfterReplace = await request('/api/management/groups', {}, adminCookie);
expectStatus(groupsAfterReplace.response.status, 200, '读取运营组统计', groupsAfterReplace.body);
const groupStats=(groupsAfterReplace.body as Array<{id:number;user_count:number;operator_count:number;group_admin_name:string|null}>).find((item)=>item.id===groupId);
if(!groupStats||groupStats.user_count!==3||groupStats.operator_count!==2||!groupStats.group_admin_name)throw new Error('运营组统计不正确');
const renameGroup=await request(`/api/management/groups/${groupId}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'已更名运营组'})},adminCookie);
expectStatus(renameGroup.response.status,200,'修改运营组名称',renameGroup.body);
if((renameGroup.body as {name:string}).name!=='已更名运营组')throw new Error('运营组名称未正确更新');
const newAdminLogin=await request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'first_operator',password:'Operator123!'})});
expectStatus(newAdminLogin.response.status,200,'新组管理员登录',newAdminLogin.body);
const accountsAfterLogin=await request(`/api/management/users?groupId=${groupId}`,{},adminCookie);
if(!(accountsAfterLogin.body as Array<{username:string;last_login_at:string|null}>).find((item)=>item.username==='first_operator')?.last_login_at)throw new Error('最近登录时间未记录');
const adminLogs=await request('/api/management/audit-logs?userId=1',{},adminCookie);
expectStatus(adminLogs.response.status,200,'按账号筛选审计日志',adminLogs.body);
if(!(adminLogs.body as Array<{action:string}>).some((item)=>item.action==='group.admin_replace'))throw new Error('管理员更换审计日志缺失');
const project = await request('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'PostgreSQL 联调项目', slug: 'postgres-runtime-test', client_description: '运行时验证', groupId }) }, adminCookie);
expectStatus(project.response.status, 201, '创建项目', project.body);
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
const projectId = Number((project.body as { id: number }).id);
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
expectStatus(access.response.status, 200, '配置客户访问');
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, '创建作品交付集');
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, '客户登录');
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
expectStatus(reviewProject.response.status, 200, '客户项目读取');
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');
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
process.stdout.write('PostgreSQL 运行时验证通过单组管理员、多光影叙事、多平台管理员、管理员更换、活动时间、审计筛选、项目、客户门禁、作品交付集、API Key\n');
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await closeDatabase();
}

View File

@@ -0,0 +1,46 @@
import fs from 'node:fs';
import path from 'node:path';
import Database from 'better-sqlite3';
import { newDb } from 'pg-mem';
const tables = [
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
'audit_logs', 'api_keys', 'storage_configs',
] as const;
const booleanColumns: Record<string, Set<string>> = {
users: new Set(['must_change_password']), projects: new Set(['customer_access_enabled']),
};
const memory = newDb({ autoCreateForeignKeyIndices: true });
const adapter = memory.adapters.createPg();
const client = new adapter.Client();
const sqlite = new Database(path.resolve('data/app.db'), { readonly: true });
try {
await client.connect();
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
.replace(/^BEGIN;|COMMIT;$/gm, '')
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '');
await client.query(schema);
for (const table of tables) {
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
if (!exists) continue;
const rows = sqlite.prepare(`SELECT * FROM "${table}" ORDER BY id`).all() as Record<string, unknown>[];
for (const row of rows) {
const columns = Object.keys(row);
const values = columns.map((column) => booleanColumns[table]?.has(column) ? Boolean(row[column]) : row[column]);
await client.query(`INSERT INTO "${table}" (${columns.map((column) => `"${column}"`).join(',')}) VALUES (${columns.map((_, index) => `$${index + 1}`).join(',')})`, values);
}
const pgCount = Number((await client.query(`SELECT COUNT(*)::int AS count FROM "${table}"`)).rows[0].count);
if (pgCount !== rows.length) throw new Error(`${table} 行数不一致SQLite=${rows.length}, PostgreSQL=${pgCount}`);
}
const current = (await client.query(`SELECT n.version_number,n.review_status,i.storage_provider FROM notes n JOIN images i ON i.note_id=n.id AND i.version_number=n.version_number WHERE n.id=1 LIMIT 1`)).rows[0];
if (!current || Number(current.version_number) < 1) throw new Error('作品版本关系未正确迁移');
const activeStorage = Number((await client.query("SELECT COUNT(*)::int AS count FROM storage_configs WHERE status='active'")).rows[0].count);
if (activeStorage > 1) throw new Error('活动对象存储配置超过一个');
process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`);
} finally {
sqlite.close();
await client.end();
}

234
shared/types.ts Normal file
View File

@@ -0,0 +1,234 @@
export type ReviewStatus = 'draft' | 'pending' | 'changes_requested' | 'approved';
export type CollectionStatus = 'draft' | 'reviewing' | 'completed' | 'archived';
export type UserRole = 'platform_admin' | 'group_admin' | 'operator';
export interface CurrentUser {
id: number;
group_id: number | null;
group_name: string | null;
username: string;
display_name: string;
role: UserRole;
must_change_password: boolean;
}
export interface OperationGroup {
id: number;
name: string;
status: 'active' | 'disabled';
user_count: number;
active_user_count: number;
operator_count: number;
disabled_user_count: number;
project_count: number;
customer_link_count: number;
group_admin_name: string | null;
group_admin_status: 'active' | 'disabled' | null;
created_at: string;
}
export interface ManagedUser {
id: number;
group_id: number | null;
group_name: string | null;
username: string;
display_name: string;
role: UserRole;
status: 'active' | 'disabled';
must_change_password: boolean;
last_login_at: string | null;
last_operation_at: string | null;
created_at: string;
}
export interface ManagedApiKey {
id: number;
group_id: number | null;
project_id: number | null;
project_name: string | null;
name: string;
key_prefix: string;
scope: 'platform' | 'project';
status: 'active' | 'revoked';
created_by_name: string;
last_used_at: string | null;
created_at: string;
}
export interface StorageConfig {
id: number;
provider: 'tencent_cos';
region: string;
bucket: string;
public_base_url: string;
cdn_domain: string;
path_prefix: string;
status: 'draft' | 'active' | 'archived';
test_status: 'untested' | 'passed' | 'failed';
test_message: string;
last_tested_at: string | null;
created_by_name: string;
created_at: string;
activated_at: string | null;
has_credentials: boolean;
}
export interface AuditLogEntry {
id: number;
group_id: number | null;
group_name: string | null;
user_id: number | null;
user_name: string | null;
action: string;
entity_type: string;
entity_id: number | null;
detail: Record<string, unknown>;
created_at: string;
}
export interface Project {
id: number;
group_id: number;
group_name: string;
name: string;
slug: string;
client_description: string;
status: 'active' | 'closed' | 'archived';
customer_access_enabled: boolean;
access_expires_at: string | null;
has_access_password: boolean;
collection_count: number;
work_count: number;
created_at: string;
}
export interface CustomerAccessState {
project_name: string;
client_description: string;
enabled: boolean;
expired: boolean;
authenticated: boolean;
reviewer_name: string | null;
}
export interface WorkCollection {
id: number;
project_id: number;
name: string;
client_description: string;
status: CollectionStatus;
work_count: number;
approved_count: number;
created_at: string;
}
export interface Note {
id: number;
collection_id: number;
title: string;
description: string;
tags: string[];
review_status: ReviewStatus;
version_number: number;
cover_image: string;
image_count: number;
annotation_count: number;
comment_count: number;
created_at: string;
}
export interface NoteImage {
id: number;
note_id: number;
url: string;
width: number;
height: number;
order_index: number;
storage_provider: 'local' | 'tencent_cos';
storage_key: string;
}
export interface Annotation {
id: number;
image_id: number;
x: number;
y: number;
content: string;
author_name: string;
status: 'open' | 'resolved' | 'confirmed';
created_at: string;
}
export interface WorkComment {
id: number;
note_id: number;
content: string;
author_name: string;
author_role: 'client' | 'operator';
status: 'open' | 'resolved' | 'confirmed';
created_at: string;
}
export interface TextAnnotation {
id: number;
note_id: number;
version_number: number;
target: 'title' | 'description';
content: string;
author_name: string;
status: 'open' | 'resolved' | 'confirmed';
created_at: string;
}
export interface ImageWithAnnotations extends NoteImage {
annotations: Annotation[];
}
export interface NoteDetail extends Note {
images: ImageWithAnnotations[];
text_annotations: TextAnnotation[];
comments: WorkComment[];
project: Pick<Project, 'id' | 'name' | 'slug'>;
collection: Pick<WorkCollection, 'id' | 'name'>;
versions: WorkVersion[];
review_events: ReviewEvent[];
}
export interface WorkVersion {
version_number: number;
title: string;
description: string;
tags: string[];
review_status: ReviewStatus;
created_at: string;
}
export interface ReviewEvent {
id: number;
version_number: number;
event_type: 'submitted' | 'approved' | 'changes_requested' | 'reopened';
from_status: ReviewStatus | null;
to_status: ReviewStatus;
reason: string;
actor_name: string;
actor_role: 'client' | 'operator' | 'group_admin' | 'platform_admin';
created_at: string;
}
export interface CreateAnnotationRequest {
x: number;
y: number;
content: string;
author_name?: string;
}
export interface NoteListQuery {
sort?: 'created_at' | 'annotations';
order?: 'asc' | 'desc';
q?: string;
collectionId?: number;
status?: ReviewStatus;
tag?: string;
projectId?: number;
groupId?: number;
}

48
src/App.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom';
import SiteHeader from '@/components/SiteHeader';
import Dashboard from '@/pages/Dashboard';
import ProjectPage from '@/pages/Project';
import CollectionPage from '@/pages/Collection';
import NoteDetailPage from '@/pages/NoteDetail';
import UploadPage from '@/pages/Upload';
import LoginPage from '@/pages/Login';
import ProtectedRoute from '@/components/ProtectedRoute';
import ChangePasswordPage from '@/pages/ChangePassword';
import ManagementPage from '@/pages/Management';
import CustomerReviewPage from '@/pages/CustomerReview';
import NewVersionPage from '@/pages/NewVersion';
export default function App() {
return (
<BrowserRouter>
<AppContent />
</BrowserRouter>
);
}
function AppContent() {
const location = useLocation();
const customerView = location.pathname.startsWith('/review/');
return (
<div className="min-h-screen bg-[#f7f6f2] text-[#171714]">
{!customerView && <SiteHeader />}
<Routes>
<Route path="/" element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
<Route path="/login" element={<LoginPage />} />
<Route path="/change-password" element={<ProtectedRoute><ChangePasswordPage /></ProtectedRoute>} />
<Route path="/management" element={<ProtectedRoute><ManagementPage /></ProtectedRoute>} />
<Route path="/projects/:projectId" element={<ProtectedRoute><ProjectPage /></ProtectedRoute>} />
<Route path="/projects/:projectId/collections/:collectionId" element={<ProtectedRoute><CollectionPage /></ProtectedRoute>} />
<Route path="/works/:noteId" element={<ProtectedRoute><NoteDetailPage /></ProtectedRoute>} />
<Route path="/notes/:noteId" element={<ProtectedRoute><NoteDetailPage /></ProtectedRoute>} />
<Route path="/review/:slug" element={<CustomerReviewPage />} />
<Route path="/review/:slug/collections/:collectionId" element={<CustomerReviewPage />} />
<Route path="/review/:slug/works/:noteId" element={<CustomerReviewPage />} />
<Route path="/works/:noteId/new-version" element={<ProtectedRoute><NewVersionPage /></ProtectedRoute>} />
<Route path="/projects/:projectId/collections/:collectionId/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
<Route path="/upload" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</div>
);
}

86
src/api/client.ts Normal file
View File

@@ -0,0 +1,86 @@
import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkCollection, WorkComment } from '@shared/types';
export class ApiError extends Error {
constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; }
}
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
let message = `请求失败 (${res.status})`;
try { const data = await res.json(); message = data.error ?? data.message ?? message; } catch { /* noop */ }
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
login: (username: string, password: string) => request<{ success: true }>('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }) }),
logout: () => request<void>('/api/auth/logout', { method: 'POST' }),
me: () => request<CurrentUser>('/api/auth/me'),
changePassword: (currentPassword: string, newPassword: string) => request<{ success: true }>('/api/auth/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) }),
updateCurrentGroup: (name: string) => request<{ id: number; name: string }>('/api/groups/current', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }),
listGroups: () => request<OperationGroup[]>('/api/management/groups'),
createGroup: (data: { name: string; username: string; display_name: string; password: string }) => request<OperationGroup>('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
setGroupStatus: (id: number, status: 'active' | 'disabled') => request<{ success: true; status: string }>(`/api/management/groups/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
updateGroupName: (id: number, name: string) => request<{ id: number; name: string }>(`/api/management/groups/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }),
replaceGroupAdmin: (id: number, user_id: number, previous_action: 'demote' | 'disable') => request<{ success: true; previous_admin: string; next_admin: string }>(`/api/management/groups/${id}/replace-admin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id, previous_action }) }),
listManagedUsers: (groupId?: number) => request<ManagedUser[]>(`/api/management/users${groupId ? `?groupId=${groupId}` : ''}`),
createManagedUser: (data: { group_id?: number; username: string; display_name: string; password: string; role?: UserRole }) => request<ManagedUser>('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateManagedUserName: (id: number, display_name: string) => request<{ success: true; display_name: string }>(`/api/management/users/${id}/name`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name }) }),
setUserStatus: (id: number, status: 'active' | 'disabled') => request<{ success: true; status: string }>(`/api/management/users/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
resetUserPassword: (id: number, password: string) => request<{ success: true }>(`/api/management/users/${id}/reset-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }),
listApiKeys: () => request<ManagedApiKey[]>('/api/management/api-keys'),
createApiKey: (data: { name: string; project_id?: number }) => request<{ token: string; item: ManagedApiKey }>('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
revokeApiKey: (id: number) => request<void>(`/api/management/api-keys/${id}`, { method: 'DELETE' }),
listAuditLogs: (userId?: number) => request<AuditLogEntry[]>(`/api/management/audit-logs${userId ? `?userId=${userId}` : ''}`),
listStorageConfigs: () => request<StorageConfig[]>('/api/management/storage-configs'),
createStorageConfig: (data: { region: string; bucket: string; public_base_url: string; cdn_domain: string; path_prefix: string; secret_id: string; secret_key: string }) => request<StorageConfig>('/api/management/storage-configs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
testStorageConfig: (id: number) => request<{ success: true; test_status: 'passed'; test_message: string }>(`/api/management/storage-configs/${id}/test`, { method: 'POST' }),
activateStorageConfig: (id: number) => request<{ success: true }>(`/api/management/storage-configs/${id}/activate`, { method: 'POST' }),
listProjects: () => request<Project[]>('/api/projects'),
getProject: (id: number) => request<Project>(`/api/projects/${id}`),
createProject: (data: { name: string; slug: string; client_description: string; groupId?: number }) => request<Project>('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateProject: (id: number, data: { name: string; client_description: string }) => request<Project>(`/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateCustomerAccess: (id: number, data: { enabled: boolean; password?: string; expires_at?: string | null }) => request<Project>(`/api/projects/${id}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
listCollections: (projectId: number) => request<WorkCollection[]>(`/api/projects/${projectId}/collections`),
createCollection: (projectId: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
updateCollection: (projectId: number, id: number, data: { name: string; client_description: string }) => request<WorkCollection>(`/api/projects/${projectId}/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
listNotes: (query: NoteListQuery = {}) => {
const params = new URLSearchParams();
Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value)));
return request<Note[]>(`/api/notes?${params}`);
},
getNote: (id: number, version?: number) => request<NoteDetail>(`/api/notes/${id}${version ? `?version=${version}` : ''}`),
createNote: (payload: { collectionId: number; title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('collectionId', String(payload.collectionId));
form.append('title', payload.title);
form.append('description', payload.description);
form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>('/api/notes', { method: 'POST', body: form });
},
createWorkVersion: (noteId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => {
const form = new FormData();
form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(','));
payload.images.forEach((file) => form.append('images', file));
return request<Note>(`/api/notes/${noteId}/versions`, { method: 'POST', body: form });
},
setReviewStatus: (id: number, status: ReviewStatus) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }),
reopenWork: (id: number, reason: string) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }),
addAnnotation: (imageId: number, data: { x: number; y: number; content: string; author_name?: string }) => request<Annotation>(`/api/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addTextAnnotation: (noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/notes/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
deleteAnnotation: (id: number) => request<void>(`/api/annotations/${id}`, { method: 'DELETE' }),
addComment: (noteId: number, data: { content: string; author_name: string; author_role?: 'client' | 'operator' }) => request<WorkComment>(`/api/notes/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
getCustomerAccess: (slug: string) => request<CustomerAccessState>(`/api/review/${slug}/access`),
customerLogin: (slug: string, data: { reviewer_name: string; password: string }) => request<{ success: true; reviewer_name: string }>(`/api/review/${slug}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
getCustomerProject: (slug: string) => request<{ project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string }>(`/api/review/${slug}/project`),
getCustomerCollection: (slug: string, collectionId: number) => request<{ collection: WorkCollection; works: Note[] }>(`/api/review/${slug}/collections/${collectionId}/works`),
getCustomerWork: (slug: string, noteId: number, version?: number) => request<NoteDetail>(`/api/review/${slug}/works/${noteId}${version ? `?version=${version}` : ''}`),
addCustomerComment: (slug: string, noteId: number, content: string) => request<WorkComment>(`/api/review/${slug}/works/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }),
addCustomerAnnotation: (slug: string, imageId: number, data: { x: number; y: number; content: string }) => request<Annotation>(`/api/review/${slug}/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
addCustomerTextAnnotation: (slug: string, noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request<TextAnnotation>(`/api/review/${slug}/works/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }),
submitCustomerDecision: (slug: string, noteId: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision, reason }) }),
};

View File

@@ -0,0 +1,13 @@
import { useRef, useState } from 'react';
import { Check, MessageCircle, X } from 'lucide-react';
import type { Annotation, NoteImage } from '@shared/types';
export default function AnnotatableImage({image,annotations,onAdd}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise<void>}){
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 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 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>)}
{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>
}

View File

@@ -0,0 +1,28 @@
import { useState } from 'react';
import { Check, MessageCircle, Plus, X } from 'lucide-react';
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 }) {
const [adding, setAdding] = useState(false);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [text, setText] = useState('');
const [busy, setBusy] = useState(false);
const selected = annotations.find((annotation) => annotation.id === selectedId);
const submit = async () => {
if (!text.trim() || busy) return;
setBusy(true);
try { await onAdd(text.trim()); setText(''); setAdding(false); }
finally { setBusy(false); }
};
return <div className="relative">
{children}
<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>
{annotations.map((annotation, index) => <button key={annotation.id} type="button" aria-label={`查看${label}批注 ${index + 1}`} onClick={() => { setAdding(false); setSelectedId((current) => current === annotation.id ? null : annotation.id); }} className={`grid h-7 min-w-7 place-items-center rounded-full px-2 text-[10px] font-semibold text-white transition ${selectedId === annotation.id ? 'bg-black' : 'bg-[#ef4b2f] hover:scale-105'}`}>{index + 1}</button>)}
</div>
{selected && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.18em] text-[#ef4b2f]">{label} {annotations.findIndex((annotation) => annotation.id === selected.id) + 1}</p><p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-black/75">{selected.content}</p></div><button aria-label="关闭批注" onClick={() => setSelectedId(null)} className="grid h-7 w-7 flex-none place-items-center rounded-full bg-black/5 text-black/45 hover:bg-black hover:text-white"><X size={13}/></button></div><div className="mt-3 border-t border-black/[.07] pt-3 text-[11px] text-black/40"> · <span className="font-medium text-black/65">{selected.author_name || '未知用户'}</span></div></div>}
{adding && <div className="mt-3 max-w-md rounded-2xl border border-black/10 bg-white p-4 shadow-lg"><div className="mb-3 flex items-center justify-between text-xs font-medium"><span className="flex items-center gap-1.5"><MessageCircle size={13}/>{label}</span><button aria-label="取消添加批注" onClick={() => setAdding(false)}><X size={14}/></button></div><textarea autoFocus rows={3} value={text} onChange={(event) => setText(event.target.value)} className="w-full resize-none rounded-xl border border-black/10 p-3 text-sm outline-none focus:border-[#ef4b2f]" placeholder={`填写针对${label}的修改意见…`}/><button disabled={busy || !text.trim()} onClick={() => void submit()} className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-full bg-black py-2.5 text-xs text-white disabled:opacity-30"><Check size={12}/>{busy ? '正在提交…' : '提交批注'}</button></div>}
</div>;
}

View File

@@ -0,0 +1,117 @@
import { Trash2, MessageCircle } from 'lucide-react';
import type { ImageWithAnnotations } from '@shared/types';
import { cn } from '@/lib/utils';
interface AnnotationPanelProps {
image: ImageWithAnnotations;
highlightedId: number | null;
onHighlight: (id: number | null) => void;
onDelete: (id: number) => void;
}
export default function AnnotationPanel({
image,
highlightedId,
onHighlight,
onDelete,
}: AnnotationPanelProps) {
return (
<aside className="flex flex-col h-full bg-cream border-l border-stone-200">
<div className="px-6 py-5 border-b border-stone-200">
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-stone-500 chapter-prefix">
Annotations
</div>
<div className="mt-2 flex items-baseline justify-between">
<h3 className="font-display text-2xl tracking-tightest text-ink">
</h3>
<span className="font-mono text-xs text-stone-500">
{image.annotations.length}
</span>
</div>
<p className="mt-2 text-xs text-stone-500 leading-relaxed">
</p>
</div>
<div className="flex-1 overflow-y-auto scroll-thin">
{image.annotations.length === 0 ? (
<div className="px-6 py-16 text-center">
<div className="inline-flex items-center justify-center w-12 h-12 border border-stone-300 rounded-full mb-4">
<MessageCircle className="w-5 h-5 text-stone-400" strokeWidth={1.2} />
</div>
<p className="font-display text-lg text-stone-600"></p>
<p className="mt-1 text-xs text-stone-400">
</p>
</div>
) : (
<ul className="divide-y divide-stone-200/70">
{image.annotations.map((ann, i) => (
<li
key={ann.id}
onMouseEnter={() => onHighlight(ann.id)}
onMouseLeave={() => onHighlight(null)}
onClick={() =>
onHighlight(highlightedId === ann.id ? null : ann.id)
}
className={cn(
'group px-6 py-4 cursor-pointer transition-colors',
highlightedId === ann.id
? 'bg-ochre/5'
: 'hover:bg-stone-50',
)}
>
<div className="flex items-start gap-3">
<span
className={cn(
'flex-shrink-0 flex items-center justify-center w-6 h-6 rounded-full border-2 font-mono text-[10px] font-bold transition-colors',
highlightedId === ann.id
? 'bg-ochre border-ochre text-cream'
: 'bg-cream border-ochre/60 text-ochre',
)}
>
{i + 1}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-ink leading-relaxed whitespace-pre-wrap break-words">
{ann.content}
</p>
<div className="mt-1.5 flex items-center gap-3 font-mono text-[9px] uppercase tracking-[0.15em] text-stone-400">
<span>
x: {ann.x.toFixed(3)}, y: {ann.y.toFixed(3)}
</span>
<span>
{new Date(ann.created_at).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(ann.id);
}}
className="flex-shrink-0 text-stone-300 hover:text-ochre transition-colors p-1"
aria-label="删除注释"
>
<Trash2 className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
<div className="px-6 py-4 border-t border-stone-200 font-mono text-[9px] uppercase tracking-[0.2em] text-stone-400 flex items-center justify-between">
<span>Hover to highlight</span>
<span>Click to focus</span>
</div>
</aside>
);
}

View File

@@ -0,0 +1,225 @@
import { useRef, useState, useCallback } from 'react';
import { Plus, X } from 'lucide-react';
import type { ImageWithAnnotations, Annotation } from '@shared/types';
import { cn } from '@/lib/utils';
interface ImageViewerProps {
image: ImageWithAnnotations;
index: number;
total: number;
onPrev: () => void;
onNext: () => void;
onAddAnnotation: (x: number, y: number, content: string) => void;
onHighlightAnnotation: (id: number | null) => void;
highlightedId: number | null;
}
export default function ImageViewer({
image,
index,
total,
onPrev,
onNext,
onAddAnnotation,
onHighlightAnnotation,
highlightedId,
}: ImageViewerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [pending, setPending] = useState<{ x: number; y: number } | null>(null);
const [draft, setDraft] = useState('');
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
// 点击点位/弹窗时不创建新注释
const target = e.target as HTMLElement;
if (target.closest('[data-pin]') || target.closest('[data-popup]')) return;
const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width;
const y = (e.clientY - rect.top) / rect.height;
if (x < 0 || x > 1 || y < 0 || y > 1) return;
setPending({ x, y });
setDraft('');
},
[],
);
const submitDraft = () => {
if (!pending || !draft.trim()) return;
onAddAnnotation(pending.x, pending.y, draft.trim());
setPending(null);
setDraft('');
};
const cancelDraft = () => {
setPending(null);
setDraft('');
};
return (
<div className="relative">
{/* 顶部元信息 */}
<div className="flex items-center justify-between mb-4 font-mono text-[10px] uppercase tracking-[0.25em] text-stone-500">
<span className="chapter-prefix">Plate {String(index + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}</span>
<span>{image.width}×{image.height || '—'}</span>
</div>
{/* 主图区 */}
<div
ref={containerRef}
onClick={handleClick}
className="relative bg-stone-100 overflow-hidden paper-edge-strong cursor-crosshair select-none"
style={{ aspectRatio: image.width && image.height ? `${image.width} / ${image.height}` : '4 / 3' }}
>
<img
src={image.url}
alt=""
className="w-full h-full object-contain pointer-events-none"
draggable={false}
/>
{/* 已有注释点位 */}
{image.annotations.map((ann, i) => (
<AnnotationPin
key={ann.id}
annotation={ann}
number={i + 1}
highlighted={highlightedId === ann.id}
onClick={() =>
onHighlightAnnotation(highlightedId === ann.id ? null : ann.id)
}
/>
))}
{/* 新建注释弹窗 */}
{pending && (
<div
data-popup
className="absolute z-30 -translate-x-1/2 -translate-y-[calc(100%+12px)]"
style={{ left: `${pending.x * 100}%`, top: `${pending.y * 100}%` }}
onClick={(e) => e.stopPropagation()}
>
<div className="bg-cream paper-edge-strong border border-ink/10 w-72 p-4 animate-scale-in">
<div className="flex items-center justify-between mb-3">
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-ochre">
New Annotation
</span>
<button
onClick={cancelDraft}
className="text-stone-400 hover:text-ink"
>
<X className="w-3.5 h-3.5" strokeWidth={1.5} />
</button>
</div>
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submitDraft();
if (e.key === 'Escape') cancelDraft();
}}
placeholder="写下你对这一处的观察…"
rows={3}
className="w-full bg-transparent border border-stone-300 p-2 text-sm text-ink resize-none focus:outline-none focus:border-ochre"
/>
<div className="flex items-center justify-between mt-3">
<span className="font-mono text-[9px] uppercase tracking-wider text-stone-400">
+
</span>
<button
onClick={submitDraft}
disabled={!draft.trim()}
className="inline-flex items-center gap-1.5 bg-ink text-cream px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.2em] disabled:opacity-30 hover:bg-ochre transition-colors"
>
<Plus className="w-3 h-3" strokeWidth={1.5} />
Pin
</button>
</div>
</div>
</div>
)}
</div>
{/* 左右切换 */}
{total > 1 && (
<>
<button
onClick={onPrev}
disabled={index === 0}
className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 lg:-translate-x-6 w-10 h-10 flex items-center justify-center border border-ink/20 bg-cream/80 backdrop-blur hover:bg-ink hover:text-cream disabled:opacity-0 disabled:pointer-events-none transition-all"
aria-label="上一张"
>
<span className="font-mono text-lg"></span>
</button>
<button
onClick={onNext}
disabled={index === total - 1}
className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-2 lg:translate-x-6 w-10 h-10 flex items-center justify-center border border-ink/20 bg-cream/80 backdrop-blur hover:bg-ink hover:text-cream disabled:opacity-0 disabled:pointer-events-none transition-all"
aria-label="下一张"
>
<span className="font-mono text-lg"></span>
</button>
</>
)}
</div>
);
}
interface AnnotationPinProps {
annotation: Annotation;
number: number;
highlighted: boolean;
onClick: () => void;
}
function AnnotationPin({ annotation, number, highlighted, onClick }: AnnotationPinProps) {
return (
<button
data-pin
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={cn(
'absolute z-20 group/pin',
'transition-transform duration-300',
highlighted ? 'z-30 scale-125' : 'hover:scale-110',
)}
style={{
left: `${annotation.x * 100}%`,
top: `${annotation.y * 100}%`,
transform: 'translate(-50%, -50%)',
}}
aria-label={`注释 ${number}`}
>
{/* 脉冲圈 */}
<span
className={cn(
'absolute inset-0 rounded-full animate-pulse-soft',
highlighted ? 'opacity-100' : 'opacity-60',
)}
style={{
background: 'radial-gradient(circle, rgba(184, 65, 46, 0.3) 0%, transparent 70%)',
width: '36px',
height: '36px',
left: '-6px',
top: '-6px',
}}
/>
{/* 编号点位 */}
<span
className={cn(
'relative flex items-center justify-center w-6 h-6 rounded-full border-2 font-mono text-[10px] font-bold transition-colors',
highlighted
? 'bg-ochre border-ochre text-cream'
: 'bg-cream border-ochre text-ochre group-hover/pin:bg-ochre group-hover/pin:text-cream',
)}
style={{ boxShadow: '0 2px 8px rgba(0,0,0,0.25)' }}
>
{number}
</span>
</button>
);
}

View File

@@ -0,0 +1,83 @@
import { Link } from 'react-router-dom';
import { MessageCircle, ImageIcon } from 'lucide-react';
import type { Note } from '@shared/types';
interface NoteCardProps {
note: Note;
index: number;
}
export default function NoteCard({ note, index }: NoteCardProps) {
const date = new Date(note.created_at);
const dateLabel = `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
const num = String(index + 1).padStart(3, '0');
// 给卡片轻微的纵向错落
const offset = index % 3 === 0 ? 'lg:mt-12' : index % 3 === 2 ? 'lg:mt-6' : '';
return (
<Link
to={`/notes/${note.id}`}
className={`group block animate-fade-up ${offset}`}
style={{ animationDelay: `${Math.min(index * 80, 600)}ms` }}
>
<article className="relative">
{/* 编号标签 */}
<div className="flex items-baseline justify-between mb-3 font-mono text-[10px] uppercase tracking-[0.2em] text-stone-500">
<span> {num}</span>
<span>{dateLabel}</span>
</div>
{/* 封面图 */}
<div className="relative overflow-hidden bg-stone-100 aspect-[4/5] paper-edge">
{note.cover_image ? (
<img
src={note.cover_image}
alt={note.title}
loading="lazy"
className="w-full h-full object-cover transition-transform duration-[1.2s] ease-out group-hover:scale-105"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-stone-400">
<ImageIcon className="w-8 h-8" strokeWidth={1} />
</div>
)}
{/* 图片数徽章 */}
{note.image_count > 1 && (
<div className="absolute top-3 right-3 inline-flex items-center gap-1 bg-cream/90 backdrop-blur px-2 py-1 font-mono text-[10px] text-ink">
<ImageIcon className="w-2.5 h-2.5" strokeWidth={1.5} />
{note.image_count}
</div>
)}
{/* 注释数徽章 */}
{note.annotation_count > 0 && (
<div className="absolute bottom-3 left-3 inline-flex items-center gap-1 bg-ochre text-cream px-2 py-1 font-mono text-[10px]">
<MessageCircle className="w-2.5 h-2.5" strokeWidth={1.5} />
{note.annotation_count}
</div>
)}
{/* 悬停遮罩线 */}
<div className="absolute inset-0 border border-ink/0 group-hover:border-ink/30 transition-colors duration-500 pointer-events-none" />
</div>
{/* 标题 */}
<h3 className="mt-4 font-display text-2xl leading-tight text-ink tracking-tightest group-hover:text-ochre transition-colors duration-300">
{note.title}
</h3>
{/* 描述 */}
{note.description && (
<p className="mt-2 text-sm text-stone-600 line-clamp-2 leading-relaxed">
{note.description}
</p>
)}
{/* 底部细线 */}
<div className="mt-4 h-px bg-stone-200 origin-left scale-x-100 group-hover:bg-ochre/40 transition-colors duration-500" />
</article>
</Link>
);
}

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '@/store/useAuthStore';
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { user, initialized, initialize } = useAuthStore();
const location = useLocation();
useEffect(() => { if (!initialized) void initialize(); }, [initialized, initialize]);
if (!initialized) return <div className="grid min-h-[70vh] place-items-center text-sm text-black/35"></div>;
if (!user) return <Navigate to="/login" state={{ from: location.pathname }} replace />;
if (user.must_change_password && location.pathname !== '/change-password') return <Navigate to="/change-password" replace />;
return children;
}

View File

@@ -0,0 +1,26 @@
export default function SiteFooter() {
return (
<footer className="border-t border-stone-200/60 mt-32">
<div className="mx-auto max-w-[1400px] px-6 lg:px-10 py-10">
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div>
<div className="font-display text-3xl text-ink tracking-tightest leading-none">
</div>
<div className="font-mono text-[10px] uppercase tracking-[0.25em] text-stone-500 mt-2">
End of Volume
</div>
</div>
<div className="font-mono text-[11px] text-stone-500 leading-relaxed">
<div> · Annotated Visual Notes</div>
<div className="mt-1">© {new Date().getFullYear()} Atelier Press · All entries archived locally.</div>
</div>
</div>
<div className="mt-8 pt-6 border-t border-stone-200/50 font-mono text-[10px] uppercase tracking-[0.2em] text-stone-400 flex items-center justify-between">
<span>Set in Fraunces &amp; Inter</span>
<span>Bound by light &amp; ink</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,32 @@
import { useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Aperture, LogIn, LogOut, Settings2, ShieldCheck } from 'lucide-react';
import { useAuthStore } from '@/store/useAuthStore';
const roleLabel = {
platform_admin: '平台管理员',
group_admin: '组管理员',
operator: '光影叙事',
} as const;
export default function SiteHeader() {
const { user, initialized, initialize, logout } = useAuthStore();
useEffect(() => { if (!initialized) void initialize(); }, [initialized, initialize]);
return (
<header className="sticky top-0 z-50 border-b border-black/[.08] bg-[#faf9f5]/90 backdrop-blur-xl">
<div className="mx-auto flex h-[72px] max-w-[1500px] items-center justify-between px-5 lg:px-10">
<Link to="/" className="group flex items-center gap-3">
<span className="grid h-10 w-10 place-items-center rounded-full bg-[#181817] text-[#f7c45c] shadow-sm transition-transform duration-500 group-hover:rotate-45"><Aperture size={17} strokeWidth={1.5}/></span>
<div><div className="font-display text-[22px] font-medium leading-none tracking-[-.04em]"></div><div className="mt-1.5 font-mono text-[9px] uppercase tracking-[.24em] text-black/40">Delivery · Review · Proof</div></div>
</Link>
{user ? <div className="flex items-center gap-2">
{(user.role === 'platform_admin' || user.role === 'group_admin') && <Link to="/management" className="grid h-9 w-9 place-items-center rounded-full border border-black/[.09] bg-white text-black/45 transition hover:border-black/25 hover:text-black" title="管理与审计"><Settings2 size={14}/></Link>}
<div className="flex items-center gap-2 rounded-full border border-black/[.09] bg-white px-3 py-2 text-xs text-black/60"><ShieldCheck size={14} className="text-[#b56a2d]"/><span className="hidden max-w-28 truncate lg:inline text-black/40">{user.group_name || '平台工作区'} ·</span><span className="hidden sm:inline">{user.display_name}</span><span className="rounded-full bg-[#f2eee7] px-2 py-0.5 text-[9px] text-black/55">{roleLabel[user.role]}</span></div>
<button onClick={() => void logout()} className="grid h-9 w-9 place-items-center rounded-full border border-black/[.09] bg-white text-black/40 transition hover:border-black/25 hover:text-black" title="退出登录"><LogOut size={14}/></button>
</div> : <Link to="/login" className="inline-flex items-center gap-2 rounded-full border border-black/[.12] bg-white px-4 py-2 text-xs text-black/60 transition hover:border-black hover:bg-black hover:text-white"><LogIn size={13}/></Link>}
</div>
<div className="h-px bg-gradient-to-r from-transparent via-[#b56a2d]/70 to-transparent" />
</header>
);
}

View File

@@ -0,0 +1,11 @@
import type { ReviewStatus } from '@shared/types';
const labels: Record<ReviewStatus, string> = { draft: '草稿', pending: '待验收', changes_requested: '需修改', approved: '已通过' };
const styles: Record<ReviewStatus, string> = {
draft: 'bg-black/5 text-black/55', pending: 'bg-amber-100 text-amber-800',
changes_requested: 'bg-red-100 text-red-700', approved: 'bg-emerald-100 text-emerald-700',
};
export default function StatusBadge({ status }: { status: ReviewStatus }) {
return <span className={`rounded-full px-2.5 py-1 text-[11px] font-medium ${styles[status]}`}>{labels[status]}</span>;
}

View File

@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState } from 'react';
import { CheckCircle2, Cloud, Database, Loader2, LockKeyhole, RadioTower, ShieldCheck } from 'lucide-react';
import type { StorageConfig } from '@shared/types';
import { api, ApiError } from '@/api/client';
const initialForm = {
region: 'ap-guangzhou', bucket: '', public_base_url: '', cdn_domain: '',
path_prefix: 'delivery-desk', secret_id: '', secret_key: '',
};
export default function StorageSettings() {
const [items, setItems] = useState<StorageConfig[]>([]);
const [form, setForm] = useState(initialForm);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<'save' | 'test' | 'activate' | null>(null);
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const active = useMemo(() => items.find((item) => item.status === 'active'), [items]);
const draft = useMemo(() => items.find((item) => item.status === 'draft'), [items]);
const load = async () => {
setLoading(true);
try { setItems(await api.listStorageConfigs()); }
catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
};
useEffect(() => { void load(); }, []);
const save = async () => {
setBusy('save'); setError(''); setNotice('');
try {
await api.createStorageConfig(form);
setForm(initialForm);
setNotice('配置草稿已加密保存,请执行连接测试。');
await load();
} catch (reason) { setError(messageOf(reason)); }
finally { setBusy(null); }
};
const test = async (id: number) => {
setBusy('test'); setError(''); setNotice('');
try {
const result = await api.testStorageConfig(id);
setNotice(result.test_message);
await load();
} catch (reason) { setError(messageOf(reason)); await load(); }
finally { setBusy(null); }
};
const activate = async (id: number) => {
setBusy('activate'); setError(''); setNotice('');
try {
await api.activateStorageConfig(id);
setNotice('新存储桶已启用,后续上传将写入该配置。');
await load();
} catch (reason) { setError(messageOf(reason)); }
finally { setBusy(null); }
};
if (loading) return <div className="grid gap-3 py-8">{[1, 2].map((item) => <div key={item} className="h-28 animate-pulse rounded-2xl bg-black/5"/>)}</div>;
return <section className="py-8">
<div className="mb-7"><p className="font-mono text-[10px] uppercase tracking-[.28em] text-[#d15f37]">Infrastructure / Object Storage</p><h2 className="mt-2 font-display text-4xl tracking-[-.04em]"> COS</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-black/45">稿</p></div>
{error && <div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{notice && <div className="mb-5 flex items-center gap-2 rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800"><CheckCircle2 size={15}/>{notice}</div>}
<div className="grid gap-5 xl:grid-cols-[.85fr_1.15fr]">
<div className="space-y-5">
<ConfigCard title="当前生效" icon={<Cloud size={18}/>} item={active}/>
{draft && <div className="rounded-[24px] border border-[#d15f37]/25 bg-[#fff8f3] p-5"><div className="flex items-start justify-between gap-3"><div><p className="font-mono text-[9px] uppercase tracking-[.24em] text-[#d15f37]">Pending configuration</p><h3 className="mt-2 font-display text-2xl">{draft.bucket}</h3><p className="mt-1 text-xs text-black/40">{draft.region} · {draft.path_prefix || '根目录'}</p></div><Status status={draft.test_status}/></div><div className="mt-5 grid grid-cols-2 gap-3"><button disabled={busy !== null} onClick={() => void test(draft.id)} className="inline-flex items-center justify-center gap-2 rounded-full border border-black/15 bg-white py-3 text-sm disabled:opacity-40">{busy === 'test' ? <Loader2 size={14} className="animate-spin"/> : <RadioTower size={14}/>}</button><button disabled={busy !== null || draft.test_status !== 'passed'} onClick={() => void activate(draft.id)} className="inline-flex items-center justify-center gap-2 rounded-full bg-[#171714] py-3 text-sm text-white disabled:opacity-25">{busy === 'activate' ? <Loader2 size={14} className="animate-spin"/> : <ShieldCheck size={14}/>}</button></div>{draft.test_message && <p className={`mt-3 text-xs leading-5 ${draft.test_status === 'failed' ? 'text-red-600' : 'text-emerald-700'}`}>{draft.test_message}</p>}</div>}
<div className="rounded-[24px] border border-black/[.08] bg-[#171714] p-5 text-white"><LockKeyhole size={18} className="text-[#f2b27e]"/><h3 className="mt-4 font-display text-2xl"></h3><p className="mt-2 text-xs leading-6 text-white/45">SecretId SecretKey 使 AES-256-GCM </p></div>
</div>
<div className="rounded-[28px] border border-black/[.08] bg-white p-5 sm:p-7"><div className="flex items-center gap-3"><div className="grid h-10 w-10 place-items-center rounded-full bg-[#f1eee7]"><Database size={17}/></div><div><h3 className="font-display text-2xl">稿</h3><p className="text-xs text-black/35"></p></div></div><div className="mt-7 grid gap-5 sm:grid-cols-2"><Field label="地域 Region"><input value={form.region} onChange={(e) => setForm({ ...form, region: e.target.value })} placeholder="ap-guangzhou"/></Field><Field label="存储桶 Bucket"><input value={form.bucket} onChange={(e) => setForm({ ...form, bucket: e.target.value })} placeholder="bucket-name-1250000000"/></Field><Field label="SecretId"><input autoComplete="off" value={form.secret_id} onChange={(e) => setForm({ ...form, secret_id: e.target.value })}/></Field><Field label="SecretKey"><input autoComplete="new-password" type="password" value={form.secret_key} onChange={(e) => setForm({ ...form, secret_key: e.target.value })}/></Field><Field label="公共访问域名"><input value={form.public_base_url} onChange={(e) => setForm({ ...form, public_base_url: e.target.value })} placeholder="https://bucket.cos.region.myqcloud.com"/></Field><Field label="CDN 域名(可选)"><input value={form.cdn_domain} onChange={(e) => setForm({ ...form, cdn_domain: e.target.value })} placeholder="https://cdn.example.com"/></Field><div className="sm:col-span-2"><Field label="文件路径前缀"><input value={form.path_prefix} onChange={(e) => setForm({ ...form, path_prefix: e.target.value })} placeholder="delivery-desk"/></Field></div></div><button disabled={busy !== null || !form.region || !form.bucket || !form.secret_id || !form.secret_key} onClick={() => void save()} className="mt-7 inline-flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-3.5 text-sm text-white transition hover:bg-[#d15f37] disabled:opacity-30">{busy === 'save' ? <Loader2 size={15} className="animate-spin"/> : <LockKeyhole size={15}/>}稿</button></div>
</div>
</section>;
}
function ConfigCard({ title, icon, item }: { title: string; icon: React.ReactNode; item?: StorageConfig }) { return <div className="rounded-[24px] border border-black/[.08] bg-white p-5"><div className="flex items-center gap-2 text-black/45">{icon}<span className="font-mono text-[9px] uppercase tracking-[.22em]">{title}</span></div>{item ? <><div className="mt-5 flex items-start justify-between gap-3"><div><h3 className="font-display text-2xl">{item.bucket}</h3><p className="mt-1 text-xs text-black/40">{item.region} · {item.path_prefix || '根目录'}</p></div><span className="rounded-full bg-emerald-50 px-2.5 py-1 text-[9px] text-emerald-700">ACTIVE</span></div><p className="mt-4 break-all text-xs leading-5 text-black/40">{item.cdn_domain || item.public_base_url || '使用 COS 默认访问域名'}</p></> : <div className="py-8 text-center text-sm text-black/30">使 uploads </div>}</div>; }
function Status({ status }: { status: StorageConfig['test_status'] }) { const map = { untested: ['未测试', 'bg-black/5 text-black/40'], passed: ['已通过', 'bg-emerald-100 text-emerald-700'], failed: ['未通过', 'bg-red-100 text-red-700'] } as const; return <span className={`rounded-full px-2.5 py-1 text-[9px] ${map[status][1]}`}>{map[status][0]}</span>; }
function Field({ label, children }: { label: string; children: React.ReactNode }) { return <label className="block text-xs font-medium text-black/55">{label}<div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-[#faf9f5] [&_input]:p-3 [&_input]:outline-none [&_input]:transition [&_input]:focus:border-[#d15f37]">{children}</div></label>; }
function messageOf(reason: unknown) { return reason instanceof ApiError || reason instanceof Error ? reason.message : '操作失败'; }

96
src/index.css Normal file
View File

@@ -0,0 +1,96 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
font-family: 'Noto Sans SC', 'Inter', system-ui, sans-serif;
line-height: 1.55;
font-weight: 400;
color: #1A1A1A;
background-color: #faf9f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body, #root {
min-height: 100%;
}
body {
margin: 0;
background-color: #faf9f5;
/* 纸张噪点纹理叠加 */
background-image:
radial-gradient(circle at 25% 30%, rgba(184, 65, 46, 0.04) 0%, transparent 35%),
radial-gradient(circle at 75% 70%, rgba(92, 107, 90, 0.04) 0%, transparent 40%),
url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0 0.1 0 0 0 0.12 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-attachment: fixed;
}
::selection {
background-color: rgba(184, 65, 46, 0.18);
color: #1A1A1A;
}
}
@layer utilities {
/* 数字编号风格的章节前缀 */
.chapter-prefix::before {
content: '';
display: inline-block;
width: 1.5rem;
height: 1px;
background-color: currentColor;
vertical-align: middle;
margin-right: 0.75rem;
opacity: 0.6;
}
.paper-edge {
box-shadow:
0 1px 0 rgba(26, 26, 26, 0.04),
0 8px 24px -12px rgba(26, 26, 26, 0.12);
}
.paper-edge-strong {
box-shadow:
0 1px 0 rgba(26, 26, 26, 0.06),
0 20px 50px -20px rgba(26, 26, 26, 0.25);
}
/* 自定义滚动条 */
.scroll-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scroll-thin::-webkit-scrollbar-track {
background: transparent;
}
.scroll-thin::-webkit-scrollbar-thumb {
background: rgba(26, 26, 26, 0.15);
border-radius: 0;
}
.scroll-thin::-webkit-scrollbar-thumb:hover {
background: rgba(26, 26, 26, 0.3);
}
}
/* 全局滚动条 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #F0EBE0;
}
::-webkit-scrollbar-thumb {
background: #C9BCA3;
border: 2px solid #F0EBE0;
}
::-webkit-scrollbar-thumb:hover {
background: #A8997A;
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,11 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { KeyRound } from 'lucide-react';
import { api } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
export default function ChangePasswordPage(){
const [current,setCurrent]=useState('');const [next,setNext]=useState('');const [confirm,setConfirm]=useState('');const [error,setError]=useState('');const [busy,setBusy]=useState(false);const initialize=useAuthStore(s=>s.initialize);const nav=useNavigate();
const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(next!==confirm){setError('两次输入的新密码不一致');return}setBusy(true);try{await api.changePassword(current,next);await initialize();nav('/')}catch(err){setError(err instanceof Error?err.message:'修改失败')}finally{setBusy(false)}};
return <main className="grid min-h-[calc(100vh-66px)] place-items-center px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[28px] border border-black/10 bg-white p-7 shadow-xl shadow-black/5"><div className="grid h-11 w-11 place-items-center rounded-full bg-[#ef4b2f] text-white"><KeyRound size={18}/></div><p className="mt-7 font-mono text-[10px] uppercase tracking-[.26em] text-[#ef4b2f]">First sign in</p><h1 className="mt-2 font-display text-4xl"></h1><p className="mt-3 text-sm leading-6 text-black/45"> 8 </p>{[['当前密码',current,setCurrent],['新密码',next,setNext],['确认新密码',confirm,setConfirm]].map(([label,value,setter])=><label key={label as string} className="mt-5 block text-xs text-black/50">{label as string}<input type="password" value={value as string} onChange={e=>(setter as React.Dispatch<React.SetStateAction<string>>)(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 p-3"/></label>)}{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-600">{error}</p>}<button disabled={busy||!current||!next||!confirm} className="mt-6 w-full rounded-full bg-black py-3.5 text-sm text-white disabled:opacity-30">{busy?'正在保存…':'保存并进入工作台'}</button></form></main>
}

21
src/pages/Collection.tsx Normal file
View File

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

View File

@@ -0,0 +1,81 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, Check, KeyRound, MessageSquareText, Send } from 'lucide-react';
import type { CustomerAccessState, Note, NoteDetail, Project, WorkCollection } from '@shared/types';
import { api, ApiError } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import StatusBadge from '@/components/StatusBadge';
type ProjectPayload = { project: Pick<Project, 'id' | 'name' | 'slug' | 'client_description' | 'status'>; collections: WorkCollection[]; reviewer_name: string };
export default function CustomerReviewPage() {
const { slug = '', collectionId, noteId } = useParams();
const [search] = useSearchParams();
const selectedVersion = search.get('version');
const [access, setAccess] = useState<CustomerAccessState | null>(null);
const [projectData, setProjectData] = useState<ProjectPayload | null>(null);
const [collectionData, setCollectionData] = useState<{ collection: WorkCollection; works: Note[] } | null>(null);
const [work, setWork] = useState<NoteDetail | null>(null);
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setError('');
try {
const state = await api.getCustomerAccess(slug);
setAccess(state);
if (!state.authenticated) return;
if (noteId) {
const result = await api.getCustomerWork(slug, Number(noteId), selectedVersion ? Number(selectedVersion) : undefined);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}
else if (collectionId) setCollectionData(await api.getCustomerCollection(slug, Number(collectionId)));
else setProjectData(await api.getCustomerProject(slug));
} catch (reason) {
setError(reason instanceof ApiError ? reason.message : '页面加载失败');
}
}, [slug, collectionId, noteId, selectedVersion]);
useEffect(() => { void load(); }, [load]);
const login = async (event: React.FormEvent) => {
event.preventDefault(); setBusy(true); setError('');
try { await api.customerLogin(slug, { reviewer_name: name, password }); await load(); }
catch (reason) { setError(reason instanceof ApiError ? reason.message : '验证失败'); }
finally { setBusy(false); }
};
if (!access && !error) return <Centered text="正在打开验收空间…"/>;
if (!access) return <Centered text={error || '项目不存在'}/>;
if (!access.enabled || access.expired) return <Centered text={access.expired ? '此项目的访问链接已到期' : '此项目暂未开放客户访问'}/>;
if (!access.authenticated) return <AccessGate access={access} name={name} password={password} error={error} busy={busy} setName={setName} setPassword={setPassword} submit={login}/>;
if (noteId) return work ? <WorkReview slug={slug} work={work} reviewer={access.reviewer_name || '客户'} reload={load}/> : <Centered text={error || '正在加载作品…'}/>;
if (collectionId) return collectionData ? <CollectionReview slug={slug} data={collectionData}/> : <Centered text={error || '正在加载作品交付集…'}/>;
return projectData ? <ProjectReview slug={slug} data={projectData}/> : <Centered text={error || '正在加载项目…'}/>;
}
function AccessGate({ access, name, password, error, busy, setName, setPassword, submit }: { access: CustomerAccessState; name: string; password: string; error: string; busy: boolean; setName: (v: string) => void; setPassword: (v: string) => void; submit: (e: React.FormEvent) => void }) {
return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 py-12"><form onSubmit={submit} className="w-full max-w-md rounded-[32px] border border-black/10 bg-white p-7 shadow-2xl shadow-black/5 sm:p-10"><div className="grid h-12 w-12 place-items-center rounded-full bg-[#171714] text-[#f3bd69]"><KeyRound size={18}/></div><p className="mt-8 font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">Private review</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]">{access.project_name}</h1><p className="mt-4 text-sm leading-7 text-black/45">{access.client_description || '请输入姓名与项目密码,进入本次作品验收。'}</p><label className="mt-8 block text-xs text-black/50"><input autoFocus value={name} onChange={(e)=>setName(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label><label className="mt-5 block text-xs text-black/50">访<input type="password" value={password} onChange={(e)=>setPassword(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 px-4 py-3.5 text-sm outline-none focus:border-[#ba623c]"/></label>{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy||name.trim().length<2||!password} className="mt-7 flex w-full items-center justify-center gap-2 rounded-full bg-[#171714] py-4 text-sm text-white disabled:opacity-30">{busy?'正在验证…':<><ArrowRight size={15}/></>}</button><p className="mt-5 text-center text-[11px] text-black/30"> 7 </p></form></main>;
}
function ProjectReview({ slug, data }: { slug: string; data: ProjectPayload }) {
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.project.name} meta={`${data.reviewer_name} · 客户验收`}/><section className="mx-auto max-w-6xl px-5 py-12 lg:px-10"><p className="max-w-2xl text-sm leading-7 text-black/50">{data.project.client_description}</p><div className="mt-10 space-y-3">{data.collections.map((item, index)=><Link key={item.id} to={`/review/${slug}/collections/${item.id}`} className="group grid gap-4 rounded-2xl border border-black/10 bg-white p-5 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#eeeae1] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><h2 className="font-display text-2xl">{item.name}</h2><p className="mt-1 text-sm text-black/40">{item.client_description || '作品交付集验收'}</p></div><div className="flex items-center gap-5 text-xs text-black/45"><span><b className="text-lg text-black">{item.approved_count}/{item.work_count}</b> </span><ArrowRight className="transition group-hover:translate-x-1"/></div></Link>)}</div></section></main>;
}
function CollectionReview({ slug, data }: { slug: string; data: { collection: WorkCollection; works: Note[] } }) {
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={data.collection.name} meta={`${data.works.length} 件作品`}/><section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><Link to={`/review/${slug}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/></Link><div className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">{data.works.map((item)=><Link key={item.id} to={`/review/${slug}/works/${item.id}`} className="group overflow-hidden rounded-[24px] border border-black/10 bg-white"><div className="aspect-[4/5] overflow-hidden bg-black/5"><img src={item.cover_image} alt="" className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.025]"/></div><div className="p-5"><div className="flex items-center justify-between gap-3"><h2 className="font-display text-2xl leading-tight">{item.title}</h2><StatusBadge status={item.review_status}/></div></div></Link>)}</div></section></main>;
}
function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise<void> }) {
const [comment,setComment]=useState(''); const [reason,setReason]=useState(''); const [busy,setBusy]=useState(false);
const send=async()=>{if(!comment.trim())return;setBusy(true);await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload();setBusy(false)};
const decide=async(decision:'approved'|'changes_requested')=>{if(decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm('确认通过这个版本吗?通过后将记录你的验收决定。'))return;setBusy(true);await api.submitCustomerDecision(slug,work.id,decision,reason.trim());setReason('');await reload();setBusy(false)};
return <main className="min-h-screen bg-[#f7f5ef]"><ReviewHeader title={work.project.name} meta={`${reviewer} · V${work.version_number}`}/><div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]"><article className="min-w-0 px-5 py-9 lg:px-10 lg:py-12"><div className="flex flex-wrap items-center justify-between gap-3"><Link to={`/review/${slug}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/40"><ArrowLeft size={13}/>{work.collection.name}</Link><div className="flex gap-2">{work.versions.map((item)=><Link key={item.version_number} to={`/review/${slug}/works/${work.id}?version=${item.version_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${item.version_number===work.version_number?'bg-black text-white':'bg-black/5'}`}>V{item.version_number}</Link>)}</div></div><p className="mb-5 mt-8 text-xs text-black/45">{work.project.name} <span className="mx-2 text-black/20">/</span> {work.collection.name} <span className="mx-2 text-black/20">/</span> <b className="font-mono text-[#ba623c]">WORK {String(work.id).padStart(3,'0')}</b></p><div className="columns-1 gap-5 xl:columns-2">{work.images.map((img)=><AnnotatableImage key={img.id} image={img} annotations={img.annotations} onAdd={async(x,y,content)=>{await api.addCustomerAnnotation(slug,img.id,{x,y,content});await reload()}}/>)}</div><div className="mt-12 border-t border-black/10 pt-9"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation)=>annotation.target==='title')} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'title',content});await reload()}}><h1 className="font-display text-4xl leading-[1.08] tracking-[-.04em] md:text-5xl">{work.title}</h1></AnnotatableText>{work.description&&<div className="mt-7"><AnnotatableText label="正文" annotations={work.text_annotations.filter((annotation)=>annotation.target==='description')} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'description',content});await reload()}}><p className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.description}</p></AnnotatableText></div>}{work.tags.length>0&&<p className="mt-7 max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.tags.join(' ')}</p>}</div></article><aside className="border-t border-black/10 bg-[#efebe3] lg:sticky lg:top-0 lg:h-screen lg:border-l lg:border-t-0"><div className="flex h-full flex-col"><div className="border-b border-black/10 p-5"><p className="font-mono text-[10px] uppercase tracking-[.25em] text-black/35">Review</p><h2 className="mt-2 font-display text-3xl"></h2></div><div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length===0&&<div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/></div>}{work.comments.map((item)=><div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role==='operator'?'ml-5 bg-black text-white':'mr-5 bg-white'}`}><div className="mb-2 text-[10px] opacity-45">{item.author_name}</div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}</div><div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e)=>setComment(e.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="针对整个作品留下意见…"/><button disabled={busy||!comment.trim()} onClick={()=>void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{work.review_status!=='approved'&&<><textarea rows={2} value={reason} onChange={(e)=>setReason(e.target.value)} className="mt-3 w-full resize-none rounded-xl border border-black/10 bg-white p-3 text-xs" placeholder="要求修改时,请填写原因"/><div className="mt-2 grid grid-cols-2 gap-2"><button disabled={busy||!reason.trim()} onClick={()=>void decide('changes_requested')} className="rounded-full border border-[#ba623c]/30 bg-white py-3 text-xs text-[#a94e2c] disabled:opacity-30"></button><button disabled={busy} onClick={()=>void decide('approved')} className="flex items-center justify-center gap-2 rounded-full bg-emerald-600 py-3 text-xs text-white"><Check size={14}/></button></div></>}</div></div></aside></div></main>;
}
function ReviewHeader({title,meta}:{title:string;meta:string}) { return <header className="border-b border-white/10 bg-[#171714] text-white"><div className="mx-auto flex max-w-[1500px] items-center justify-between px-5 py-5 lg:px-10"><div><p className="font-mono text-[9px] uppercase tracking-[.25em] text-[#f3bd69]">Delivery Desk</p><h1 className="mt-1 font-display text-2xl">{title}</h1></div><span className="rounded-full border border-white/15 px-3 py-1.5 text-[10px] text-white/55">{meta}</span></div></header> }
function Centered({text}:{text:string}) { return <main className="grid min-h-screen place-items-center bg-[#f4f1ea] px-5 text-center text-sm text-black/45">{text}</main> }

34
src/pages/Dashboard.tsx Normal file
View File

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

183
src/pages/Gallery.tsx Normal file
View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from 'react';
import { useNotesStore } from '@/store/useNotesStore';
import NoteCard from '@/components/NoteCard';
import { Search, ArrowUpDown, Inbox } from 'lucide-react';
import { cn } from '@/lib/utils';
export default function Gallery() {
const { notes, loading, error, query, fetchNotes, setQuery } = useNotesStore();
const [searchInput, setSearchInput] = useState('');
useEffect(() => {
fetchNotes();
}, [fetchNotes]);
// 搜索防抖
useEffect(() => {
const t = setTimeout(() => {
if (searchInput !== (query.q ?? '')) {
setQuery({ q: searchInput || undefined });
}
}, 300);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const stats = useMemo(() => {
const total = notes.length;
const images = notes.reduce((s, n) => s + n.image_count, 0);
const ann = notes.reduce((s, n) => s + n.annotation_count, 0);
return { total, images, ann };
}, [notes]);
return (
<div>
{/* Hero */}
<section className="border-b border-stone-200/60">
<div className="mx-auto max-w-[1400px] px-6 lg:px-10 pt-20 pb-16">
<div className="font-mono text-[10px] uppercase tracking-[0.3em] text-stone-500 mb-6 animate-fade-in">
01 Volume One · 2026
</div>
<h1 className="font-display text-[clamp(3.5rem,9vw,8.5rem)] leading-[0.92] tracking-tighter-2 text-ink animate-fade-up">
<span className="italic text-ochre"></span>
<br />
<span className="italic text-sage"></span>
</h1>
<div className="mt-10 grid grid-cols-1 md:grid-cols-12 gap-6 animate-fade-up" style={{ animationDelay: '200ms' }}>
<p className="md:col-span-7 lg:col-span-6 text-stone-600 leading-relaxed text-base">
</p>
<div className="md:col-span-5 lg:col-start-9 lg:col-span-4 flex items-end justify-end gap-8">
<Stat label="Entries" value={stats.total} />
<Stat label="Plates" value={stats.images} />
<Stat label="Notes" value={stats.ann} accent />
</div>
</div>
<div className="mt-12 h-px bg-ink/15 origin-left animate-draw-line" />
</div>
</section>
{/* 工具栏 */}
<section className="sticky top-[73px] z-30 bg-paper/85 backdrop-blur border-b border-stone-200/60">
<div className="mx-auto max-w-[1400px] px-6 lg:px-10 py-4 flex flex-col sm:flex-row sm:items-center gap-4 justify-between">
<div className="flex items-center gap-4 font-mono text-[10px] uppercase tracking-[0.2em]">
<span className="text-stone-400"> 02 Index</span>
<div className="hidden sm:flex items-center gap-1">
{(['created_at', 'annotations'] as const).map((key) => (
<button
key={key}
onClick={() => setQuery({ sort: key })}
className={cn(
'px-2 py-1 transition-colors',
query.sort === key
? 'text-ink underline underline-offset-4 decoration-ochre decoration-1'
: 'text-stone-400 hover:text-ink',
)}
>
{key === 'created_at' ? 'By Date' : 'By Notes'}
</button>
))}
<span className="mx-2 text-stone-300">·</span>
<button
onClick={() => setQuery({ order: query.order === 'asc' ? 'desc' : 'asc' })}
className="inline-flex items-center gap-1 text-stone-500 hover:text-ink"
>
<ArrowUpDown className="w-3 h-3" strokeWidth={1.5} />
{query.order === 'asc' ? 'Asc' : 'Desc'}
</button>
</div>
</div>
<div className="relative w-full sm:w-72">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-stone-400"
strokeWidth={1.5}
/>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="搜索标题或描述…"
className="w-full bg-transparent border border-stone-300 pl-9 pr-3 py-2 text-sm text-ink placeholder:text-stone-400 focus:outline-none focus:border-ink"
/>
</div>
</div>
</section>
{/* 笔记瀑布流 */}
<section className="mx-auto max-w-[1400px] px-6 lg:px-10 py-16">
{error && (
<div className="border border-ochre/30 bg-ochre/5 p-6 mb-12 font-mono text-sm text-ochre">
{error}
</div>
)}
{loading && notes.length === 0 ? (
<SkeletonGrid />
) : notes.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
{notes.map((note, i) => (
<NoteCard key={note.id} note={note} index={i} />
))}
</div>
)}
</section>
</div>
);
}
function Stat({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
return (
<div className="text-right">
<div
className={cn(
'font-display text-4xl leading-none tracking-tightest',
accent ? 'text-ochre' : 'text-ink',
)}
>
{String(value).padStart(2, '0')}
</div>
<div className="mt-1 font-mono text-[9px] uppercase tracking-[0.25em] text-stone-500">
{label}
</div>
</div>
);
}
function SkeletonGrid() {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-16">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="animate-pulse">
<div className="flex justify-between mb-3">
<div className="h-3 w-12 bg-stone-200" />
<div className="h-3 w-16 bg-stone-200" />
</div>
<div className="aspect-[4/5] bg-stone-200" />
<div className="h-6 bg-stone-200 mt-4 w-3/4" />
<div className="h-4 bg-stone-200 mt-2 w-full" />
<div className="h-4 bg-stone-200 mt-1 w-2/3" />
</div>
))}
</div>
);
}
function EmptyState() {
return (
<div className="text-center py-24">
<div className="inline-flex items-center justify-center w-16 h-16 border border-stone-300 rounded-full mb-6">
<Inbox className="w-7 h-7 text-stone-400" strokeWidth={1.2} />
</div>
<h3 className="font-display text-3xl text-ink tracking-tightest">
</h3>
<p className="mt-3 text-sm text-stone-500 max-w-md mx-auto leading-relaxed">
<span className="font-mono text-ochre">Compose</span>
<span className="font-mono text-ochre">POST /api/notes</span>
</p>
</div>
);
}

50
src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
import { ArrowRight, LockKeyhole, Sparkle } from 'lucide-react';
import { useAuthStore } from '@/store/useAuthStore';
export default function LoginPage() {
const { user, login, loading } = useAuthStore();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const location = useLocation();
if (user) return <Navigate to="/" replace />;
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setError('');
try {
await login(username, password);
navigate((location.state as { from?: string } | null)?.from || '/');
} catch (reason) {
setError(reason instanceof Error ? reason.message : '登录失败');
}
};
return (
<main className="grid min-h-[calc(100vh-73px)] bg-[#faf9f5] lg:grid-cols-[1.2fr_.8fr]">
<section className="relative hidden overflow-hidden bg-[#181817] p-14 text-[#f8f4ec] lg:flex lg:flex-col">
<div className="absolute -left-44 -top-44 h-[540px] w-[540px] rounded-full border border-[#f7c45c]/20" />
<div className="absolute -bottom-64 -right-44 h-[620px] w-[620px] rounded-full bg-[#b56a2d]/15 blur-3xl" />
<div className="relative"><p className="font-mono text-[10px] uppercase tracking-[.34em] text-[#f7c45c]">A quiet place for good work</p></div>
<div className="relative my-auto max-w-2xl"><h1 className="font-display text-7xl leading-[.93] tracking-[-.06em]"><br/><em className="font-light text-white/40"></em></h1><p className="mt-9 max-w-md text-sm leading-7 text-white/45"></p></div>
<div className="relative flex items-center gap-3 text-[11px] text-white/35"><Sparkle size={14} className="text-[#f7c45c]"/> </div>
</section>
<section className="grid place-items-center px-5 py-14 sm:px-10">
<form onSubmit={submit} className="w-full max-w-sm">
<div className="grid h-12 w-12 place-items-center rounded-full border border-black/10 bg-white text-[#b56a2d]"><LockKeyhole size={18}/></div>
<p className="mt-9 font-mono text-[10px] uppercase tracking-[.3em] text-[#b56a2d]">Private workspace</p>
<h1 className="mt-3 font-display text-5xl tracking-[-.05em] text-[#181817]"></h1>
<p className="mt-3 max-w-xs text-sm leading-6 text-black/45">使</p>
<label className="mt-9 block text-xs font-medium text-black/55"><input autoFocus autoComplete="username" value={username} onChange={e => setUsername(e.target.value)} className="mt-2.5 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm outline-none transition focus:border-[#b56a2d]"/></label>
<label className="mt-5 block text-xs font-medium text-black/55"><input type="password" autoComplete="current-password" value={password} onChange={e => setPassword(e.target.value)} className="mt-2.5 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm outline-none transition focus:border-[#b56a2d]"/></label>
{error && <p className="mt-4 rounded-xl border border-red-100 bg-red-50 p-3 text-xs text-red-700">{error}</p>}
<button disabled={loading || !username || !password} className="mt-8 flex w-full items-center justify-center gap-2 rounded-full bg-[#181817] py-4 text-sm text-white transition hover:bg-[#b56a2d] disabled:opacity-35">
{loading ? '正在验证身份…' : <> <ArrowRight size={15}/></>}
</button>
<p className="mt-5 text-center text-[11px] leading-5 text-black/35"></p>
</form>
</section>
</main>
);
}

170
src/pages/Management.tsx Normal file
View File

@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { ArrowRightLeft, ChevronDown, Copy, Fingerprint, History, KeyRound, Plus, RefreshCw, Search, ShieldCheck, Users, X } from 'lucide-react';
import type { AuditLogEntry, ManagedApiKey, ManagedUser, OperationGroup, Project } from '@shared/types';
import { api, ApiError } from '@/api/client';
import { useAuthStore } from '@/store/useAuthStore';
import StorageSettings from '@/components/StorageSettings';
type Tab = 'groups' | 'accounts' | 'keys' | 'storage' | 'audit';
type Panel = 'group' | 'user' | 'key' | 'rename' | 'rename_group' | 'replace_admin' | 'reset' | null;
const roleLabel = { platform_admin: '平台管理员', group_admin: '组管理员', operator: '光影叙事' } as const;
const actionLabel: Record<string, string> = {
'group.create': '创建运营组', 'group.active': '启用运营组', 'group.disabled': '停用运营组',
'group.admin_replace':'更换组管理员','group.rename':'修改运营组名称','user.create': '创建账号', 'user.active': '启用账号', 'user.disabled': '停用账号', 'user.password_reset': '重置密码','user.name_update':'修改用户名',
'api_key.create': '创建 API Key', 'api_key.revoke': '吊销 API Key',
'project.create': '创建项目', 'project.update': '修改项目', 'collection.create': '创建作品交付集',
'collection.update': '修改作品交付集', 'work.create': '上传作品',
};
export default function ManagementPage() {
const { user } = useAuthStore();
const isPlatform = user?.role === 'platform_admin';
const [tab, setTab] = useState<Tab>(isPlatform ? 'groups' : 'accounts');
const [panel, setPanel] = useState<Panel>(null);
const [groups, setGroups] = useState<OperationGroup[]>([]);
const [accounts, setAccounts] = useState<ManagedUser[]>([]);
const [keys, setKeys] = useState<ManagedApiKey[]>([]);
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [revealedKey, setRevealedKey] = useState('');
const [resetTarget, setResetTarget] = useState<ManagedUser | null>(null);
const [renameTarget, setRenameTarget] = useState<ManagedUser | null>(null);
const [replaceGroup, setReplaceGroup] = useState<OperationGroup | null>(null);
const [renameGroup, setRenameGroup] = useState<OperationGroup | null>(null);
const [renameGroupValue, setRenameGroupValue] = useState('');
const [replacementUserId, setReplacementUserId] = useState('');
const [previousAdminAction, setPreviousAdminAction] = useState<'demote' | 'disable'>('demote');
const [accountGroupFilter, setAccountGroupFilter] = useState('');
const [auditUserId, setAuditUserId] = useState<number | null>(null);
const [groupForm, setGroupForm] = useState({ name: '', username: '', display_name: '', password: '' });
const [userForm, setUserForm] = useState({ group_id: '', username: '', display_name: '', password: '', role: 'operator' as 'platform_admin' | 'group_admin' | 'operator' });
const [keyForm, setKeyForm] = useState({ name: '', project_id: '' });
const [resetPassword, setResetPassword] = useState('');
const [renameValue, setRenameValue] = useState('');
const load = useCallback(async () => {
if (!user || user.role === 'operator') return;
setLoading(true);
setError('');
try {
const [nextAccounts, nextKeys, nextLogs, nextProjects, nextGroups] = await Promise.all([
api.listManagedUsers(), api.listApiKeys(), api.listAuditLogs(), api.listProjects(),
isPlatform ? api.listGroups() : Promise.resolve([]),
]);
setAccounts(nextAccounts); setKeys(nextKeys); setLogs(nextLogs); setProjects(nextProjects); setGroups(nextGroups);
} catch (reason) { setError(messageOf(reason)); }
finally { setLoading(false); }
}, [isPlatform, user]);
useEffect(() => { void load(); }, [load]);
const tabs = useMemo(() => [
...(isPlatform ? [{ id: 'groups' as const, label: '运营组', count: groups.length }] : []),
{ id: 'accounts' as const, label: '账号', count: accounts.length },
{ id: 'keys' as const, label: 'API Key', count: keys.filter((item) => item.status === 'active').length },
...(isPlatform ? [{ id: 'storage' as const, label: '对象存储', count: 0 }] : []),
{ id: 'audit' as const, label: '审计日志', count: logs.length },
], [accounts.length, groups.length, isPlatform, keys, logs.length]);
if (user?.role === 'operator') {
return <main className="mx-auto max-w-3xl px-5 py-24 text-center"><ShieldCheck className="mx-auto text-black/25"/><h1 className="mt-5 font-display text-4xl"></h1><p className="mt-3 text-sm text-black/45"></p></main>;
}
const run = async (task: () => Promise<void>) => {
setSaving(true); setError('');
try { await task(); setPanel(null); await load(); }
catch (reason) { setError(messageOf(reason)); }
finally { setSaving(false); }
};
const renameOperationGroup = async () => {
if (!renameGroup) return;
setSaving(true); setError('');
try {
const updated = await api.updateGroupName(renameGroup.id, renameGroupValue.trim());
setGroups((items) => items.map((item) => item.id === updated.id ? { ...item, name: updated.name } : item));
setAccounts((items) => items.map((item) => item.group_id === updated.id ? { ...item, group_name: updated.name } : item));
setProjects((items) => items.map((item) => item.group_id === updated.id ? { ...item, group_name: updated.name } : item));
setPanel(null);
} catch (reason) { setError(messageOf(reason)); }
finally { setSaving(false); }
};
return (
<main className="mx-auto max-w-[1500px] px-5 py-9 lg:px-10 lg:py-14">
<section className="relative overflow-hidden rounded-[32px] bg-[#171714] px-6 py-8 text-white sm:px-9 lg:px-12 lg:py-11">
<div className="absolute -right-24 -top-24 h-72 w-72 rounded-full border border-white/10"/><div className="absolute right-12 top-8 h-24 w-24 rounded-full bg-[#d97045]/20 blur-2xl"/>
<div className="relative flex flex-col gap-8 md:flex-row md:items-end md:justify-between"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#f1a06f]">Governance / Delivery Desk</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em] sm:text-6xl"></h1><p className="mt-4 max-w-xl text-sm leading-6 text-white/45"></p></div><button onClick={() => void load()} className="inline-flex items-center justify-center gap-2 rounded-full border border-white/15 px-4 py-2.5 text-xs text-white/60 transition hover:border-white/40 hover:text-white"><RefreshCw size={13} className={loading ? 'animate-spin' : ''}/></button></div>
</section>
<nav className="mt-7 flex gap-1 overflow-x-auto border-b border-black/10">
{tabs.map((item) => <button key={item.id} onClick={() => setTab(item.id)} className={`relative whitespace-nowrap px-4 py-4 text-sm transition ${tab === item.id ? 'text-black' : 'text-black/40 hover:text-black'}`}>{item.label}<span className="ml-2 font-mono text-[10px] text-black/30">{item.count}</span>{tab === item.id && <span className="absolute inset-x-3 bottom-0 h-0.5 bg-[#d15f37]"/>}</button>)}
</nav>
{error && <div className="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>}
{loading ? <Loading /> : <>
{tab === 'groups' && <Groups groups={groups} accounts={accounts} onCreate={() => setPanel('group')} onRename={(item)=>{setRenameGroup(item);setRenameGroupValue(item.name);setPanel('rename_group')}} onShowAccounts={(item) => { setAccountGroupFilter(String(item.id)); setTab('accounts'); }} onReplace={(item) => { setReplaceGroup(item); setReplacementUserId(''); setPreviousAdminAction('demote'); setPanel('replace_admin'); }} onToggle={(item) => { if(item.status==='active'&&!window.confirm(`停用“${item.name}”将影响 ${item.active_user_count} 个启用账号、${item.project_count} 个项目和 ${item.customer_link_count} 个客户访问链接。确认继续吗?`))return;void run(() => api.setGroupStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined)); }}/>}
{tab === 'accounts' && <Accounts accounts={accounts} groups={groups} groupFilter={accountGroupFilter} onGroupFilter={setAccountGroupFilter} currentId={user?.id} currentRole={user?.role} onCreate={() => setPanel('user')} onAudit={(item) => { setAuditUserId(item.id); setTab('audit'); }} onRename={(item) => { setRenameTarget(item); setRenameValue(item.display_name); setPanel('rename'); }} onToggle={(item) => void run(() => api.setUserStatus(item.id, item.status === 'active' ? 'disabled' : 'active').then(() => undefined))} onReset={(item) => { setResetTarget(item); setResetPassword(''); setPanel('reset'); }}/>}
{tab === 'keys' && <ApiKeys items={keys} onCreate={() => { setRevealedKey(''); setPanel('key'); }} onRevoke={(item) => void run(() => api.revokeApiKey(item.id))}/>}
{tab === 'storage' && isPlatform && <StorageSettings/>}
{tab === 'audit' && <AuditLogs items={logs} filteredUser={auditUserId ? accounts.find((item)=>item.id===auditUserId) : undefined} onClear={() => setAuditUserId(null)}/>}
</>}
{panel === 'group' && <Modal title="创建运营组" subtitle="创建数据空间,并同时配置首位组管理员。" onClose={() => setPanel(null)}><FormField label="运营组名称"><input value={groupForm.name} onChange={(e) => setGroupForm({ ...groupForm, name: e.target.value })}/></FormField><div className="grid gap-4 sm:grid-cols-2"><FormField label="管理员登录账号"><input value={groupForm.username} onChange={(e) => setGroupForm({ ...groupForm, username: e.target.value })}/></FormField><FormField label="管理员姓名" hint="填写本人姓名"><input value={groupForm.display_name} onChange={(e) => setGroupForm({ ...groupForm, display_name: e.target.value })}/></FormField></div><FormField label="临时密码" hint="至少 8 位,包含字母和数字"><input type="password" value={groupForm.password} onChange={(e) => setGroupForm({ ...groupForm, password: e.target.value })}/></FormField><Submit saving={saving} disabled={!groupForm.name || !groupForm.username || !groupForm.display_name || !groupForm.password} onClick={() => void run(async () => { await api.createGroup(groupForm); setGroupForm({ name: '', username: '', display_name: '', password: '' }); })}></Submit></Modal>}
{panel === 'user' && <Modal title="创建工作台账号" subtitle={isPlatform ? '每组限一位组管理员;光影叙事和平台管理员均可创建多位。' : '为当前运营组添加拥有独立用户名的光影叙事账号。'} onClose={() => setPanel(null)}>{isPlatform && <div className="grid gap-4 sm:grid-cols-2">{userForm.role === 'platform_admin' ? <div className="rounded-xl border border-black/10 bg-white p-3 text-xs text-black/45"></div> : <FormField label="所属运营组"><select value={userForm.group_id} onChange={(e) => setUserForm({ ...userForm, group_id: e.target.value })}><option value=""></option>{groups.filter((item) => item.status === 'active').map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<FormField label="身份"><select value={userForm.role} onChange={(e) => setUserForm({ ...userForm, role: e.target.value as 'platform_admin' | 'group_admin' | 'operator', group_id: e.target.value === 'platform_admin' ? '' : userForm.group_id })}><option value="operator"></option><option value="group_admin"></option><option value="platform_admin"></option></select></FormField></div>}<div className="grid gap-4 sm:grid-cols-2"><FormField label="登录账号"><input value={userForm.username} onChange={(e) => setUserForm({ ...userForm, username: e.target.value })}/></FormField><FormField label="用户名" hint="填写成员名称"><input value={userForm.display_name} onChange={(e) => setUserForm({ ...userForm, display_name: e.target.value })}/></FormField></div><FormField label="临时密码" hint="首次登录后必须修改"><input type="password" value={userForm.password} onChange={(e) => setUserForm({ ...userForm, password: e.target.value })}/></FormField><Submit saving={saving} disabled={(isPlatform && userForm.role !== 'platform_admin' && !userForm.group_id) || !userForm.username || !userForm.display_name || !userForm.password} onClick={() => void run(async () => { await api.createManagedUser({ ...userForm, group_id: userForm.group_id ? Number(userForm.group_id) : undefined }); setUserForm({ group_id: '', username: '', display_name: '', password: '', role: 'operator' }); })}></Submit></Modal>}
{panel === 'reset' && resetTarget && <Modal title="重置临时密码" subtitle={`账号:${resetTarget.username} · ${resetTarget.display_name}`} onClose={() => setPanel(null)}><FormField label="新临时密码" hint="重置后原会话立即失效"><input autoFocus type="password" value={resetPassword} onChange={(e) => setResetPassword(e.target.value)}/></FormField><Submit saving={saving} disabled={!resetPassword} onClick={() => void run(() => api.resetUserPassword(resetTarget.id, resetPassword).then(() => undefined))}></Submit></Modal>}
{panel === 'rename' && renameTarget && <Modal title="修改用户名" subtitle={`登录账号:${renameTarget.username} · 身份:${roleLabel[renameTarget.role]}`} onClose={() => setPanel(null)}><FormField label="用户名" hint="仅修改页面展示用户名,不改变登录账号与权限"><input autoFocus value={renameValue} onChange={(e) => setRenameValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameValue.trim().length < 2} onClick={() => void run(() => api.updateManagedUserName(renameTarget.id, renameValue.trim()).then(() => undefined))}></Submit></Modal>}
{panel === 'replace_admin' && replaceGroup && <Modal title="更换组管理员" subtitle={`${replaceGroup.name} 当前管理员:${replaceGroup.group_admin_name || '未设置'}`} onClose={() => setPanel(null)}><FormField label="新组管理员" hint="从已启用的光影叙事中选择"><select value={replacementUserId} onChange={(e)=>setReplacementUserId(e.target.value)}><option value=""></option>{accounts.filter((item)=>item.group_id===replaceGroup.id&&item.role==='operator'&&item.status==='active').map((item)=><option key={item.id} value={item.id}>{item.display_name} · {item.username}</option>)}</select></FormField><FormField label="原管理员处理"><select value={previousAdminAction} onChange={(e)=>setPreviousAdminAction(e.target.value as 'demote'|'disable')}><option value="demote"></option><option value="disable"></option></select></FormField><div className="rounded-xl border border-[#d15f37]/15 bg-[#fff7f2] p-3 text-xs leading-5 text-[#91462e]"></div><Submit saving={saving} disabled={!replacementUserId} onClick={() => void run(() => api.replaceGroupAdmin(replaceGroup.id,Number(replacementUserId),previousAdminAction).then(()=>undefined))}></Submit></Modal>}
{panel === 'rename_group' && renameGroup && <Modal title="修改运营组名称" subtitle="只修改名称,不影响组内账号、项目、客户链接和权限。" onClose={()=>setPanel(null)}><FormField label="运营组名称" hint="240 个字符"><input autoFocus value={renameGroupValue} onChange={(e)=>setRenameGroupValue(e.target.value)}/></FormField><Submit saving={saving} disabled={renameGroupValue.trim().length<2||renameGroupValue.trim()===renameGroup.name} onClick={()=>void renameOperationGroup()}></Submit></Modal>}
{panel === 'key' && <Modal title={revealedKey ? '保存 API Key' : '创建 API Key'} subtitle={revealedKey ? '密钥只显示这一次。关闭前请复制到安全位置。' : isPlatform ? '平台级 Key 可用于外部系统创建项目。' : '项目级 Key 仅能操作绑定的项目。'} onClose={() => { setPanel(null); setRevealedKey(''); }}>{revealedKey ? <div><div className="break-all rounded-2xl bg-[#171714] p-5 font-mono text-xs leading-6 text-[#f2c38d]">{revealedKey}</div><button onClick={() => void navigator.clipboard.writeText(revealedKey)} className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-3 text-sm"><Copy size={14}/>复制密钥</button></div> : <><FormField label="Key 名称"><input value={keyForm.name} onChange={(e) => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="例如:自动发布客户端"/></FormField>{!isPlatform && <FormField label="绑定项目"><select value={keyForm.project_id} onChange={(e) => setKeyForm({ ...keyForm, project_id: e.target.value })}><option value="">请选择</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></FormField>}<Submit saving={saving} disabled={!keyForm.name || (!isPlatform && !keyForm.project_id)} onClick={() => { setSaving(true); setError(''); void api.createApiKey({ name: keyForm.name, project_id: keyForm.project_id ? Number(keyForm.project_id) : undefined }).then(({ token }) => { setRevealedKey(token); setKeyForm({ name: '', project_id: '' }); return load(); }).catch((reason) => setError(messageOf(reason))).finally(() => setSaving(false)); }}></Submit></>}</Modal>}
</main>
);
}
function Groups({ groups, accounts, onCreate, onRename, onShowAccounts, onReplace, onToggle }: { groups: OperationGroup[]; accounts: ManagedUser[]; onCreate: () => void;onRename:(item:OperationGroup)=>void; onShowAccounts: (item:OperationGroup)=>void; onReplace:(item:OperationGroup)=>void; onToggle: (item: OperationGroup) => void }) {
const [expandedId, setExpandedId] = useState<number | null>(null);
const [query,setQuery]=useState('');const[status,setStatus]=useState('all');
const visible=groups.filter((item)=>(status==='all'||item.status===status)&&item.name.toLowerCase().includes(query.trim().toLowerCase()));
return <Section title="运营组" intro="每个运营组拥有独立的账号与项目数据,展开可查看组内成员。" action="创建运营组" onAction={onCreate}><Filters><SearchBox value={query} onChange={setQuery} placeholder="搜索运营组"/><FilterSelect value={status} onChange={setStatus}><option value="all"></option><option value="active"></option><option value="disabled"></option></FilterSelect></Filters><div className="grid gap-3">{visible.map((item) => {
const expanded = expandedId === item.id;
const members = accounts.filter((account) => account.group_id === item.id).sort((a, b) => Number(b.role === 'group_admin') - Number(a.role === 'group_admin') || a.display_name.localeCompare(b.display_name, 'zh-CN'));
const groupAdmin=members.find((member)=>member.role==='group_admin');
return <div key={item.id} className="overflow-hidden rounded-2xl border border-black/[.08] bg-white">
<div className="grid gap-4 px-4 py-4 sm:grid-cols-[42px_1fr_auto] sm:items-center">
<div className="grid h-10 w-10 place-items-center rounded-full bg-[#f1eee7] text-black/55"><Users size={17}/></div>
<div><div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-medium">{item.name}</h3><span className={`rounded-full px-2 py-0.5 text-[9px] ${item.status === 'active' ? 'bg-emerald-50 text-emerald-700' : 'bg-black/5 text-black/35'}`}>{item.status === 'active' ? 'ACTIVE' : 'DISABLED'}</span>{item.status==='active'&&groupAdmin?.status!=='active'&&<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[9px] text-amber-700"></span>}</div><p className="mt-1 text-xs leading-5 text-black/40">{groupAdmin?.display_name || item.group_admin_name || '未设置'}{groupAdmin?.status==='disabled'?'(停用)':''} · {item.operator_count} · {item.disabled_user_count} </p><p className="text-[11px] leading-5 text-black/30">{item.project_count} · {item.customer_link_count} · {dateText(item.created_at)}</p></div>
<div className="flex flex-wrap items-center gap-3"><button aria-expanded={expanded} onClick={() => setExpandedId(expanded ? null : item.id)} className="inline-flex items-center gap-1.5 text-xs text-black/45 hover:text-black">{expanded ? '收起账号' : '查看账号'}<ChevronDown size={13} className={`transition-transform ${expanded ? 'rotate-180' : ''}`}/></button><button onClick={()=>onShowAccounts(item)} className="text-xs text-black/45 hover:text-black"></button><button onClick={()=>onRename(item)} className="text-xs text-black/45 hover:text-black"></button><button disabled={!members.some((member)=>member.role==='operator'&&member.status==='active')} onClick={()=>onReplace(item)} className="inline-flex items-center gap-1 text-xs text-black/45 hover:text-black disabled:opacity-25"><ArrowRightLeft size={12}/></button><button onClick={() => onToggle(item)} className="text-xs text-black/45 underline decoration-black/20 underline-offset-4 hover:text-black">{item.status === 'active' ? '停用' : '启用'}</button></div>
</div>
{expanded && <div className="border-t border-black/[.06] bg-[#faf9f5] px-4 py-2 sm:pl-[70px]">{members.length === 0 ? <p className="py-5 text-xs text-black/35"></p> : members.map((member) => <div key={member.id} className="grid gap-2 border-b border-black/[.06] py-3 last:border-0 sm:grid-cols-[minmax(120px,1fr)_minmax(140px,1fr)_auto] sm:items-center"><div className="flex items-center gap-2"><span className="text-sm font-medium">{member.display_name}</span><span className="rounded-full bg-[#f3ece5] px-2 py-0.5 text-[9px] text-[#a55534]">{roleLabel[member.role]}</span></div><span className="font-mono text-[11px] text-black/40">{member.username}</span><span className={`w-fit rounded-full px-2 py-0.5 text-[9px] ${member.status === 'active' ? 'bg-emerald-50 text-emerald-700' : 'bg-black/5 text-black/35'}`}>{member.status === 'active' ? '启用' : '停用'}</span></div>)}</div>}
</div>;
})}{visible.length===0&&<Empty text="没有符合条件的运营组"/>}</div></Section>;
}
function Accounts({ accounts, groups, groupFilter, onGroupFilter, currentId, currentRole, onCreate, onAudit, onRename, onToggle, onReset }: { accounts: ManagedUser[];groups:OperationGroup[];groupFilter:string;onGroupFilter:(value:string)=>void; currentId?: number; currentRole?: string; onCreate: () => void;onAudit:(item:ManagedUser)=>void; onRename: (item: ManagedUser) => void; onToggle: (item: ManagedUser) => void; onReset: (item: ManagedUser) => void }) {
const[query,setQuery]=useState('');const[role,setRole]=useState('all');const[status,setStatus]=useState('all');
const filtered=accounts.filter((item)=>(!groupFilter||(groupFilter==='platform'?item.group_id==null:String(item.group_id)===groupFilter))&&(role==='all'||item.role===role)&&(status==='all'||(status==='password'?item.must_change_password:item.status===status))&&`${item.display_name} ${item.username}`.toLowerCase().includes(query.trim().toLowerCase()));
const sections=[{title:'平台管理员',items:filtered.filter((item)=>item.role==='platform_admin')},{title:'运营组成员',items:filtered.filter((item)=>item.role!=='platform_admin')}].filter((section)=>section.items.length);
const render=(item:ManagedUser)=>{const canRename=currentRole==='platform_admin'||(currentRole==='group_admin'&&(item.id===currentId||item.role==='operator'));const activity=`最近登录:${item.last_login_at?dateText(item.last_login_at):'从未登录'} · 最近操作:${item.last_operation_at?dateText(item.last_operation_at):'暂无'} · 创建:${dateText(item.created_at)}`;return <Row key={item.id} icon={<Fingerprint size={17}/>} title={item.display_name} badge={roleLabel[item.role]} meta={`登录账号:${item.username} · 所属:${item.group_name||'平台'}${item.must_change_password?' · 待修改临时密码':''}\n${activity}`} status={item.status}><div className="flex flex-wrap gap-3"><button onClick={()=>onAudit(item)} className="inline-flex items-center gap-1 text-xs text-black/45 hover:text-black"><History size={12}/></button>{canRename&&<button onClick={()=>onRename(item)} className="text-xs text-black/45 hover:text-black"></button>}{item.id!==currentId&&<><button onClick={()=>onReset(item)} className="text-xs text-black/45 hover:text-black"></button><button onClick={()=>onToggle(item)} className="text-xs text-black/45 hover:text-black">{item.status==='active'?'停用':'启用'}</button></>}</div></Row>};
return <Section title="账号" intro="按运营组、身份与状态快速定位账号;平台管理员独立展示。" action="创建账号" onAction={onCreate}><Filters><SearchBox value={query} onChange={setQuery} placeholder="搜索用户名或登录账号"/><FilterSelect value={groupFilter} onChange={onGroupFilter}><option value=""></option><option value="platform"></option>{groups.map((item)=><option key={item.id} value={item.id}>{item.name}</option>)}</FilterSelect><FilterSelect value={role} onChange={setRole}><option value="all"></option><option value="platform_admin"></option><option value="group_admin"></option><option value="operator"></option></FilterSelect><FilterSelect value={status} onChange={setStatus}><option value="all"></option><option value="active"></option><option value="disabled"></option><option value="password"></option></FilterSelect></Filters><div className="space-y-7">{sections.map((section)=><div key={section.title}><h3 className="mb-3 font-mono text-[10px] uppercase tracking-[.2em] text-black/35">{section.title} · {section.items.length}</h3><div className="grid gap-3">{section.items.map(render)}</div></div>)}{!sections.length&&<Empty text="没有符合条件的账号"/>}</div></Section>;
}
function ApiKeys({ items, onCreate, onRevoke }: { items: ManagedApiKey[]; onCreate: () => void; onRevoke: (item: ManagedApiKey) => void }) { return <Section title="API Key" intro="明文仅创建时显示一次,数据库只保存不可逆哈希。" action="创建 API Key" onAction={onCreate}><div className="grid gap-3">{items.length === 0 ? <Empty text="尚未创建 API Key"/> : items.map((item) => <Row key={item.id} icon={<KeyRound size={17}/>} title={item.name} meta={`${item.key_prefix} · ${item.scope === 'platform' ? '平台级' : item.project_name || '项目级'} · ${item.last_used_at ? `最近使用 ${dateText(item.last_used_at)}` : '尚未使用'}`} status={item.status}><button disabled={item.status === 'revoked'} onClick={() => onRevoke(item)} className="text-xs text-black/45 hover:text-red-600 disabled:hidden"></button></Row>)}</div></Section>; }
function AuditLogs({ items,filteredUser,onClear }: { items: AuditLogEntry[];filteredUser?:ManagedUser;onClear:()=>void }) { const visible=filteredUser?items.filter((item)=>item.user_id===filteredUser.id):items;return <Section title="审计日志" intro={filteredUser?`仅查看 ${filteredUser.display_name}${filteredUser.username})的操作记录。`:'最近 200 条关键操作,按时间倒序保留。'}>{filteredUser&&<button onClick={onClear} className="mb-4 rounded-full border border-black/10 bg-white px-4 py-2 text-xs text-black/50 hover:text-black"></button>}<div className="overflow-hidden rounded-2xl border border-black/10 bg-white">{visible.length === 0 ? <Empty text="暂无审计记录"/> : visible.map((item) => <div key={item.id} className="grid gap-2 border-b border-black/[.06] px-4 py-4 last:border-0 sm:grid-cols-[170px_1fr_auto] sm:items-center"><time className="font-mono text-[10px] text-black/35">{dateText(item.created_at)}</time><div><p className="text-sm">{actionLabel[item.action] || item.action}</p><p className="mt-1 text-xs text-black/35">{item.user_name || 'API / 系统'} · {item.group_name || '平台'} · {item.entity_type} #{item.entity_id ?? '—'}</p></div><span className="font-mono text-[10px] text-black/25">LOG {String(item.id).padStart(4, '0')}</span></div>)}</div></Section>; }
function Section({ title, intro, action, onAction, children }: { title: string; intro: string; action?: string; onAction?: () => void; children: React.ReactNode }) { return <section className="py-8"><div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"><div><h2 className="font-display text-3xl">{title}</h2><p className="mt-1 text-sm text-black/40">{intro}</p></div>{action && <button onClick={onAction} className="inline-flex items-center justify-center gap-2 rounded-full bg-[#171714] px-5 py-3 text-sm text-white transition hover:bg-[#d15f37]"><Plus size={15}/>{action}</button>}</div>{children}</section>; }
function Row({ icon, title, meta, status, badge, children }: { icon: React.ReactNode; title: string; meta: string; status: string; badge?: string; children: React.ReactNode }) { const active = status === 'active'; return <div className="grid gap-4 rounded-2xl border border-black/[.08] bg-white px-4 py-4 sm:grid-cols-[42px_1fr_auto] sm:items-center"><div className="grid h-10 w-10 place-items-center rounded-full bg-[#f1eee7] text-black/55">{icon}</div><div><div className="flex flex-wrap items-center gap-2"><h3 className="text-sm font-medium">{title}</h3>{badge && <span className="rounded-full bg-[#f3ece5] px-2 py-0.5 text-[9px] text-[#a55534]"> · {badge}</span>}<span className={`rounded-full px-2 py-0.5 text-[9px] ${active ? 'bg-emerald-50 text-emerald-700' : 'bg-black/5 text-black/35'}`}>{active ? 'ACTIVE' : status.toUpperCase()}</span></div><p className="mt-1 whitespace-pre-line text-xs leading-5 text-black/40">{meta}</p></div><div>{children}</div></div>; }
function Filters({children}:{children:React.ReactNode}){return <div className="mb-6 flex flex-wrap gap-2 rounded-2xl border border-black/[.07] bg-white p-3">{children}</div>}
function SearchBox({value,onChange,placeholder}:{value:string;onChange:(value:string)=>void;placeholder:string}){return <label className="relative min-w-[220px] flex-1"><Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-black/25"/><input value={value} onChange={(e)=>onChange(e.target.value)} placeholder={placeholder} className="w-full rounded-xl border border-black/10 bg-[#faf9f5] py-2.5 pl-9 pr-3 text-xs outline-none focus:border-[#d15f37]"/></label>}
function FilterSelect({value,onChange,children}:{value:string;onChange:(value:string)=>void;children:React.ReactNode}){return <select value={value} onChange={(e)=>onChange(e.target.value)} className="rounded-xl border border-black/10 bg-[#faf9f5] px-3 py-2.5 text-xs text-black/55 outline-none focus:border-[#d15f37]">{children}</select>}
function Modal({ title, subtitle, onClose, children }: { title: string; subtitle: string; onClose: () => void; children: React.ReactNode }) { return <div className="fixed inset-0 z-[90] grid place-items-center overflow-y-auto bg-black/45 p-4 backdrop-blur-sm"><div className="my-6 w-full max-w-lg rounded-[28px] bg-[#f7f6f2] p-6 shadow-2xl sm:p-8"><div className="flex items-start justify-between gap-4"><div><h3 className="font-display text-3xl">{title}</h3><p className="mt-2 text-sm leading-6 text-black/45">{subtitle}</p></div><button onClick={onClose} className="grid h-9 w-9 flex-none place-items-center rounded-full border border-black/10 bg-white"><X size={16}/></button></div><div className="mt-7 space-y-5">{children}</div></div></div>; }
function FormField({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { return <label className="block text-xs font-medium text-black/55"><span className="flex justify-between gap-3"><span>{label}</span>{hint && <span className="font-normal text-black/30">{hint}</span>}</span><div className="mt-2 [&_input]:w-full [&_input]:rounded-xl [&_input]:border [&_input]:border-black/10 [&_input]:bg-white [&_input]:p-3 [&_input]:outline-none [&_input]:focus:border-[#d15f37] [&_select]:w-full [&_select]:rounded-xl [&_select]:border [&_select]:border-black/10 [&_select]:bg-white [&_select]:p-3 [&_select]:outline-none">{children}</div></label>; }
function Submit({ saving, disabled, onClick, children }: { saving: boolean; disabled: boolean; onClick: () => void; children: React.ReactNode }) { return <button disabled={saving || disabled} onClick={onClick} className="w-full rounded-full bg-[#171714] py-3.5 text-sm text-white transition hover:bg-[#d15f37] disabled:opacity-30">{saving ? '正在保存…' : children}</button>; }
function Loading() { return <div className="grid gap-3 py-8">{[1, 2, 3].map((item) => <div key={item} className="h-20 animate-pulse rounded-2xl bg-black/5"/>)}</div>; }
function Empty({ text }: { text: string }) { return <div className="rounded-2xl border border-dashed border-black/15 px-5 py-14 text-center text-sm text-black/35">{text}</div>; }
function messageOf(reason: unknown) { return reason instanceof ApiError || reason instanceof Error ? reason.message : '操作失败'; }
function dateText(value: string) { const date = new Date(value.includes('T') ? value : `${value.replace(' ', 'T')}Z`); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }); }

19
src/pages/NewVersion.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowUp, ImagePlus, X } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
type Item = { file: File; url: string };
export default function NewVersionPage() {
const id = Number(useParams().noteId); const navigate = useNavigate();
const [work,setWork]=useState<NoteDetail|null>(null); const [title,setTitle]=useState(''); const [description,setDescription]=useState(''); const [tags,setTags]=useState(''); const [items,setItems]=useState<Item[]>([]); const [busy,setBusy]=useState(false); const [error,setError]=useState('');
useEffect(()=>{void api.getNote(id).then((item)=>{setWork(item);setTitle(item.title);setDescription(item.description);setTags(item.tags.join(', '))})},[id]);
useEffect(()=>()=>items.forEach((item)=>URL.revokeObjectURL(item.url)),[items]);
const add=(files:FileList|null)=>{if(!files)return;setItems((current)=>[...current,...Array.from(files).slice(0,30-current.length).map((file)=>({file,url:URL.createObjectURL(file)}))])};
const move=(index:number,direction:-1|1)=>setItems((current)=>{const target=index+direction;if(target<0||target>=current.length)return current;const copy=[...current];[copy[index],copy[target]]=[copy[target],copy[index]];return copy});
const submit=async()=>{if(!title.trim()||!items.length)return;setBusy(true);setError('');try{await api.createWorkVersion(id,{title:title.trim(),description,tags:tags?[tags]:[],images:items.map((item)=>item.file)});navigate(`/works/${id}`)}catch(reason){setError(reason instanceof Error?reason.message:'新版本上传失败');setBusy(false)}};
if(!work)return null;
return <main className="mx-auto max-w-6xl px-5 py-9 lg:px-10 lg:py-12"><Link to={`/works/${id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/></Link><div className="mt-8 grid gap-10 lg:grid-cols-[.75fr_1.25fr]"><section><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ba623c]">New revision / V{work.version_number+1}</p><h1 className="mt-3 font-display text-5xl tracking-[-.05em]"></h1><p className="mt-4 text-sm leading-6 text-black/45"></p><label className="mt-8 block text-xs text-black/50"><input value={title} onChange={(e)=>setTitle(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50"><textarea rows={7} value={description} onChange={(e)=>setDescription(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label><label className="mt-5 block text-xs text-black/50">Tag<input value={tags} onChange={(e)=>setTags(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 bg-white p-3.5 text-sm"/></label>{error&&<p className="mt-4 rounded-xl bg-red-50 p-3 text-xs text-red-700">{error}</p>}<button disabled={busy||!title.trim()||!items.length} onClick={()=>void submit()} className="mt-7 w-full rounded-full bg-black py-4 text-sm text-white disabled:opacity-30">{busy?'正在上传…':`创建 V${work.version_number+1}`}</button></section><section><label className="grid min-h-52 cursor-pointer place-items-center rounded-[28px] border border-dashed border-black/20 bg-white text-center"><input type="file" accept="image/jpeg,image/png,image/webp,image/heic,image/heif" multiple className="hidden" onChange={(e)=>add(e.target.files)}/><span><ImagePlus className="mx-auto text-black/30"/><b className="mt-3 block text-sm"></b><small className="mt-1 block text-black/35">130 </small></span></label><div className="mt-5 grid grid-cols-2 gap-4 sm:grid-cols-3">{items.map((item,index)=><div key={item.url} className="group relative overflow-hidden rounded-2xl border border-black/10 bg-white"><img src={item.url} alt="" className="aspect-[4/5] w-full object-cover"/><div className="absolute inset-x-2 bottom-2 flex justify-between"><button onClick={()=>move(index,-1)} className="rounded-full bg-white/90 p-2 disabled:opacity-30" disabled={index===0}><ArrowUp size={13}/></button><button onClick={()=>setItems((current)=>current.filter((_,i)=>i!==index))} className="rounded-full bg-white/90 p-2"><X size={13}/></button></div>{index===0&&<span className="absolute left-2 top-2 rounded-full bg-black px-2 py-1 text-[9px] text-white"></span>}</div>)}</div></section></div></main>;
}

65
src/pages/NoteDetail.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams, useSearchParams } from 'react-router-dom';
import { ArrowLeft, History, MessageSquareText, RotateCcw, Send, UploadCloud } from 'lucide-react';
import type { NoteDetail } from '@shared/types';
import { api } from '@/api/client';
import AnnotatableImage from '@/components/AnnotatableImage';
import AnnotatableText from '@/components/AnnotatableText';
import StatusBadge from '@/components/StatusBadge';
import { useAuthStore } from '@/store/useAuthStore';
export default function NoteDetailPage() {
const id = Number(useParams().noteId);
const [search] = useSearchParams();
const selected = search.get('version');
const version = selected ? Number(selected) : undefined;
const user = useAuthStore((state) => state.user);
const [work, setWork] = useState<NoteDetail | null>(null);
const [comment, setComment] = useState('');
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const result = await api.getNote(id, version);
setWork({ ...result, text_annotations: result.text_annotations ?? [] });
}, [id, version]);
useEffect(() => { void load(); }, [load]);
if (!work) return <div className="p-20 text-center text-black/35"></div>;
const latestVersion = Math.max(...work.versions.map((item) => item.version_number));
const viewingLatest = work.version_number === latestVersion;
const canReopen = viewingLatest && work.review_status === 'approved' && (user?.role === 'group_admin' || user?.role === 'platform_admin');
const send = async () => {
if (!comment.trim()) return;
setBusy(true);
await api.addComment(id, { content: comment.trim(), author_name: user?.display_name || '工作台', author_role: 'operator' });
setComment(''); await load(); setBusy(false);
};
const reopen = async () => {
if (!reason.trim()) return;
setBusy(true); setMessage('');
try { await api.reopenWork(id, reason.trim()); setReason(''); await load(); }
catch (error) { setMessage(error instanceof Error ? error.message : '操作失败'); }
finally { setBusy(false); }
};
return <main>
<header className="border-b border-black/10 bg-white"><div className="mx-auto max-w-[1500px] px-5 py-5 lg:px-10"><div className="flex flex-wrap items-center justify-between gap-3">
<Link to={`/projects/${work.project.id}/collections/${work.collection.id}`} className="inline-flex items-center gap-2 text-xs text-black/45"><ArrowLeft size={14}/>{work.collection.name}</Link>
<div className="flex flex-wrap items-center gap-2"><StatusBadge status={work.review_status}/>{work.versions.map((item) => <Link key={item.version_number} to={`/works/${id}?version=${item.version_number}`} className={`rounded-full px-3 py-1.5 text-[11px] ${item.version_number === work.version_number ? 'bg-black text-white' : 'bg-black/5'}`}>V{item.version_number}</Link>)}{viewingLatest && <Link to={`/works/${id}/new-version`} className="inline-flex items-center gap-2 rounded-full bg-black px-4 py-2 text-xs text-white"><UploadCloud size={13}/></Link>}</div>
</div></div></header>
<div className="mx-auto grid max-w-[1500px] lg:grid-cols-[minmax(0,1fr)_360px]">
<article className="min-w-0 px-5 py-10 lg:px-10 lg:py-14"><div className="mx-auto max-w-5xl">
<p className="mb-5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs font-medium text-black/50"><span>{work.project.name}</span><span className="text-black/20">/</span><span>{work.collection.name}</span><span className="text-black/20">/</span><span className="font-mono uppercase tracking-[.16em] text-[#ba623c]">Work {String(work.id).padStart(3,'0')}</span></p>
<div className="columns-1 gap-5 xl:columns-2">{work.images.map((image) => <AnnotatableImage key={image.id} image={image} annotations={image.annotations} onAdd={async (x,y,text) => { await api.addAnnotation(image.id, { x,y,content:text }); await load(); }}/>)}</div>
<div className="mt-12 border-t border-black/10 pt-10"><AnnotatableText label="标题" annotations={work.text_annotations.filter((annotation) => annotation.target === 'title')} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'title', content }); await load(); }}><h1 className="max-w-4xl font-display text-4xl leading-[1.08] tracking-[-.04em] md:text-5xl">{work.title}</h1></AnnotatableText>{work.description && <div className="mt-7"><AnnotatableText label="正文" annotations={work.text_annotations.filter((annotation) => annotation.target === 'description')} onAdd={async (content) => { await api.addTextAnnotation(id, { version_number: work.version_number, target: 'description', content }); await load(); }}><p className="max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.description}</p></AnnotatableText></div>}{work.tags.length > 0 && <p className="mt-7 max-w-3xl whitespace-pre-wrap text-[15px] leading-8 text-black/65">{work.tags.join(' ')}</p>}</div>
</div></article>
<aside className="border-t border-black/10 bg-[#efede7] lg:sticky lg:top-[66px] lg:h-[calc(100vh-66px)] lg:border-l lg:border-t-0"><div className="flex h-full flex-col">
<div className="border-b border-black/10 p-5"><p className="font-mono text-[10px] uppercase tracking-[.25em] text-black/35">Review conversation</p><h2 className="mt-2 font-display text-3xl"></h2><p className="mt-2 text-xs leading-5 text-black/45"></p></div>
<div className="flex-1 space-y-3 overflow-auto p-4">{work.comments.length === 0 && <div className="py-10 text-center text-xs text-black/35"><MessageSquareText className="mx-auto mb-3"/></div>}{work.comments.map((item) => <div key={item.id} className={`rounded-2xl p-3 text-sm ${item.author_role === 'operator' ? 'ml-5 bg-black text-white' : 'mr-5 bg-white'}`}><div className="mb-2 flex items-center justify-between text-[10px] opacity-50"><span>{item.author_name}</span><span>{new Date(item.created_at).toLocaleString('zh-CN')}</span></div><p className="whitespace-pre-wrap leading-6">{item.content}</p></div>)}{work.review_events.length > 0 && <div className="mt-5 border-t border-black/10 pt-4"><div className="mb-3 flex items-center gap-2 text-xs text-black/45"><History size={13}/></div>{work.review_events.map((event) => <div key={event.id} className="mb-2 rounded-xl bg-white/60 p-3 text-[11px] leading-5 text-black/55">V{event.version_number} · {event.actor_name} · {event.to_status}{event.reason && <p className="mt-1 text-black/70">{event.reason}</p>}</div>)}</div>}</div>
<div className="border-t border-black/10 p-4"><div className="relative"><textarea rows={3} value={comment} onChange={(e) => setComment(e.target.value)} className="w-full resize-none rounded-2xl border border-black/10 bg-white p-3 pr-12 text-sm" placeholder="回复客户或记录处理结果…"/><button disabled={busy || !comment.trim()} onClick={() => void send()} className="absolute bottom-3 right-3 rounded-full bg-black p-2 text-white disabled:opacity-30"><Send size={14}/></button></div>{canReopen && <div className="mt-3 rounded-2xl border border-black/10 bg-white p-3"><textarea rows={2} value={reason} onChange={(e) => setReason(e.target.value)} className="w-full resize-none text-xs outline-none" placeholder="填写重新打开验收的原因"/><button disabled={busy || !reason.trim()} onClick={() => void reopen()} className="mt-2 flex w-full items-center justify-center gap-2 rounded-full border border-black/15 py-2.5 text-xs disabled:opacity-30"><RotateCcw size={13}/></button></div>}{message && <p className="mt-2 text-xs text-red-600">{message}</p>}</div>
</div></aside>
</div>
</main>;
}

64
src/pages/Project.tsx Normal file
View File

@@ -0,0 +1,64 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ArrowLeft, ArrowRight, CalendarRange, Copy, KeyRound, Pencil, Plus, X } from 'lucide-react';
import type { Project, WorkCollection } from '@shared/types';
import { api } from '@/api/client';
export default function ProjectPage() {
const id = Number(useParams().projectId);
const [project, setProject] = useState<Project | null>(null);
const [collections, setCollections] = useState<WorkCollection[]>([]);
const [collectionOpen, setCollectionOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [accessOpen, setAccessOpen] = useState(false);
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
const [projectName, setProjectName] = useState('');
const [projectDesc, setProjectDesc] = useState('');
const [accessEnabled, setAccessEnabled] = useState(false);
const [accessPassword, setAccessPassword] = useState('');
const [expiresAt, setExpiresAt] = useState('');
const [message, setMessage] = useState('');
const load = useCallback(async () => {
const [nextProject, nextCollections] = await Promise.all([api.getProject(id), api.listCollections(id)]);
setProject(nextProject);
setCollections(nextCollections);
}, [id]);
useEffect(() => { void load(); }, [load]);
if (!project) return null;
const reviewUrl = `${window.location.origin}/review/${project.slug}`;
const createCollection = async () => {
await api.createCollection(id, { name, client_description: desc });
setCollectionOpen(false); setName(''); setDesc(''); await load();
};
const editProject = async () => {
setProject(await api.updateProject(id, { name: projectName, client_description: projectDesc }));
setEditOpen(false);
};
const openAccess = () => {
setAccessEnabled(Boolean(project.customer_access_enabled));
setAccessPassword('');
setExpiresAt(project.access_expires_at?.slice(0, 16) || '');
setMessage(''); setAccessOpen(true);
};
const saveAccess = async () => {
try {
const updated = await api.updateCustomerAccess(id, { enabled: accessEnabled, password: accessPassword || undefined, expires_at: expiresAt || null });
setProject(updated); setMessage('客户访问设置已保存'); setAccessPassword('');
} catch (reason) { setMessage(reason instanceof Error ? reason.message : '保存失败'); }
};
return <main>
<section className="border-b border-black/10 bg-[#171714] text-white"><div className="mx-auto max-w-[1500px] px-5 py-12 lg:px-10 lg:py-20"><Link to="/" className="inline-flex items-center gap-2 text-xs text-white/55"><ArrowLeft size={14}/></Link><div className="mt-10 grid gap-8 lg:grid-cols-[1fr_auto] lg:items-end"><div><p className="font-mono text-[10px] uppercase tracking-[.3em] text-[#ff795f]">Client Project / {project.slug}</p><h1 className="mt-4 font-display text-5xl tracking-[-.05em] sm:text-7xl">{project.name}</h1><p className="mt-5 max-w-2xl text-sm leading-7 text-white/55">{project.client_description}</p></div><div><div className="flex gap-8"><Metric n={project.collection_count} label="作品交付集"/><Metric n={project.work_count} label="作品"/></div><div className="mt-6 flex flex-wrap gap-2"><button onClick={()=>{setProjectName(project.name);setProjectDesc(project.client_description);setEditOpen(true)}} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><Pencil size={13}/></button><button onClick={openAccess} className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-xs text-white/70 hover:border-white/50 hover:text-white"><KeyRound size={13}/>访</button></div></div></div></div></section>
<section className="mx-auto max-w-[1500px] px-5 py-10 lg:px-10"><div className="mb-6 flex items-center justify-between"><div><p className="font-mono text-[10px] tracking-[.25em] text-black/35">COLLECTIONS</p><h2 className="mt-1 font-display text-3xl"></h2></div><button onClick={()=>setCollectionOpen(true)} className="inline-flex items-center gap-2 rounded-full border border-black/15 bg-white px-5 py-3 text-sm"><Plus size={16}/></button></div><div className="space-y-3">{collections.map((item,index)=><Link key={item.id} to={`/projects/${id}/collections/${item.id}`} className="group grid gap-5 rounded-2xl border border-black/10 bg-white p-5 transition hover:border-black/30 md:grid-cols-[56px_1fr_auto] md:items-center"><div className="grid h-14 w-14 place-items-center rounded-xl bg-[#f1efe9] font-display text-xl">{String(index+1).padStart(2,'0')}</div><div><div className="flex flex-wrap items-center gap-3"><h3 className="font-display text-2xl">{item.name}</h3><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>
{collectionOpen&&<Modal close={()=>setCollectionOpen(false)} icon={<CalendarRange/>} title="新建作品交付集"><input className="mt-7 w-full rounded-xl border border-black/10 p-3" placeholder="例如2026 年 8 月任务" value={name} onChange={(e)=>setName(e.target.value)}/><textarea className="mt-3 w-full rounded-xl border border-black/10 p-3" rows={3} placeholder="客户可见的作品交付集说明" value={desc} onChange={(e)=>setDesc(e.target.value)}/><button disabled={!name} onClick={()=>void createCollection()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{editOpen&&<Modal close={()=>setEditOpen(false)} icon={<Pencil/>} title="编辑项目"><label className="mt-7 block text-xs text-black/45"><input className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" value={projectName} onChange={(e)=>setProjectName(e.target.value)}/></label><label className="mt-4 block text-xs text-black/45"><textarea className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm" rows={4} value={projectDesc} onChange={(e)=>setProjectDesc(e.target.value)}/></label><p className="mt-3 text-[11px] text-black/35"> {project.slug} </p><button disabled={!projectName.trim()} onClick={()=>void editProject()} className="mt-5 w-full rounded-full bg-black py-3 text-white disabled:opacity-30"></button></Modal>}
{accessOpen&&<Modal close={()=>setAccessOpen(false)} icon={<KeyRound/>} title="客户访问"><div className="mt-7 rounded-2xl border border-black/10 bg-white p-4"><p className="break-all text-xs leading-5 text-black/50">{reviewUrl}</p><button onClick={()=>void navigator.clipboard.writeText(reviewUrl).then(()=>setMessage('链接已复制'))} className="mt-3 inline-flex items-center gap-2 text-xs text-[#aa4f2e]"><Copy size={13}/></button></div><label className="mt-5 flex items-center justify-between rounded-xl border border-black/10 bg-white p-4 text-sm">访<input type="checkbox" checked={accessEnabled} onChange={(e)=>setAccessEnabled(e.target.checked)} className="h-4 w-4 accent-black"/></label><label className="mt-4 block text-xs text-black/45">{project.has_access_password?'重置访问密码(不修改可留空)':'设置访问密码'}<input type="password" value={accessPassword} onChange={(e)=>setAccessPassword(e.target.value)} placeholder="至少 6 位" className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label><label className="mt-4 block text-xs text-black/45"><input type="datetime-local" value={expiresAt} onChange={(e)=>setExpiresAt(e.target.value)} className="mt-2 w-full rounded-xl border border-black/10 p-3 text-sm"/></label>{message&&<p className="mt-3 text-xs text-black/50">{message}</p>}<button onClick={()=>void saveAccess()} className="mt-5 w-full rounded-full bg-black py-3 text-white">访</button></Modal>}
</main>;
}
function Metric({n,label}:{n:number;label:string}) { return <div><strong className="font-display text-4xl">{String(n).padStart(2,'0')}</strong><span className="ml-2 text-xs text-white/40">{label}</span></div> }
function Modal({close,icon,title,children}:{close:()=>void;icon:React.ReactNode;title:string;children:React.ReactNode}) { return <div className="fixed inset-0 z-[80] grid place-items-center bg-black/45 p-4"><div className="w-full max-w-md rounded-3xl bg-[#f7f6f2] p-7"><div className="flex items-center justify-between"><div>{icon}<h3 className="mt-3 font-display text-3xl">{title}</h3></div><button onClick={close}><X/></button></div>{children}</div></div> }

19
src/pages/Upload.tsx Normal file
View File

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

26
src/store/useAuthStore.ts Normal file
View File

@@ -0,0 +1,26 @@
import { create } from 'zustand';
import type { CurrentUser } from '@shared/types';
import { api } from '@/api/client';
interface AuthState {
user: CurrentUser | null;
loading: boolean;
initialized: boolean;
initialize: () => Promise<void>;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null, loading: false, initialized: false,
initialize: async () => {
try { set({ user: await api.me(), initialized: true }); }
catch { set({ user: null, initialized: true }); }
},
login: async (username, password) => {
set({ loading: true });
try { await api.login(username, password); set({ user: await api.me(), loading: false, initialized: true }); }
catch (error) { set({ loading: false }); throw error; }
},
logout: async () => { await api.logout(); set({ user: null }); },
}));

View File

@@ -0,0 +1,42 @@
import { create } from 'zustand';
import type { Note, NoteListQuery } from '@shared/types';
import { api } from '../api/client';
interface NotesState {
notes: Note[];
loading: boolean;
error: string | null;
query: NoteListQuery;
fetchNotes: (query?: NoteListQuery) => Promise<void>;
setQuery: (q: Partial<NoteListQuery>) => void;
removeNote: (id: number) => void;
}
export const useNotesStore = create<NotesState>((set, get) => ({
notes: [],
loading: false,
error: null,
query: { sort: 'created_at', order: 'desc' },
fetchNotes: async (query) => {
const nextQuery = query ?? get().query;
set({ loading: true, error: null });
try {
const notes = await api.listNotes(nextQuery);
set({ notes, loading: false, query: nextQuery });
} catch (err) {
const message = err instanceof Error ? err.message : '加载失败';
set({ loading: false, error: message });
}
},
setQuery: (q) => {
const next = { ...get().query, ...q };
set({ query: next });
get().fetchNotes(next);
},
removeNote: (id) => {
set((s) => ({ notes: s.notes.filter((n) => n.id !== id) }));
},
}));

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

71
tailwind.config.js Normal file
View File

@@ -0,0 +1,71 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
container: {
center: true,
},
extend: {
colors: {
paper: "#F5F1EA", // 米白纸张
ink: "#1A1A1A", // 墨黑
ochre: "#B8412E", // 赭石红
sage: "#5C6B5A", // 鼠尾草绿
cream: "#FBF8F2", // 奶白
stone: {
50: "#FAF7F2",
100: "#F0EBE0",
200: "#E2DACA",
300: "#C9BCA3",
400: "#A8997A",
500: "#857558",
600: "#65573E",
700: "#4A3F2C",
800: "#2E2619",
900: "#1A150E",
},
},
fontFamily: {
display: ['Fraunces', 'Georgia', 'serif'],
body: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
letterSpacing: {
'tightest': '-0.04em',
'tighter-2': '-0.06em',
},
animation: {
'fade-up': 'fadeUp 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) both',
'fade-in': 'fadeIn 0.6s ease-out both',
'pulse-soft': 'pulseSoft 2.4s ease-in-out infinite',
'scale-in': 'scaleIn 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both',
'draw-line': 'drawLine 1s ease-out both',
},
keyframes: {
fadeUp: {
'0%': { opacity: '0', transform: 'translateY(16px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
pulseSoft: {
'0%, 100%': { transform: 'scale(1)', opacity: '1' },
'50%': { transform: 'scale(1.12)', opacity: '0.85' },
},
scaleIn: {
'0%': { opacity: '0', transform: 'scale(0.9)' },
'100%': { opacity: '1', transform: 'scale(1)' },
},
drawLine: {
'0%': { transform: 'scaleX(0)' },
'100%': { transform: 'scaleX(1)' },
},
},
},
},
plugins: [],
};

45
tsconfig.json Normal file
View File

@@ -0,0 +1,45 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": false,
"forceConsistentCasingInFileNames": false,
"baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
],
"@shared/*": [
"./shared/*"
]
},
"types": [
"node",
"express"
]
},
"include": [
"src",
"api",
"shared",
"scripts"
]
}

12
vercel.json Normal file
View File

@@ -0,0 +1,12 @@
{
"rewrites": [
{
"source": "/api/(.*)",
"destination": "/api/index"
},
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}

42
vite.config.ts Normal file
View File

@@ -0,0 +1,42 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from "vite-tsconfig-paths";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [
'react-dev-locator',
],
},
}),
tsconfigPaths(),
],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
configure: (proxy) => {
proxy.on('error', (err) => {
console.log('proxy error', err);
});
proxy.on('proxyReq', (_proxyReq, req) => {
console.log('Sending Request to the Target:', req.method, req.url);
});
proxy.on('proxyRes', (proxyRes, req) => {
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
});
},
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
}
}
}
})