feat(auth): 添加认证模块和图片批注功能(项目初始化)
- 实现用户登录、登出、密码修改等认证功能 - 添加会话管理和权限控制中间件 - 创建图片批注组件和相关API路由 - 实现批注的增删改查功能 - 添加Docker和Git忽略配置文件 - 创建系统架构文档和开发约定说明 - 集成认证模块到前端应用路由中
This commit is contained in:
35
.dockerignore
Normal file
35
.dockerignore
Normal 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
19
.env.example
Normal 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
69
.gitignore
vendored
Normal 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
8
AGENTS.md
Normal 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
20
Dockerfile
Normal 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
92
README.md
Normal 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
102
api/app.ts
Normal 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
114
api/auth.ts
Normal 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
38
api/configCrypto.ts
Normal 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
48
api/customerAuth.ts
Normal 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
116
api/database.ts
Normal 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
273
api/db.ts
Normal 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
9
api/index.ts
Normal 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);
|
||||
}
|
||||
16
api/repositories/annotationsRepository.ts
Normal file
16
api/repositories/annotationsRepository.ts
Normal 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; },
|
||||
};
|
||||
28
api/repositories/imagesRepository.ts
Normal file
28
api/repositories/imagesRepository.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
53
api/repositories/notesRepository.ts
Normal file
53
api/repositories/notesRepository.ts
Normal 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
18
api/routes/annotations.ts
Normal 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
43
api/routes/auth.ts
Normal 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
28
api/routes/comments.ts
Normal 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: '回复内容须为 1–2000 个字符' }); 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
7
api/routes/groups.ts
Normal 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:'组名长度需要在 2–40 个字符之间'});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
39
api/routes/images.ts
Normal 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
57
api/routes/management.ts
Normal 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:'运营组名称需为 2–40 个字符'});return}if(!/^[A-Za-z0-9._-]{3,32}$/.test(username)){res.status(400).json({error:'账号需为 3–32 位字母、数字、点、横线或下划线'});return}if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});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:'运营组名称需为 2–40 个字符'});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:'账号需为 3–32 位字母、数字、点、横线或下划线'});return}
|
||||
if(displayName.length<2||displayName.length>40){res.status(400).json({error:'姓名需为 2–40 个字符'});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:'姓名需为 2–40 个字符'});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 名称需为 2–50 个字符'});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
183
api/routes/notes.ts
Normal 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: '批注内容须为 1–1000 个字符' }); 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
85
api/routes/projects.ts
Normal 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
32
api/routes/review.ts
Normal 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:'请填写 2–30 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})});
|
||||
|
||||
router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all<Record<string,unknown>>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})});
|
||||
|
||||
router.get('/:slug/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:'反馈内容须为 1–2000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
|
||||
|
||||
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const target=req.body?.target;const content=String(req.body?.content??'').trim();if(!Number.isFinite(versionNumber)||!['title','description'].includes(target)){res.status(400).json({error:'批注目标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}const belongs=await database.one('SELECT v.id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}const id=await database.insertId('INSERT INTO text_annotations (note_id,version_number,target,content,author_name) VALUES (?,?,?,?,?)',[noteId,versionNumber,target,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
|
||||
|
||||
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one(`SELECT i.id FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
|
||||
|
||||
router.post('/:slug/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
16
api/routes/storage.ts
Normal 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
37
api/server.ts
Normal 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;
|
||||
78
api/services/notesService.ts
Normal file
78
api/services/notesService.ts
Normal 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
90
api/storage.ts
Normal 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
51
api/upload.ts
Normal 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
40
compose.yaml
Normal 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
203
db/postgres/schema.sql
Normal 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
81
docs/architecture.md
Normal 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
37
docs/handoff.md
Normal 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
94
docs/integration-guide.md
Normal 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
70
docs/operator-runbook.md
Normal 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
28
eslint.config.js
Normal 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
30
index.html
Normal 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
10
nodemon.json
Normal 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
66
package.json
Normal 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
6079
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
allowBuilds:
|
||||
better-sqlite3: true
|
||||
esbuild: true
|
||||
sharp: true
|
||||
10
postcss.config.js
Normal file
10
postcss.config.js
Normal 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
4
public/favicon.svg
Normal 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 |
56
scripts/migrate-sqlite-to-postgres.ts
Normal file
56
scripts/migrate-sqlite-to-postgres.ts
Normal 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);
|
||||
}
|
||||
100
scripts/test-postgres-runtime.ts
Normal file
100
scripts/test-postgres-runtime.ts
Normal 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();
|
||||
}
|
||||
46
scripts/validate-postgres-migration.ts
Normal file
46
scripts/validate-postgres-migration.ts
Normal 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
234
shared/types.ts
Normal 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
48
src/App.tsx
Normal 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
86
src/api/client.ts
Normal 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 }) }),
|
||||
};
|
||||
13
src/components/AnnotatableImage.tsx
Normal file
13
src/components/AnnotatableImage.tsx
Normal 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>
|
||||
}
|
||||
28
src/components/AnnotatableText.tsx
Normal file
28
src/components/AnnotatableText.tsx
Normal 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>;
|
||||
}
|
||||
117
src/components/AnnotationPanel.tsx
Normal file
117
src/components/AnnotationPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
src/components/ImageViewer.tsx
Normal file
225
src/components/ImageViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/NoteCard.tsx
Normal file
83
src/components/NoteCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/components/ProtectedRoute.tsx
Normal file
13
src/components/ProtectedRoute.tsx
Normal 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;
|
||||
}
|
||||
26
src/components/SiteFooter.tsx
Normal file
26
src/components/SiteFooter.tsx
Normal 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 & Inter</span>
|
||||
<span>Bound by light & ink</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
32
src/components/SiteHeader.tsx
Normal file
32
src/components/SiteHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
src/components/StatusBadge.tsx
Normal file
11
src/components/StatusBadge.tsx
Normal 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>;
|
||||
}
|
||||
80
src/components/StorageSettings.tsx
Normal file
80
src/components/StorageSettings.tsx
Normal 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
96
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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>,
|
||||
)
|
||||
11
src/pages/ChangePassword.tsx
Normal file
11
src/pages/ChangePassword.tsx
Normal 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
21
src/pages/Collection.tsx
Normal 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>
|
||||
}
|
||||
81
src/pages/CustomerReview.tsx
Normal file
81
src/pages/CustomerReview.tsx
Normal 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
34
src/pages/Dashboard.tsx
Normal 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
183
src/pages/Gallery.tsx
Normal 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
50
src/pages/Login.tsx
Normal 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
170
src/pages/Management.tsx
Normal 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="2–40 个字符"><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
19
src/pages/NewVersion.tsx
Normal 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">1–30 张,选择顺序即展示顺序</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
65
src/pages/NoteDetail.tsx
Normal 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
64
src/pages/Project.tsx
Normal 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
19
src/pages/Upload.tsx
Normal 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
26
src/store/useAuthStore.ts
Normal 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 }); },
|
||||
}));
|
||||
42
src/store/useNotesStore.ts
Normal file
42
src/store/useNotesStore.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
71
tailwind.config.js
Normal file
71
tailwind.config.js
Normal 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
45
tsconfig.json
Normal 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
12
vercel.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/api/(.*)",
|
||||
"destination": "/api/index"
|
||||
},
|
||||
{
|
||||
"source": "/(.*)",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
vite.config.ts
Normal file
42
vite.config.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user