init: AI培训与智能巡检系统
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# 复制为 .env 并填入你的密钥(AI 识车需要)
|
||||
# 通义千问 DashScope API Key:https://dashscope.console.aliyun.com/
|
||||
DASHSCOPE_API_KEY=
|
||||
# 可选:视觉模型,默认 qwen-vl-plus(也可用 qwen-vl-max)
|
||||
# DASHSCOPE_VL_MODEL=qwen-vl-plus
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,39 @@
|
||||
// 简易性能基准 — 对应 T-1.8 性能验收。
|
||||
// 用法:先启动 API(node dist/main.js),再 `node bench.mjs`
|
||||
const BASE = process.env.BENCH_BASE || 'http://127.0.0.1:3001';
|
||||
const N = Number(process.env.BENCH_N || 200);
|
||||
|
||||
function pct(arr, p) {
|
||||
const s = [...arr].sort((a, b) => a - b);
|
||||
return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))];
|
||||
}
|
||||
|
||||
async function bench(name, path, threshold) {
|
||||
const lat = [];
|
||||
// 预热
|
||||
for (let i = 0; i < 5; i++) await fetch(`${BASE}${path}`);
|
||||
for (let i = 0; i < N; i++) {
|
||||
const t = performance.now();
|
||||
const res = await fetch(`${BASE}${path}`);
|
||||
await res.text();
|
||||
lat.push(performance.now() - t);
|
||||
}
|
||||
const p50 = pct(lat, 50);
|
||||
const p95 = pct(lat, 95);
|
||||
const ok = p95 < threshold;
|
||||
console.log(
|
||||
`${ok ? '✓' : '✗'} ${name.padEnd(16)} p50=${p50.toFixed(1)}ms ` +
|
||||
`p95=${p95.toFixed(1)}ms (阈值 ${threshold}ms)`,
|
||||
);
|
||||
return ok;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
results.push(await bench('list', '/api/models?pageSize=24', 1500));
|
||||
results.push(await bench('list+filter', '/api/models?category=动车组&sort=max_speed_value&order=desc', 1500));
|
||||
results.push(await bench('detail', '/api/models/1', 1500));
|
||||
results.push(await bench('search', '/api/search?q=HXD', 500));
|
||||
|
||||
const allOk = results.every(Boolean);
|
||||
console.log(`\n基准结果:${allOk ? '全部达标 ✓' : '存在未达标项 ✗'}(每项 ${N} 次请求)`);
|
||||
process.exit(allOk ? 0 : 1);
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+8576
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@train/api",
|
||||
"version": "0.1.0",
|
||||
"description": "中国机车图鉴 后端 API (NestJS)",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main.js",
|
||||
"test": "jest",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"fetch-images": "node scripts/fetch-images.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.4",
|
||||
"@nestjs/core": "^10.4.4",
|
||||
"@nestjs/jwt": "^11.0.2",
|
||||
"@nestjs/platform-express": "^10.4.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.5",
|
||||
"@nestjs/schematics": "^10.1.4",
|
||||
"@nestjs/testing": "^10.4.4",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.11",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.13",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^22.7.4",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.2"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 批量取图脚本(在有网络的本机运行):
|
||||
* 按 Wikidata P18 → Commons 同名分类 → Commons 关键词搜索 的顺序,
|
||||
* 为每个车型下载若干张真实照片到「本地共享图库」(uploads + photos 表),
|
||||
* 并记录署名(作者/许可证/来源)。下载后应用即从本地提供图片,运行时不再访问 Commons。
|
||||
*
|
||||
* 用法(在 apps/api 目录):
|
||||
* node scripts/fetch-images.mjs # 全部缺图车型,每个 1 张
|
||||
* node scripts/fetch-images.mjs --per 3 # 每个车型最多 3 张
|
||||
* node scripts/fetch-images.mjs --limit 20 # 仅处理前 20 个缺图车型
|
||||
* node scripts/fetch-images.mjs --category 电力机车
|
||||
* node scripts/fetch-images.mjs --dry # 只检索不下载不入库
|
||||
*
|
||||
* 所有下载图片一律入库为「候选(candidate)」,进入管理员「候选审图」队列,
|
||||
* 由人工确认后才会作为封面/图册展示(候选 + 人工确认 双闸门,保证准确与合规)。
|
||||
*
|
||||
* 注意:需联网访问 wikidata.org / commons.wikimedia.org(自由授权,已记录署名)。
|
||||
* 下载完成后图片保存在本地 app/data/uploads/,库表记录在 app/data/app.db。
|
||||
*/
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { writeFile } from 'fs/promises';
|
||||
import { join, resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = resolve(__dirname, '..', '..', '..'); // Train/
|
||||
const MACHINES_DB = join(ROOT, 'app', 'data', 'machines.db');
|
||||
const APP_DB = process.env.APP_DB_PATH || join(ROOT, 'app', 'data', 'app.db');
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || join(ROOT, 'app', 'data', 'uploads');
|
||||
const UA = 'ChinaLocoAtlas/0.1 (image fetcher; contact admin)';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (k) => {
|
||||
const i = args.indexOf(k);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const LIMIT = opt('--limit') ? Number(opt('--limit')) : Infinity;
|
||||
const PER = opt('--per') ? Math.max(1, Number(opt('--per'))) : 1;
|
||||
const CATEGORY = opt('--category');
|
||||
const DRY = args.includes('--dry');
|
||||
const STATUS = 'candidate'; // 一律入候选,交管理员「候选审图」确认
|
||||
|
||||
const CAT_HINT = {
|
||||
蒸汽机车: 'steam locomotive',
|
||||
电力机车: 'electric locomotive',
|
||||
内燃机车: 'diesel locomotive',
|
||||
动车组: 'EMU',
|
||||
客车: 'passenger car',
|
||||
货车: 'freight car',
|
||||
检测车: 'inspection car',
|
||||
旅游列车: 'tourist train',
|
||||
};
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const latin = (s) => (s.match(/[A-Za-z0-9-]+/g) || []).join('');
|
||||
const strip = (h) => (h || '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
const isImg = (s) => /\.(jpe?g|png)$/i.test(s || '');
|
||||
|
||||
async function api(url) {
|
||||
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 从 Commons File:xxx 取缩略图 url + 署名。*/
|
||||
async function commonsFileInfo(title) {
|
||||
const u =
|
||||
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
|
||||
`&titles=${encodeURIComponent(title)}&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1280`;
|
||||
const d = await api(u);
|
||||
const pages = d?.query?.pages || {};
|
||||
const p = Object.values(pages)[0];
|
||||
const ii = p?.imageinfo?.[0];
|
||||
if (!ii?.thumburl) return null;
|
||||
const em = ii.extmetadata || {};
|
||||
return {
|
||||
url: ii.thumburl,
|
||||
descriptionUrl: ii.descriptionurl || '',
|
||||
author: strip(em.Artist?.value) || '未署名',
|
||||
license: strip(em.LicenseShortName?.value) || '见来源',
|
||||
};
|
||||
}
|
||||
|
||||
/** 1) Wikidata P18 主图(最多 1 张,最准)。*/
|
||||
async function viaWikidata(model) {
|
||||
const q = `${latin(model.model_code) || model.model_code} ${CAT_HINT[model.category] || 'train'}`;
|
||||
const s = await api(
|
||||
'https://www.wikidata.org/w/api.php?action=wbsearchentities&format=json&origin=*' +
|
||||
`&language=en&type=item&limit=3&search=${encodeURIComponent(q)}`,
|
||||
);
|
||||
for (const hit of s?.search || []) {
|
||||
const e = await api(
|
||||
'https://www.wikidata.org/w/api.php?action=wbgetentities&format=json&origin=*' +
|
||||
`&props=claims&ids=${hit.id}`,
|
||||
);
|
||||
const p18 = e?.entities?.[hit.id]?.claims?.P18?.[0]?.mainsnak?.datavalue?.value;
|
||||
if (p18) {
|
||||
const info = await commonsFileInfo('File:' + p18);
|
||||
if (info) return [{ ...info, via: 'wikidata' }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** 2) Commons 同名分类(可多张)。*/
|
||||
async function viaCategory(model, want) {
|
||||
const code = latin(model.model_code) || model.model_code;
|
||||
const out = [];
|
||||
for (const cat of [`Category:China Railways ${code}`, `Category:${code}`]) {
|
||||
const d = await api(
|
||||
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
|
||||
`&list=categorymembers&cmtype=file&cmlimit=20&cmtitle=${encodeURIComponent(cat)}`,
|
||||
);
|
||||
for (const f of d?.query?.categorymembers || []) {
|
||||
if (!isImg(f.title)) continue;
|
||||
const info = await commonsFileInfo(f.title);
|
||||
if (info) out.push({ ...info, via: 'category' });
|
||||
if (out.length >= want) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 3) Commons 关键词搜索(最不准,兜底,可多张)。*/
|
||||
async function viaSearch(model, want) {
|
||||
const q = `${latin(model.model_code) || model.model_code} ${CAT_HINT[model.category] || 'train'} China Railway`;
|
||||
const d = await api(
|
||||
'https://commons.wikimedia.org/w/api.php?action=query&format=json&origin=*' +
|
||||
`&generator=search&gsrnamespace=6&gsrlimit=${Math.max(3, want)}&gsrsearch=${encodeURIComponent(q)}` +
|
||||
'&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1280',
|
||||
);
|
||||
const pages = d?.query?.pages || {};
|
||||
const out = [];
|
||||
for (const p of Object.values(pages)) {
|
||||
const ii = p?.imageinfo?.[0];
|
||||
if (!ii?.thumburl || !isImg(ii.thumburl)) continue;
|
||||
const em = ii.extmetadata || {};
|
||||
out.push({
|
||||
url: ii.thumburl,
|
||||
descriptionUrl: ii.descriptionurl || '',
|
||||
author: strip(em.Artist?.value) || '未署名',
|
||||
license: strip(em.LicenseShortName?.value) || '见来源',
|
||||
via: 'search',
|
||||
});
|
||||
if (out.length >= want) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 聚合各来源,按 url 去重,返回最多 want 张。*/
|
||||
async function collectImages(model, want) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
const push = (arr) => {
|
||||
for (const it of arr) {
|
||||
if (out.length >= want) break;
|
||||
if (seen.has(it.url)) continue;
|
||||
seen.add(it.url);
|
||||
out.push(it);
|
||||
}
|
||||
};
|
||||
push(await viaWikidata(model));
|
||||
if (out.length < want) push(await viaCategory(model, want - out.length));
|
||||
if (out.length < want) push(await viaSearch(model, want - out.length));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
const mdb = new Database(MACHINES_DB, { readonly: true });
|
||||
const adb = new Database(APP_DB);
|
||||
|
||||
const admin =
|
||||
adb.prepare("SELECT id FROM users WHERE role='admin' ORDER BY id LIMIT 1").get() ||
|
||||
adb.prepare('SELECT id FROM users ORDER BY id LIMIT 1').get();
|
||||
if (!admin) {
|
||||
console.error('找不到用户:请先启动后端注册一个管理员(或设 ADMIN_EMAIL)。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const where = CATEGORY ? 'WHERE c.name = ?' : '';
|
||||
const models = mdb
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id ${where}
|
||||
ORDER BY m.id`,
|
||||
)
|
||||
.all(...(CATEGORY ? [CATEGORY] : []));
|
||||
|
||||
const hasPhoto = adb.prepare('SELECT 1 FROM photos WHERE model_id = ? LIMIT 1');
|
||||
const insert = adb.prepare(
|
||||
`INSERT INTO photos (model_id, uploader_id, filename, caption, status, source_url, author, license)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`取图:每车型最多 ${PER} 张,入库状态=候选(candidate)${DRY ? '(dry-run)' : ''};保存到 ${UPLOAD_DIR}`,
|
||||
);
|
||||
|
||||
const report = [];
|
||||
let done = 0;
|
||||
let saved = 0;
|
||||
for (const m of models) {
|
||||
if (done >= LIMIT) break;
|
||||
if (hasPhoto.get(m.id)) continue;
|
||||
done++;
|
||||
let hits = [];
|
||||
try {
|
||||
hits = await collectImages(m, PER);
|
||||
} catch (e) {
|
||||
report.push(`✗ ${m.model_code}:检索出错 ${e.message}`);
|
||||
await sleep(300);
|
||||
continue;
|
||||
}
|
||||
if (hits.length === 0) {
|
||||
report.push(`— ${m.model_code}:未找到`);
|
||||
await sleep(200);
|
||||
continue;
|
||||
}
|
||||
if (DRY) {
|
||||
report.push(`✓ ${m.model_code}:[dry] ${hits.map((h) => h.via).join(',')} (${hits.length} 张)`);
|
||||
await sleep(200);
|
||||
continue;
|
||||
}
|
||||
let n = 0;
|
||||
for (const hit of hits) {
|
||||
try {
|
||||
const buf = Buffer.from(
|
||||
await (await fetch(hit.url, { headers: { 'User-Agent': UA } })).arrayBuffer(),
|
||||
);
|
||||
const ext = (hit.url.match(/\.(jpe?g|png)/i) || ['.jpg'])[0].toLowerCase();
|
||||
const fn = `${Date.now()}-${randomBytes(6).toString('hex')}${ext}`;
|
||||
await writeFile(join(UPLOAD_DIR, fn), buf);
|
||||
insert.run(m.id, admin.id, fn, `${m.model_code}(${hit.via})`, STATUS, hit.descriptionUrl, hit.author, hit.license);
|
||||
n++;
|
||||
saved++;
|
||||
} catch (e) {
|
||||
report.push(`✗ ${m.model_code}:下载失败 ${e.message}`);
|
||||
}
|
||||
await sleep(300); // 礼貌限速
|
||||
}
|
||||
if (n > 0) report.push(`✓ ${m.model_code}:入库 ${n} 张(${STATUS})`);
|
||||
}
|
||||
|
||||
const ok = report.filter((r) => r.startsWith('✓')).length;
|
||||
const miss = report.filter((r) => r.startsWith('—')).length;
|
||||
const err = report.filter((r) => r.startsWith('✗')).length;
|
||||
console.log(report.join('\n'));
|
||||
console.log(`\n命中 ${ok} · 未找到 ${miss} · 出错 ${err}(处理 ${done} 个车型,保存 ${saved} 张)`);
|
||||
console.log('候选图已入库,请用管理员在网页"候选审图 / 图册"逐个确认后作为封面展示。');
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DatabaseModule } from './db/database.module';
|
||||
import { WritableDbModule } from './db/writable-db.module';
|
||||
import { CategoriesModule } from './categories/categories.module';
|
||||
import { ModelsModule } from './models/models.module';
|
||||
import { UnitsModule } from './units/units.module';
|
||||
import { SearchModule } from './search/search.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { ContribModule } from './contrib/contrib.module';
|
||||
import { CommunityModule } from './community/community.module';
|
||||
import { ForumModule } from './forum/forum.module';
|
||||
import { SightingsModule } from './sightings/sightings.module';
|
||||
import { StatsModule } from './stats/stats.module';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { IdentifyModule } from './identify/identify.module';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DatabaseModule,
|
||||
WritableDbModule,
|
||||
AuthModule,
|
||||
CategoriesModule,
|
||||
ModelsModule,
|
||||
UnitsModule,
|
||||
SearchModule,
|
||||
ContribModule,
|
||||
CommunityModule,
|
||||
ForumModule,
|
||||
SightingsModule,
|
||||
StatsModule,
|
||||
PhotosModule,
|
||||
IdentifyModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto, RegisterDto } from './dto';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from './roles';
|
||||
|
||||
@Controller('api/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Post('register')
|
||||
register(@Body() dto: RegisterDto) {
|
||||
return this.auth.register(dto);
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.auth.login(dto);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
me(@CurrentUser() user: JwtPayload) {
|
||||
return this.auth.me(user.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard, RolesGuard } from './roles';
|
||||
|
||||
export const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
JwtModule.register({
|
||||
secret: JWT_SECRET,
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtAuthGuard, RolesGuard],
|
||||
exports: [AuthService, JwtAuthGuard, RolesGuard, JwtModule],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/** AuthService 单元测试 — 对应 T-2.1 UT。*/
|
||||
function makeService() {
|
||||
const db = new Database(':memory:');
|
||||
db.exec(`CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
contribution_points INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);`);
|
||||
const jwt = new JwtService({ secret: 'test-secret', signOptions: { expiresIn: '1h' } });
|
||||
const svc = new AuthService({ db } as any, jwt);
|
||||
return { svc, jwt, db };
|
||||
}
|
||||
|
||||
describe('AuthService', () => {
|
||||
it('注册返回 token 与用户,密码不外泄', async () => {
|
||||
const { svc } = makeService();
|
||||
const { token, user } = await svc.register({
|
||||
email: 'A@Example.com',
|
||||
password: 'secret123',
|
||||
displayName: '老司机',
|
||||
});
|
||||
expect(token).toBeTruthy();
|
||||
expect(user.email).toBe('a@example.com'); // 规范化小写
|
||||
expect(user.role).toBe('user');
|
||||
expect((user as any).password_hash).toBeUndefined();
|
||||
});
|
||||
|
||||
it('重复邮箱注册抛 409', async () => {
|
||||
const { svc } = makeService();
|
||||
const dto = { email: 'b@x.com', password: 'secret123', displayName: '阿强' };
|
||||
await svc.register(dto);
|
||||
await expect(svc.register(dto)).rejects.toBeInstanceOf(ConflictException);
|
||||
});
|
||||
|
||||
it('登录校验密码,错误抛 401', async () => {
|
||||
const { svc } = makeService();
|
||||
await svc.register({ email: 'c@x.com', password: 'secret123', displayName: 'C' });
|
||||
const ok = await svc.login({ email: 'c@x.com', password: 'secret123' });
|
||||
expect(ok.token).toBeTruthy();
|
||||
await expect(
|
||||
svc.login({ email: 'c@x.com', password: 'wrongpass' }),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('签发的 token 可被验证且含角色', async () => {
|
||||
const { svc, jwt } = makeService();
|
||||
const { token } = await svc.register({
|
||||
email: 'd@x.com',
|
||||
password: 'secret123',
|
||||
displayName: 'D',
|
||||
});
|
||||
const payload = jwt.verify(token);
|
||||
expect(payload.email).toBe('d@x.com');
|
||||
expect(payload.role).toBe('user');
|
||||
expect(payload.sub).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
OnModuleInit,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { LoginDto, RegisterDto } from './dto';
|
||||
import type { JwtPayload, Role } from './roles';
|
||||
import { badgesFor, levelFor, type Badge, type LevelInfo } from '../community/levels';
|
||||
|
||||
export interface PublicUser {
|
||||
id: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
contributionPoints: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface UserStats extends PublicUser {
|
||||
level: LevelInfo;
|
||||
approvedCount: number;
|
||||
badges: Badge[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly database: WritableDbService,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
/** 开机预置测试账号,便于演示/测试(生产可用 SEED_TEST_USERS=false 关闭)。*/
|
||||
onModuleInit() {
|
||||
if (process.env.SEED_TEST_USERS === 'false') return;
|
||||
const accounts: { email: string; password: string; displayName: string; role: Role }[] = [
|
||||
{ email: 'admin@demo.com', password: 'demo1234', displayName: '演示管理员', role: 'admin' },
|
||||
{ email: 'mod@demo.com', password: 'demo1234', displayName: '演示版主', role: 'moderator' },
|
||||
{ email: 'user@demo.com', password: 'demo1234', displayName: '演示用户', role: 'user' },
|
||||
];
|
||||
const db = this.database.db;
|
||||
for (const a of accounts) {
|
||||
const email = a.email.toLowerCase();
|
||||
const existing: any = db
|
||||
.prepare('SELECT id, role FROM users WHERE email = ?')
|
||||
.get(email);
|
||||
if (existing) {
|
||||
if (existing.role !== a.role) {
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(a.role, existing.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
db.prepare(
|
||||
`INSERT INTO users (email, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(email, bcrypt.hashSync(a.password, 10), a.displayName, a.role);
|
||||
}
|
||||
}
|
||||
|
||||
private toPublic(row: any): PublicUser {
|
||||
return {
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
displayName: row.display_name,
|
||||
role: row.role,
|
||||
contributionPoints: row.contribution_points,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
private sign(user: PublicUser): string {
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
return this.jwt.sign(payload);
|
||||
}
|
||||
|
||||
async register(dto: RegisterDto): Promise<{ token: string; user: PublicUser }> {
|
||||
const db = this.database.db;
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const exists = db.prepare('SELECT 1 FROM users WHERE email = ?').get(email);
|
||||
if (exists) throw new ConflictException('该邮箱已注册');
|
||||
const hash = await bcrypt.hash(dto.password, 10);
|
||||
const adminEmail = (process.env.ADMIN_EMAIL || '').trim().toLowerCase();
|
||||
const role: Role = adminEmail && email === adminEmail ? 'admin' : 'user';
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO users (email, password_hash, display_name, role)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
)
|
||||
.run(email, hash, dto.displayName.trim(), role);
|
||||
const row = db
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.get(info.lastInsertRowid);
|
||||
const user = this.toPublic(row);
|
||||
return { token: this.sign(user), user };
|
||||
}
|
||||
|
||||
async login(dto: LoginDto): Promise<{ token: string; user: PublicUser }> {
|
||||
const db = this.database.db;
|
||||
const email = dto.email.trim().toLowerCase();
|
||||
const row: any = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
|
||||
if (!row) throw new UnauthorizedException('邮箱或密码错误');
|
||||
const ok = await bcrypt.compare(dto.password, row.password_hash);
|
||||
if (!ok) throw new UnauthorizedException('邮箱或密码错误');
|
||||
const user = this.toPublic(row);
|
||||
return { token: this.sign(user), user };
|
||||
}
|
||||
|
||||
me(userId: number): UserStats {
|
||||
const row = this.database.db
|
||||
.prepare('SELECT * FROM users WHERE id = ?')
|
||||
.get(userId);
|
||||
if (!row) throw new UnauthorizedException('用户不存在');
|
||||
const user = this.toPublic(row);
|
||||
const approvedCount = (
|
||||
this.database.db
|
||||
.prepare(
|
||||
"SELECT COUNT(*) AS n FROM revisions WHERE author_id = ? AND status = 'approved'",
|
||||
)
|
||||
.get(userId) as { n: number }
|
||||
).n;
|
||||
return {
|
||||
...user,
|
||||
level: levelFor(user.contributionPoints),
|
||||
approvedCount,
|
||||
badges: badgesFor(user.contributionPoints, approvedCount),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class RegisterDto {
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6, { message: '密码至少 6 位' })
|
||||
@MaxLength(72)
|
||||
password!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2, { message: '昵称至少 2 个字符' })
|
||||
@MaxLength(30)
|
||||
displayName!: string;
|
||||
}
|
||||
|
||||
export class LoginDto {
|
||||
@IsEmail({}, { message: '邮箱格式不正确' })
|
||||
email!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(6)
|
||||
password!: string;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
createParamDecorator,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
export type Role = 'guest' | 'user' | 'trusted' | 'moderator' | 'admin';
|
||||
|
||||
/** 角色等级,用于"至少某角色"的比较。*/
|
||||
export const ROLE_RANK: Record<Role, number> = {
|
||||
guest: 0,
|
||||
user: 1,
|
||||
trusted: 2,
|
||||
moderator: 3,
|
||||
admin: 4,
|
||||
};
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
role: Role;
|
||||
}
|
||||
|
||||
export const ROLES_KEY = 'roles';
|
||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||
|
||||
/** 取当前登录用户(来自 JwtAuthGuard 注入的 req.user)。*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_data: unknown, ctx: ExecutionContext) =>
|
||||
ctx.switchToHttp().getRequest().user as JwtPayload,
|
||||
);
|
||||
|
||||
function extractToken(req: any): string | null {
|
||||
const h = req.headers?.authorization;
|
||||
if (typeof h === 'string' && h.startsWith('Bearer ')) return h.slice(7);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private readonly jwt: JwtService) {}
|
||||
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
const token = extractToken(req);
|
||||
if (!token) throw new UnauthorizedException('需要登录');
|
||||
try {
|
||||
req.user = this.jwt.verify<JwtPayload>(token);
|
||||
return true;
|
||||
} catch {
|
||||
throw new UnauthorizedException('登录已失效');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 需要"至少"指定角色之一(按等级比较)。配合 @Roles 与 JwtAuthGuard 使用。*/
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(ctx: ExecutionContext): boolean {
|
||||
const required = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||
ctx.getHandler(),
|
||||
ctx.getClass(),
|
||||
]);
|
||||
if (!required || required.length === 0) return true;
|
||||
const user = ctx.switchToHttp().getRequest().user as JwtPayload | undefined;
|
||||
if (!user) throw new UnauthorizedException('需要登录');
|
||||
const min = Math.min(...required.map((r) => ROLE_RANK[r]));
|
||||
if (ROLE_RANK[user.role] < min) {
|
||||
throw new ForbiddenException('权限不足');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Controller('api/categories')
|
||||
export class CategoriesController {
|
||||
constructor(private readonly categories: CategoriesService) {}
|
||||
|
||||
@Get()
|
||||
findAll() {
|
||||
return this.categories.findAll();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CategoriesController } from './categories.controller';
|
||||
import { CategoriesService } from './categories.service';
|
||||
|
||||
@Module({
|
||||
controllers: [CategoriesController],
|
||||
providers: [CategoriesService],
|
||||
})
|
||||
export class CategoriesModule {}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class CategoriesService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
/** 分类列表 + 各分类车型/个体计数。*/
|
||||
findAll(): any[] {
|
||||
return this.database.db
|
||||
.prepare(
|
||||
`SELECT c.id, c.name, c.subcat,
|
||||
(SELECT COUNT(*) FROM model m WHERE m.category_id = c.id) AS modelCount,
|
||||
(SELECT COUNT(*) FROM unit u WHERE u.category_id = c.id) AS unitCount
|
||||
FROM category c
|
||||
ORDER BY c.name, c.subcat`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { CommunityService } from './community.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
@Controller('api')
|
||||
export class CommunityController {
|
||||
constructor(private readonly community: CommunityService) {}
|
||||
|
||||
@Get('leaderboard')
|
||||
leaderboard(@Query('limit') limit?: string) {
|
||||
const n = limit ? Math.min(100, Math.max(1, Number(limit))) : 20;
|
||||
return this.community.leaderboard(n);
|
||||
}
|
||||
|
||||
@Get('models/:id/maintainers')
|
||||
maintainers(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.community.maintainers(id);
|
||||
}
|
||||
|
||||
@Post('models/:id/maintainers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
claim(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.community.claim(id, user.sub);
|
||||
}
|
||||
|
||||
@Delete('models/:id/maintainers')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
unclaim(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.community.unclaim(id, user.sub);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommunityService } from './community.service';
|
||||
import { CommunityController } from './community.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [CommunityController],
|
||||
providers: [CommunityService],
|
||||
})
|
||||
export class CommunityModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { levelFor } from './levels';
|
||||
|
||||
@Injectable()
|
||||
export class CommunityService {
|
||||
constructor(private readonly wdb: WritableDbService) {}
|
||||
|
||||
/** 贡献榜(公开):按积分降序。*/
|
||||
leaderboard(limit = 20) {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT id, display_name, role, contribution_points
|
||||
FROM users WHERE contribution_points > 0
|
||||
ORDER BY contribution_points DESC, id ASC LIMIT ?`,
|
||||
)
|
||||
.all(limit) as any[];
|
||||
return rows.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
id: r.id,
|
||||
displayName: r.display_name,
|
||||
role: r.role,
|
||||
points: r.contribution_points,
|
||||
title: levelFor(r.contribution_points).title,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 某车型的维护者署名列表。*/
|
||||
maintainers(modelId: number) {
|
||||
return this.wdb.db
|
||||
.prepare(
|
||||
`SELECT u.id, u.display_name AS displayName, u.role, m.created_at AS since
|
||||
FROM model_maintainers m JOIN users u ON u.id = m.user_id
|
||||
WHERE m.model_id = ? ORDER BY m.created_at ASC`,
|
||||
)
|
||||
.all(modelId);
|
||||
}
|
||||
|
||||
claim(modelId: number, userId: number) {
|
||||
this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO model_maintainers (model_id, user_id) VALUES (?, ?)
|
||||
ON CONFLICT(model_id, user_id) DO NOTHING`,
|
||||
)
|
||||
.run(modelId, userId);
|
||||
return this.maintainers(modelId);
|
||||
}
|
||||
|
||||
unclaim(modelId: number, userId: number) {
|
||||
this.wdb.db
|
||||
.prepare('DELETE FROM model_maintainers WHERE model_id = ? AND user_id = ?')
|
||||
.run(modelId, userId);
|
||||
return this.maintainers(modelId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { badgesFor, levelFor } from './levels';
|
||||
|
||||
describe('levelFor', () => {
|
||||
it('0 分为见习巡道员', () => {
|
||||
const l = levelFor(0);
|
||||
expect(l.title).toBe('见习巡道员');
|
||||
expect(l.level).toBe(1);
|
||||
expect(l.nextThreshold).toBe(10);
|
||||
});
|
||||
it('跨阈值升级', () => {
|
||||
expect(levelFor(30).title).toBe('司炉');
|
||||
expect(levelFor(159).title).toBe('副司机');
|
||||
expect(levelFor(160).title).toBe('司机');
|
||||
});
|
||||
it('满级 nextThreshold 为 null', () => {
|
||||
const l = levelFor(1000);
|
||||
expect(l.title).toBe('总工程师');
|
||||
expect(l.nextThreshold).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('badgesFor', () => {
|
||||
it('按积分/采纳数派发', () => {
|
||||
expect(badgesFor(0, 0)).toHaveLength(0);
|
||||
expect(badgesFor(5, 1).map((b) => b.key)).toContain('first_edit');
|
||||
expect(badgesFor(200, 12).map((b) => b.key)).toEqual(
|
||||
expect.arrayContaining(['first_edit', 'prolific', 'veteran']),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/** 铁路主题等级(按贡献积分)。纯函数,便于单测。*/
|
||||
export interface LevelInfo {
|
||||
level: number;
|
||||
title: string;
|
||||
min: number;
|
||||
nextThreshold: number | null; // 升到下一级所需积分;满级为 null
|
||||
}
|
||||
|
||||
export const LEVELS: { min: number; title: string }[] = [
|
||||
{ min: 0, title: '见习巡道员' },
|
||||
{ min: 10, title: '巡道员' },
|
||||
{ min: 30, title: '司炉' },
|
||||
{ min: 80, title: '副司机' },
|
||||
{ min: 160, title: '司机' },
|
||||
{ min: 320, title: '机务段长' },
|
||||
{ min: 640, title: '总工程师' },
|
||||
];
|
||||
|
||||
export function levelFor(points: number): LevelInfo {
|
||||
const p = Math.max(0, points || 0);
|
||||
let idx = 0;
|
||||
for (let i = 0; i < LEVELS.length; i++) if (p >= LEVELS[i].min) idx = i;
|
||||
const next = LEVELS[idx + 1] ?? null;
|
||||
return {
|
||||
level: idx + 1,
|
||||
title: LEVELS[idx].title,
|
||||
min: LEVELS[idx].min,
|
||||
nextThreshold: next ? next.min : null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Badge {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 根据积分与被采纳修订数派生徽章。*/
|
||||
export function badgesFor(points: number, approvedCount: number): Badge[] {
|
||||
const out: Badge[] = [];
|
||||
if (approvedCount >= 1) out.push({ key: 'first_edit', label: '首改采纳' });
|
||||
if (approvedCount >= 10) out.push({ key: 'prolific', label: '高产编辑' });
|
||||
if (points >= 160) out.push({ key: 'veteran', label: '资深贡献' });
|
||||
if (points >= 640) out.push({ key: 'chief', label: '总工殊荣' });
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsObject, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { RevisionsService } from './revisions.service';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtAuthGuard,
|
||||
Roles,
|
||||
RolesGuard,
|
||||
type JwtPayload,
|
||||
} from '../auth/roles';
|
||||
|
||||
class SubmitEditDto {
|
||||
@IsObject()
|
||||
changes!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** 编辑/修订(登录用户)。*/
|
||||
@Controller('api/models/:id/revisions')
|
||||
export class ModelRevisionsController {
|
||||
constructor(private readonly revisions: RevisionsService) {}
|
||||
|
||||
@Get()
|
||||
history(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.revisions.listForModel(id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
submit(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: SubmitEditDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.revisions.submitEdit(id, user, dto.changes, dto.note ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/** 审核(版主及以上)+ 可编辑字段元数据。*/
|
||||
@Controller('api')
|
||||
export class ReviewController {
|
||||
constructor(private readonly revisions: RevisionsService) {}
|
||||
|
||||
@Get('editable-fields')
|
||||
fields() {
|
||||
return this.revisions.editableFields();
|
||||
}
|
||||
|
||||
@Get('revisions/pending')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('moderator')
|
||||
pending() {
|
||||
return this.revisions.listPending();
|
||||
}
|
||||
|
||||
@Post('revisions/:rid/approve')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('moderator')
|
||||
approve(@Param('rid', ParseIntPipe) rid: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.revisions.approve(rid, user);
|
||||
}
|
||||
|
||||
@Post('revisions/:rid/reject')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('moderator')
|
||||
reject(@Param('rid', ParseIntPipe) rid: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.revisions.reject(rid, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RevisionsService } from './revisions.service';
|
||||
import { ModelRevisionsController, ReviewController } from './contrib.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ModelRevisionsController, ReviewController],
|
||||
providers: [RevisionsService],
|
||||
exports: [RevisionsService],
|
||||
})
|
||||
export class ContribModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export type FieldType = 'text' | 'int';
|
||||
|
||||
export interface EditableField {
|
||||
field: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
}
|
||||
|
||||
/** 可众包编辑的字段白名单(映射到 model 表列)。*/
|
||||
export const EDITABLE_FIELDS: EditableField[] = [
|
||||
{ field: 'full_name', label: '车型全称', type: 'text' },
|
||||
{ field: 'series', label: '系列', type: 'text' },
|
||||
{ field: 'manufacturer', label: '生产商', type: 'text' },
|
||||
{ field: 'country', label: '制造国/地区', type: 'text' },
|
||||
{ field: 'country_type', label: '国别属性', type: 'text' },
|
||||
{ field: 'usage', label: '用途', type: 'text' },
|
||||
{ field: 'status', label: '状态', type: 'text' },
|
||||
{ field: 'drive', label: '传动/供电方式', type: 'text' },
|
||||
{ field: 'efficiency', label: '传动效率', type: 'text' },
|
||||
{ field: 'axle_arrangement', label: '轴列式', type: 'text' },
|
||||
{ field: 'production_count', label: '产量', type: 'text' },
|
||||
{ field: 'first_year', label: '首产年', type: 'int' },
|
||||
{ field: 'last_year', label: '停产年', type: 'int' },
|
||||
{ field: 'max_speed_value', label: '最高时速(值)', type: 'int' },
|
||||
{ field: 'weight_value', label: '整备重量(值)', type: 'int' },
|
||||
];
|
||||
|
||||
export const EDITABLE_MAP = new Map(EDITABLE_FIELDS.map((f) => [f.field, f]));
|
||||
|
||||
export const STATUS_ENUM = [
|
||||
'现役', '封存', '半封存', '退役', '报废', '保存', '试验', '未知',
|
||||
];
|
||||
export const COUNTRY_TYPE_ENUM = ['国产', '进口', '引进仿制', '中外合资', '未知'];
|
||||
|
||||
/** 校验并规范化单个字段值,返回入库用字符串;非法抛 400。*/
|
||||
export function validateField(field: string, raw: unknown): string {
|
||||
const def = EDITABLE_MAP.get(field);
|
||||
if (!def) throw new BadRequestException(`字段不可编辑: ${field}`);
|
||||
const s = String(raw ?? '').trim();
|
||||
|
||||
if (def.type === 'int') {
|
||||
if (s === '') return '';
|
||||
if (!/^-?\d+$/.test(s)) throw new BadRequestException(`${def.label} 必须为整数`);
|
||||
const n = Number(s);
|
||||
if (field === 'first_year' || field === 'last_year') {
|
||||
if (n < 1800 || n > 2100)
|
||||
throw new BadRequestException(`${def.label} 年代超出合理范围`);
|
||||
}
|
||||
if (n < 0) throw new BadRequestException(`${def.label} 不能为负`);
|
||||
return String(n);
|
||||
}
|
||||
|
||||
if (s.length > 200) throw new BadRequestException(`${def.label} 过长`);
|
||||
if (field === 'status' && s && !STATUS_ENUM.includes(s))
|
||||
throw new BadRequestException(`状态取值非法`);
|
||||
if (field === 'country_type' && s && !COUNTRY_TYPE_ENUM.includes(s))
|
||||
throw new BadRequestException(`国别属性取值非法`);
|
||||
return s;
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { ROLE_RANK, type JwtPayload } from '../auth/roles';
|
||||
import { validateField, EDITABLE_FIELDS } from './editable-fields';
|
||||
|
||||
const POINTS_PER_REVISION = 5;
|
||||
|
||||
@Injectable()
|
||||
export class RevisionsService {
|
||||
constructor(
|
||||
private readonly ro: DatabaseService,
|
||||
private readonly wdb: WritableDbService,
|
||||
) {}
|
||||
|
||||
/** 某车型字段的当前生效值(覆盖优先于基础数据),字符串化。*/
|
||||
private effectiveValue(modelId: number, field: string, baseRow: any): string {
|
||||
const ov = this.wdb.db
|
||||
.prepare('SELECT value FROM model_overrides WHERE model_id = ? AND field = ?')
|
||||
.get(modelId, field) as { value: string } | undefined;
|
||||
if (ov) return ov.value ?? '';
|
||||
const v = baseRow?.[field];
|
||||
return v == null ? '' : String(v);
|
||||
}
|
||||
|
||||
private baseRow(modelId: number): any {
|
||||
const row = this.ro.db.prepare('SELECT * FROM model WHERE id = ?').get(modelId);
|
||||
if (!row) throw new NotFoundException(`车型 ${modelId} 不存在`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 提交编辑:生成修订。信任用户及以上自动通过并应用。*/
|
||||
submitEdit(
|
||||
modelId: number,
|
||||
user: JwtPayload,
|
||||
changes: Record<string, unknown>,
|
||||
note = '',
|
||||
) {
|
||||
const base = this.baseRow(modelId);
|
||||
const entries = Object.entries(changes || {});
|
||||
if (entries.length === 0) throw new BadRequestException('没有任何改动');
|
||||
|
||||
const diffs: { field: string; oldValue: string; newValue: string }[] = [];
|
||||
for (const [field, raw] of entries) {
|
||||
const newValue = validateField(field, raw);
|
||||
const oldValue = this.effectiveValue(modelId, field, base);
|
||||
if (newValue !== oldValue) diffs.push({ field, oldValue, newValue });
|
||||
}
|
||||
if (diffs.length === 0) throw new BadRequestException('与当前值相同,无需提交');
|
||||
|
||||
const autoApprove = ROLE_RANK[user.role] >= ROLE_RANK.trusted;
|
||||
const db = this.wdb.db;
|
||||
|
||||
const tx = db.transaction(() => {
|
||||
const info = db
|
||||
.prepare(
|
||||
`INSERT INTO revisions (model_id, author_id, status, note, reviewed_by, reviewed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
modelId,
|
||||
user.sub,
|
||||
autoApprove ? 'approved' : 'pending',
|
||||
note,
|
||||
autoApprove ? user.sub : null,
|
||||
autoApprove ? new Date().toISOString() : null,
|
||||
);
|
||||
const revId = Number(info.lastInsertRowid);
|
||||
const ins = db.prepare(
|
||||
`INSERT INTO revision_changes (revision_id, field, old_value, new_value)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
);
|
||||
for (const d of diffs) ins.run(revId, d.field, d.oldValue, d.newValue);
|
||||
if (autoApprove) {
|
||||
this.applyChanges(modelId, diffs, revId);
|
||||
this.award(user.sub);
|
||||
}
|
||||
return revId;
|
||||
});
|
||||
|
||||
const revId = tx();
|
||||
return this.getRevision(revId);
|
||||
}
|
||||
|
||||
private applyChanges(
|
||||
modelId: number,
|
||||
diffs: { field: string; newValue: string }[],
|
||||
revId: number,
|
||||
) {
|
||||
const up = this.wdb.db.prepare(
|
||||
`INSERT INTO model_overrides (model_id, field, value, revision_id, updated_at)
|
||||
VALUES (?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(model_id, field) DO UPDATE SET
|
||||
value = excluded.value, revision_id = excluded.revision_id, updated_at = datetime('now')`,
|
||||
);
|
||||
for (const d of diffs) up.run(modelId, d.field, d.newValue, revId);
|
||||
}
|
||||
|
||||
private award(userId: number, points = POINTS_PER_REVISION) {
|
||||
this.wdb.db
|
||||
.prepare('UPDATE users SET contribution_points = contribution_points + ? WHERE id = ?')
|
||||
.run(points, userId);
|
||||
}
|
||||
|
||||
approve(revId: number, reviewer: JwtPayload) {
|
||||
const db = this.wdb.db;
|
||||
const rev: any = db.prepare('SELECT * FROM revisions WHERE id = ?').get(revId);
|
||||
if (!rev) throw new NotFoundException('修订不存在');
|
||||
if (rev.status !== 'pending') throw new BadRequestException('该修订已处理');
|
||||
const changes = db
|
||||
.prepare('SELECT field, new_value AS newValue FROM revision_changes WHERE revision_id = ?')
|
||||
.all(revId) as { field: string; newValue: string }[];
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
`UPDATE revisions SET status='approved', reviewed_by=?, reviewed_at=datetime('now') WHERE id=?`,
|
||||
).run(reviewer.sub, revId);
|
||||
this.applyChanges(rev.model_id, changes, revId);
|
||||
this.award(rev.author_id);
|
||||
});
|
||||
tx();
|
||||
return this.getRevision(revId);
|
||||
}
|
||||
|
||||
reject(revId: number, reviewer: JwtPayload) {
|
||||
const db = this.wdb.db;
|
||||
const rev: any = db.prepare('SELECT * FROM revisions WHERE id = ?').get(revId);
|
||||
if (!rev) throw new NotFoundException('修订不存在');
|
||||
if (rev.status !== 'pending') throw new BadRequestException('该修订已处理');
|
||||
db.prepare(
|
||||
`UPDATE revisions SET status='rejected', reviewed_by=?, reviewed_at=datetime('now') WHERE id=?`,
|
||||
).run(reviewer.sub, revId);
|
||||
return this.getRevision(revId);
|
||||
}
|
||||
|
||||
getRevision(revId: number) {
|
||||
const db = this.wdb.db;
|
||||
const rev: any = db
|
||||
.prepare(
|
||||
`SELECT r.*, u.display_name AS author_name
|
||||
FROM revisions r JOIN users u ON u.id = r.author_id WHERE r.id = ?`,
|
||||
)
|
||||
.get(revId);
|
||||
if (!rev) throw new NotFoundException('修订不存在');
|
||||
rev.changes = db
|
||||
.prepare('SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?')
|
||||
.all(revId);
|
||||
return rev;
|
||||
}
|
||||
|
||||
listForModel(modelId: number) {
|
||||
const db = this.wdb.db;
|
||||
const revs = db
|
||||
.prepare(
|
||||
`SELECT r.*, u.display_name AS author_name
|
||||
FROM revisions r JOIN users u ON u.id = r.author_id
|
||||
WHERE r.model_id = ? ORDER BY r.created_at DESC, r.id DESC`,
|
||||
)
|
||||
.all(modelId) as any[];
|
||||
const stmt = db.prepare(
|
||||
'SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?',
|
||||
);
|
||||
for (const r of revs) r.changes = stmt.all(r.id);
|
||||
return revs;
|
||||
}
|
||||
|
||||
listPending() {
|
||||
const db = this.wdb.db;
|
||||
const revs = db
|
||||
.prepare(
|
||||
`SELECT r.*, u.display_name AS author_name
|
||||
FROM revisions r JOIN users u ON u.id = r.author_id
|
||||
WHERE r.status = 'pending' ORDER BY r.created_at ASC`,
|
||||
)
|
||||
.all() as any[];
|
||||
const stmt = db.prepare(
|
||||
'SELECT field, old_value, new_value FROM revision_changes WHERE revision_id = ?',
|
||||
);
|
||||
for (const r of revs) r.changes = stmt.all(r.id);
|
||||
return revs;
|
||||
}
|
||||
|
||||
/** 供 ModelsService 合并:返回 model 的字段覆盖 map。*/
|
||||
overridesFor(modelId: number): Record<string, string> {
|
||||
const rows = this.wdb.db
|
||||
.prepare('SELECT field, value FROM model_overrides WHERE model_id = ?')
|
||||
.all(modelId) as { field: string; value: string }[];
|
||||
const out: Record<string, string> = {};
|
||||
for (const r of rows) out[r.field] = r.value;
|
||||
return out;
|
||||
}
|
||||
|
||||
editableFields() {
|
||||
return EDITABLE_FIELDS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [DatabaseService],
|
||||
exports: [DatabaseService],
|
||||
})
|
||||
export class DatabaseModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import Database from 'better-sqlite3';
|
||||
import { existsSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* 打开 ETL 产出的 SQLite 数据库(app/data/machines.db)。
|
||||
* 开发期数据底座;schema 设计为可移植到 PostgreSQL。
|
||||
* DB 路径可通过环境变量 DB_PATH 覆盖(测试用)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class DatabaseService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(DatabaseService.name);
|
||||
public readonly db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
const dbPath =
|
||||
process.env.DB_PATH ||
|
||||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'machines.db'));
|
||||
if (!existsSync(dbPath)) {
|
||||
this.logger.warn(
|
||||
`数据库不存在: ${dbPath} —— 请先运行 ETL: python3 -m app.etl.importer`,
|
||||
);
|
||||
}
|
||||
this.db = new Database(dbPath, { readonly: true, fileMustExist: false });
|
||||
// 只读连接无需(也不允许)设置 WAL;容错跳过。
|
||||
try {
|
||||
this.db.pragma('query_only = ON');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { WritableDbService } from './writable-db.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [WritableDbService],
|
||||
exports: [WritableDbService],
|
||||
})
|
||||
export class WritableDbModule {}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdirSync } from 'fs';
|
||||
import { dirname, join, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* 可写库(用户 / 社区数据)。与只读的 machines.db 分离——后者由 ETL 重建会被清空。
|
||||
* 默认 app/data/app.db,可用 APP_DB_PATH 覆盖(测试用)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class WritableDbService implements OnModuleDestroy {
|
||||
private readonly logger = new Logger(WritableDbService.name);
|
||||
public readonly db: Database.Database;
|
||||
|
||||
constructor() {
|
||||
const dbPath =
|
||||
process.env.APP_DB_PATH ||
|
||||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'app.db'));
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
this.db = new Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
this.migrate();
|
||||
this.logger.log(`可写库就绪: ${dbPath}`);
|
||||
}
|
||||
|
||||
private migrate() {
|
||||
this.db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
contribution_points INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending/approved/rejected
|
||||
note TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
reviewed_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rev_model ON revisions(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rev_status ON revisions(status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS revision_changes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE,
|
||||
field TEXT NOT NULL,
|
||||
old_value TEXT,
|
||||
new_value TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_revchg_rev ON revision_changes(revision_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_overrides (
|
||||
model_id INTEGER NOT NULL,
|
||||
field TEXT NOT NULL,
|
||||
value TEXT,
|
||||
revision_id INTEGER REFERENCES revisions(id),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (model_id, field)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_maintainers (
|
||||
model_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (model_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS threads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
board TEXT NOT NULL, -- general/photography/history/entry
|
||||
model_id INTEGER, -- 非空表示绑定到某车型(词条讨论)
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
author_id INTEGER NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_activity_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
reply_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_board ON threads(board);
|
||||
CREATE INDEX IF NOT EXISTS idx_threads_model ON threads(model_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS thread_replies (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
|
||||
author_id INTEGER NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_replies_thread ON thread_replies(thread_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sightings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
car_number TEXT DEFAULT '',
|
||||
lat REAL NOT NULL,
|
||||
lng REAL NOT NULL,
|
||||
station TEXT DEFAULT '',
|
||||
spotted_at TEXT DEFAULT '',
|
||||
description TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sightings_model ON sightings(model_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sightings_created ON sightings(created_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS photos (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
model_id INTEGER NOT NULL,
|
||||
uploader_id INTEGER NOT NULL REFERENCES users(id),
|
||||
filename TEXT NOT NULL,
|
||||
caption TEXT DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'confirmed',
|
||||
source_url TEXT DEFAULT '',
|
||||
author TEXT DEFAULT '',
|
||||
license TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_model ON photos(model_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS identifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
filename TEXT NOT NULL,
|
||||
image_hash TEXT NOT NULL,
|
||||
summary TEXT DEFAULT '',
|
||||
guesses_json TEXT DEFAULT '[]',
|
||||
matches_json TEXT DEFAULT '[]',
|
||||
top_code TEXT DEFAULT '',
|
||||
top_name TEXT DEFAULT '',
|
||||
note TEXT DEFAULT '',
|
||||
error TEXT DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ident_user ON identifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ident_hash ON identifications(image_hash);
|
||||
`);
|
||||
// 已存在的库补齐新列(无 IF NOT EXISTS,需手动检查)
|
||||
this.ensureColumns('photos', {
|
||||
status: "TEXT NOT NULL DEFAULT 'confirmed'",
|
||||
source_url: "TEXT DEFAULT ''",
|
||||
author: "TEXT DEFAULT ''",
|
||||
license: "TEXT DEFAULT ''",
|
||||
featured: 'INTEGER NOT NULL DEFAULT 0',
|
||||
});
|
||||
}
|
||||
|
||||
private ensureColumns(table: string, cols: Record<string, string>) {
|
||||
const existing = new Set(
|
||||
(this.db.prepare(`PRAGMA table_info(${table})`).all() as any[]).map(
|
||||
(c) => c.name,
|
||||
),
|
||||
);
|
||||
for (const [name, def] of Object.entries(cols)) {
|
||||
if (!existing.has(name)) {
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ForumService } from './forum.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
class CreateThreadDto {
|
||||
@IsString() @MinLength(1) @MaxLength(40)
|
||||
board!: string;
|
||||
|
||||
@IsOptional() @Type(() => Number) @IsInt()
|
||||
modelId?: number;
|
||||
|
||||
@IsString() @MinLength(2) @MaxLength(80)
|
||||
title!: string;
|
||||
|
||||
@IsString() @MinLength(1) @MaxLength(5000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
class ReplyDto {
|
||||
@IsString() @MinLength(1) @MaxLength(5000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
@Controller('api')
|
||||
export class ForumController {
|
||||
constructor(private readonly forum: ForumService) {}
|
||||
|
||||
@Get('boards')
|
||||
boards() {
|
||||
return this.forum.boards();
|
||||
}
|
||||
|
||||
@Get('threads')
|
||||
list(
|
||||
@Query('board') board?: string,
|
||||
@Query('modelId') modelId?: string,
|
||||
) {
|
||||
return this.forum.listThreads({
|
||||
board,
|
||||
modelId: modelId ? Number(modelId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('threads/:id')
|
||||
get(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.forum.getThread(id);
|
||||
}
|
||||
|
||||
@Post('threads')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
create(@Body() dto: CreateThreadDto, @CurrentUser() user: JwtPayload) {
|
||||
return this.forum.createThread(user.sub, dto);
|
||||
}
|
||||
|
||||
@Post('threads/:id/replies')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
reply(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: ReplyDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.forum.addReply(user.sub, id, dto.body);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ForumService } from './forum.service';
|
||||
import { ForumController } from './forum.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ForumController],
|
||||
providers: [ForumService],
|
||||
})
|
||||
export class ForumModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
|
||||
export const BOARDS = [
|
||||
{ key: 'general', label: '综合讨论' },
|
||||
{ key: 'photography', label: '拍车 / 打卡' },
|
||||
{ key: 'history', label: '历史考证' },
|
||||
];
|
||||
const BOARD_KEYS = new Set([...BOARDS.map((b) => b.key), 'entry']);
|
||||
|
||||
@Injectable()
|
||||
export class ForumService {
|
||||
constructor(private readonly wdb: WritableDbService) {}
|
||||
|
||||
boards() {
|
||||
return BOARDS;
|
||||
}
|
||||
|
||||
listThreads(opts: { board?: string; modelId?: number; limit?: number }) {
|
||||
const conds: string[] = [];
|
||||
const params: any[] = [];
|
||||
if (opts.modelId != null) {
|
||||
conds.push('t.model_id = ?');
|
||||
params.push(opts.modelId);
|
||||
} else if (opts.board) {
|
||||
conds.push('t.board = ?');
|
||||
params.push(opts.board);
|
||||
}
|
||||
const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 50));
|
||||
return this.wdb.db
|
||||
.prepare(
|
||||
`SELECT t.id, t.board, t.model_id, t.title, t.author_id,
|
||||
u.display_name AS author_name, t.created_at,
|
||||
t.last_activity_at, t.reply_count
|
||||
FROM threads t JOIN users u ON u.id = t.author_id
|
||||
${where}
|
||||
ORDER BY t.last_activity_at DESC, t.id DESC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(...params, limit);
|
||||
}
|
||||
|
||||
createThread(
|
||||
userId: number,
|
||||
data: { board: string; modelId?: number | null; title: string; body: string },
|
||||
) {
|
||||
const board = BOARD_KEYS.has(data.board) ? data.board : 'general';
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO threads (board, model_id, title, body, author_id)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(board, data.modelId ?? null, data.title.trim(), data.body.trim(), userId);
|
||||
return this.getThread(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
getThread(id: number) {
|
||||
const t: any = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT t.*, u.display_name AS author_name
|
||||
FROM threads t JOIN users u ON u.id = t.author_id WHERE t.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!t) throw new NotFoundException('帖子不存在');
|
||||
t.replies = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT r.id, r.body, r.author_id, u.display_name AS author_name, r.created_at
|
||||
FROM thread_replies r JOIN users u ON u.id = r.author_id
|
||||
WHERE r.thread_id = ? ORDER BY r.created_at ASC, r.id ASC`,
|
||||
)
|
||||
.all(id);
|
||||
return t;
|
||||
}
|
||||
|
||||
addReply(userId: number, threadId: number, body: string) {
|
||||
const db = this.wdb.db;
|
||||
const exists = db.prepare('SELECT 1 FROM threads WHERE id = ?').get(threadId);
|
||||
if (!exists) throw new NotFoundException('帖子不存在');
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
'INSERT INTO thread_replies (thread_id, author_id, body) VALUES (?, ?, ?)',
|
||||
).run(threadId, userId, body.trim());
|
||||
db.prepare(
|
||||
`UPDATE threads SET reply_count = reply_count + 1,
|
||||
last_activity_at = datetime('now') WHERE id = ?`,
|
||||
).run(threadId);
|
||||
});
|
||||
tx();
|
||||
return this.getThread(threadId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { DatabaseService } from './db/database.service';
|
||||
|
||||
@Controller('api/health')
|
||||
export class HealthController {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
@Get()
|
||||
health() {
|
||||
const models = (
|
||||
this.database.db.prepare('SELECT COUNT(*) AS n FROM model').get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
const units = (
|
||||
this.database.db.prepare('SELECT COUNT(*) AS n FROM unit').get() as {
|
||||
n: number;
|
||||
}
|
||||
).n;
|
||||
return { status: 'ok', models, units };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { IdentifyService } from './identify.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
const ALLOWED = /\.(jpe?g|png|webp|gif)$/i;
|
||||
|
||||
@Controller('api')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class IdentifyController {
|
||||
constructor(private readonly svc: IdentifyService) {}
|
||||
|
||||
/** AI 识车:上传一张照片,调用通义千问视觉模型识别并持久化(命中哈希缓存则复用)。*/
|
||||
@Post('identify')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage: memoryStorage(),
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) =>
|
||||
ALLOWED.test(file.originalname) || /^image\//.test(file.mimetype)
|
||||
? cb(null, true)
|
||||
: cb(new BadRequestException('仅支持图片文件'), false),
|
||||
}),
|
||||
)
|
||||
async identify(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('未收到图片');
|
||||
return this.svc.identifyAndSave(user.sub, file.buffer, file.mimetype);
|
||||
}
|
||||
|
||||
/** 当前用户的识别历史。*/
|
||||
@Get('identifications')
|
||||
list(@CurrentUser() user: JwtPayload) {
|
||||
return this.svc.list(user.sub);
|
||||
}
|
||||
|
||||
/** 修改备注(人工纠正/标注)。*/
|
||||
@Patch('identifications/:id')
|
||||
update(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('note') note: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.svc.updateNote(user.sub, id, note ?? '');
|
||||
}
|
||||
|
||||
/** 删除一条识别历史。*/
|
||||
@Delete('identifications/:id')
|
||||
remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: JwtPayload) {
|
||||
return this.svc.remove(user.sub, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { IdentifyController } from './identify.controller';
|
||||
import { IdentifyService } from './identify.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [IdentifyController],
|
||||
providers: [IdentifyService],
|
||||
})
|
||||
export class IdentifyModule {}
|
||||
@@ -0,0 +1,293 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomBytes } from 'crypto';
|
||||
import { unlinkSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { PUBLIC_PREFIX, uploadDir } from '../photos/uploads';
|
||||
|
||||
/** DashScope(阿里云·通义千问)OpenAI 兼容多模态接口。*/
|
||||
const ENDPOINT =
|
||||
process.env.DASHSCOPE_ENDPOINT ||
|
||||
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
|
||||
const MODEL = process.env.DASHSCOPE_VL_MODEL || 'qwen-vl-plus';
|
||||
|
||||
export interface Guess {
|
||||
model_code: string;
|
||||
name: string;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
}
|
||||
export interface ModelMatch {
|
||||
id: number;
|
||||
model_code: string;
|
||||
category: string;
|
||||
}
|
||||
export interface Identification {
|
||||
id: number;
|
||||
url: string;
|
||||
summary: string;
|
||||
guesses: Guess[];
|
||||
matches: ModelMatch[];
|
||||
topCode: string;
|
||||
topName: string;
|
||||
note: string;
|
||||
error: string;
|
||||
cached: boolean;
|
||||
configured: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const EXT: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/jpg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'image/gif': '.gif',
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class IdentifyService {
|
||||
private readonly logger = new Logger(IdentifyService.name);
|
||||
constructor(
|
||||
private readonly db: DatabaseService,
|
||||
private readonly wdb: WritableDbService,
|
||||
) {}
|
||||
|
||||
/** 识别并持久化:命中图片哈希缓存则复用结果(不再调用 LLM)。*/
|
||||
async identifyAndSave(
|
||||
userId: number,
|
||||
buffer: Buffer,
|
||||
mimetype: string,
|
||||
): Promise<Identification> {
|
||||
const hash = createHash('sha256').update(buffer).digest('hex');
|
||||
|
||||
// 缓存:同一张图(哈希一致)此前已成功识别 → 复用结果与图片文件
|
||||
const cachedRow: any = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT * FROM identifications
|
||||
WHERE image_hash = ? AND guesses_json <> '[]' AND error = ''
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
)
|
||||
.get(hash);
|
||||
|
||||
let filename: string;
|
||||
let summary = '';
|
||||
let guesses: Guess[] = [];
|
||||
let matches: ModelMatch[] = [];
|
||||
let error = '';
|
||||
let cached = false;
|
||||
let configured = true;
|
||||
|
||||
if (cachedRow) {
|
||||
cached = true;
|
||||
filename = cachedRow.filename;
|
||||
summary = cachedRow.summary || '';
|
||||
guesses = JSON.parse(cachedRow.guesses_json || '[]');
|
||||
matches = JSON.parse(cachedRow.matches_json || '[]');
|
||||
} else {
|
||||
// 落盘图片
|
||||
const ext = EXT[mimetype] || '.jpg';
|
||||
filename = `idf-${Date.now()}-${randomBytes(5).toString('hex')}${ext}`;
|
||||
writeFileSync(join(uploadDir(), filename), buffer);
|
||||
|
||||
const dataUrl = `data:${mimetype};base64,${buffer.toString('base64')}`;
|
||||
const r = await this.runModel(dataUrl);
|
||||
configured = r.configured;
|
||||
error = r.error;
|
||||
summary = r.summary;
|
||||
guesses = r.guesses;
|
||||
matches = r.matches;
|
||||
}
|
||||
|
||||
const top = guesses[0];
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO identifications
|
||||
(user_id, filename, image_hash, summary, guesses_json, matches_json, top_code, top_name, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
userId,
|
||||
filename,
|
||||
hash,
|
||||
summary,
|
||||
JSON.stringify(guesses),
|
||||
JSON.stringify(matches),
|
||||
top?.model_code ?? '',
|
||||
top?.name ?? '',
|
||||
error,
|
||||
);
|
||||
|
||||
const row: any = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE id = ?')
|
||||
.get(Number(info.lastInsertRowid));
|
||||
return { ...this.toPublic(row), cached, configured };
|
||||
}
|
||||
|
||||
list(userId: number): Identification[] {
|
||||
const rows = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE user_id = ? ORDER BY id DESC')
|
||||
.all(userId) as any[];
|
||||
return rows.map((r) => this.toPublic(r));
|
||||
}
|
||||
|
||||
updateNote(userId: number, id: number, note: string): Identification {
|
||||
const row: any = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE id = ? AND user_id = ?')
|
||||
.get(id, userId);
|
||||
if (!row) throw new NotFoundException('记录不存在');
|
||||
this.wdb.db
|
||||
.prepare('UPDATE identifications SET note = ? WHERE id = ?')
|
||||
.run((note ?? '').slice(0, 500), id);
|
||||
return this.toPublic(
|
||||
this.wdb.db.prepare('SELECT * FROM identifications WHERE id = ?').get(id),
|
||||
);
|
||||
}
|
||||
|
||||
remove(userId: number, id: number): { ok: boolean } {
|
||||
const row: any = this.wdb.db
|
||||
.prepare('SELECT * FROM identifications WHERE id = ? AND user_id = ?')
|
||||
.get(id, userId);
|
||||
if (!row) throw new NotFoundException('记录不存在');
|
||||
this.wdb.db.prepare('DELETE FROM identifications WHERE id = ?').run(id);
|
||||
// 仅当没有其它记录共用该图片文件(缓存去重)时删除文件
|
||||
const others = this.wdb.db
|
||||
.prepare('SELECT 1 FROM identifications WHERE filename = ? LIMIT 1')
|
||||
.get(row.filename);
|
||||
if (!others) {
|
||||
try {
|
||||
unlinkSync(join(uploadDir(), row.filename));
|
||||
} catch {
|
||||
/* 文件可能已不存在 */
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private toPublic(row: any): Identification {
|
||||
return {
|
||||
id: row.id,
|
||||
url: `${PUBLIC_PREFIX}/${row.filename}`,
|
||||
summary: row.summary || '',
|
||||
guesses: JSON.parse(row.guesses_json || '[]'),
|
||||
matches: JSON.parse(row.matches_json || '[]'),
|
||||
topCode: row.top_code || '',
|
||||
topName: row.top_name || '',
|
||||
note: row.note || '',
|
||||
error: row.error || '',
|
||||
cached: false,
|
||||
configured: true,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 调用通义千问视觉模型并解析候选 + 匹配图鉴库。*/
|
||||
private async runModel(dataUrl: string): Promise<{
|
||||
configured: boolean;
|
||||
summary: string;
|
||||
guesses: Guess[];
|
||||
matches: ModelMatch[];
|
||||
error: string;
|
||||
}> {
|
||||
const key = process.env.DASHSCOPE_API_KEY;
|
||||
if (!key) {
|
||||
return {
|
||||
configured: false,
|
||||
summary: '',
|
||||
guesses: [],
|
||||
matches: [],
|
||||
error:
|
||||
'AI 识图未配置:请在 apps/api/.env 设置 DASHSCOPE_API_KEY(通义千问 DashScope 密钥)后重启后端。',
|
||||
};
|
||||
}
|
||||
|
||||
const prompt =
|
||||
'这是一张中国铁路机车或动车组的照片。请作为资深铁路机车识别专家,判断它最可能是哪一(几)种车型。\n' +
|
||||
'只输出 JSON(不要任何额外文字、不要 markdown 代码块),格式:\n' +
|
||||
'{"summary":"一句话总体判断","guesses":[{"model_code":"型号代码,如 HXD1/CR400AF/DF4/SS8/前进型","name":"中文名称","confidence":0.0到1.0之间的数,"reason":"判断依据(车头/涂装/受电弓/轮式等外观特征)"}]}\n' +
|
||||
'最多给 3 个候选,按可能性从高到低排序。若无法判断也要给出最接近的猜测。';
|
||||
|
||||
let content = '';
|
||||
try {
|
||||
const res = await fetch(ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: prompt },
|
||||
{ type: 'image_url', image_url: { url: dataUrl } },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
const j: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const m = j?.error?.message || j?.message || `HTTP ${res.status}`;
|
||||
return { configured: true, summary: '', guesses: [], matches: [], error: `AI 服务返回错误:${m}` };
|
||||
}
|
||||
const raw = j?.choices?.[0]?.message?.content;
|
||||
content = Array.isArray(raw)
|
||||
? raw.map((c: any) => c?.text || '').join('')
|
||||
: String(raw ?? '');
|
||||
} catch (e: any) {
|
||||
this.logger.error(`identify 调用失败: ${e?.message || e}`);
|
||||
return { configured: true, summary: '', guesses: [], matches: [], error: `调用 AI 服务失败:${e?.message || e}` };
|
||||
}
|
||||
|
||||
const parsed = this.parse(content);
|
||||
const matches = this.matchModels(parsed.guesses);
|
||||
return { configured: true, summary: parsed.summary, guesses: parsed.guesses, matches, error: '' };
|
||||
}
|
||||
|
||||
/** 从模型文本中解析 JSON(容错:截取首个 {...} 块)。*/
|
||||
private parse(text: string): { summary: string; guesses: Guess[] } {
|
||||
const block = text.match(/\{[\s\S]*\}/);
|
||||
if (block) {
|
||||
try {
|
||||
const o = JSON.parse(block[0]);
|
||||
const guesses: Guess[] = Array.isArray(o.guesses)
|
||||
? o.guesses.slice(0, 3).map((g: any) => ({
|
||||
model_code: String(g.model_code ?? g.code ?? '').trim(),
|
||||
name: String(g.name ?? '').trim(),
|
||||
confidence: Math.max(0, Math.min(1, Number(g.confidence) || 0)),
|
||||
reason: String(g.reason ?? '').trim(),
|
||||
}))
|
||||
: [];
|
||||
return { summary: String(o.summary ?? '').trim(), guesses };
|
||||
} catch {
|
||||
/* 解析失败则回退原文 */
|
||||
}
|
||||
}
|
||||
return { summary: text.trim().slice(0, 300), guesses: [] };
|
||||
}
|
||||
|
||||
/** 把 AI 猜测的型号匹配到图鉴库,给出可点击的详情链接。*/
|
||||
private matchModels(guesses: Guess[]): ModelMatch[] {
|
||||
const stmt = this.db.db.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
WHERE m.model_code LIKE ? OR m.full_name LIKE ? OR m.series LIKE ?
|
||||
LIMIT 4`,
|
||||
);
|
||||
const out = new Map<number, ModelMatch>();
|
||||
for (const g of guesses) {
|
||||
for (const raw of [g.model_code, g.name].filter(Boolean)) {
|
||||
const latin = (raw.match(/[A-Za-z0-9]+/g) || []).join('');
|
||||
const key = latin || raw;
|
||||
if (key.length < 2) continue;
|
||||
const rows = stmt.all(`%${key}%`, `%${key}%`, `%${key}%`) as ModelMatch[];
|
||||
for (const r of rows) if (!out.has(r.id)) out.set(r.id, r);
|
||||
}
|
||||
}
|
||||
return [...out.values()].slice(0, 8);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe, Logger } from '@nestjs/common';
|
||||
import * as express from 'express';
|
||||
import { AppModule } from './app.module';
|
||||
import { uploadDir, PUBLIC_PREFIX } from './photos/uploads';
|
||||
|
||||
// 加载 apps/api/.env(Node 20.12+/22 内置;缺失时忽略)
|
||||
try {
|
||||
(process as any).loadEnvFile?.();
|
||||
} catch {
|
||||
/* 没有 .env 文件时忽略 */
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: false,
|
||||
}),
|
||||
);
|
||||
app.enableCors();
|
||||
// 静态服务上传的图片(共享图库)
|
||||
app.use(PUBLIC_PREFIX, express.static(uploadDir()));
|
||||
const port = process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
new Logger('Bootstrap').log(`API 已启动: http://localhost:${port}/api/health`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ModelsService } from './models.service';
|
||||
import { QueryModelsDto } from './query-models.dto';
|
||||
|
||||
@Controller('api/models')
|
||||
export class ModelsController {
|
||||
constructor(private readonly models: ModelsService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: QueryModelsDto) {
|
||||
return this.models.list(query);
|
||||
}
|
||||
|
||||
@Get('families')
|
||||
families(@Query('category') category?: string) {
|
||||
return this.models.families(category);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.models.getById(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ModelsController } from './models.controller';
|
||||
import { ModelsService } from './models.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ModelsController],
|
||||
providers: [ModelsService],
|
||||
exports: [ModelsService],
|
||||
})
|
||||
export class ModelsModule {}
|
||||
@@ -0,0 +1,84 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ModelsService } from './models.service';
|
||||
import { QueryModelsDto } from './query-models.dto';
|
||||
import { seedDatabase } from '../testing/seed';
|
||||
|
||||
/** ModelsService 单元测试 — 对应 T-1.3 UT。*/
|
||||
describe('ModelsService', () => {
|
||||
let service: ModelsService;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeAll(() => {
|
||||
db = new Database(':memory:');
|
||||
seedDatabase(db);
|
||||
const wdb = new Database(':memory:');
|
||||
wdb.exec(
|
||||
`CREATE TABLE model_overrides (model_id INTEGER, field TEXT, value TEXT,
|
||||
revision_id INTEGER, updated_at TEXT, PRIMARY KEY(model_id, field));
|
||||
CREATE TABLE photos (id INTEGER PRIMARY KEY AUTOINCREMENT, model_id INTEGER,
|
||||
uploader_id INTEGER, filename TEXT, caption TEXT DEFAULT '',
|
||||
status TEXT DEFAULT 'confirmed', source_url TEXT DEFAULT '',
|
||||
author TEXT DEFAULT '', license TEXT DEFAULT '',
|
||||
featured INTEGER NOT NULL DEFAULT 0, created_at TEXT DEFAULT '');`,
|
||||
);
|
||||
service = new ModelsService({ db } as any, { db: wdb } as any);
|
||||
});
|
||||
|
||||
afterAll(() => db.close());
|
||||
|
||||
const q = (over: Partial<QueryModelsDto> = {}): QueryModelsDto =>
|
||||
Object.assign(new QueryModelsDto(), over);
|
||||
|
||||
it('返回分页结构', () => {
|
||||
const res = service.list(q());
|
||||
expect(res.total).toBe(2);
|
||||
expect(res.page).toBe(1);
|
||||
expect(res.items.length).toBe(2);
|
||||
});
|
||||
|
||||
it('按分类名模糊筛选', () => {
|
||||
const res = service.list(q({ category: '电力' }));
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.items[0].model_code).toBe('HXD1型');
|
||||
});
|
||||
|
||||
it('按年代区间筛选', () => {
|
||||
const res = service.list(q({ yearFrom: 2010 }));
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('按时速区间筛选', () => {
|
||||
const res = service.list(q({ speedMin: 300 }));
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('关键字搜索', () => {
|
||||
const res = service.list(q({ q: 'HXD' }));
|
||||
expect(res.total).toBe(1);
|
||||
});
|
||||
|
||||
it('排序:按时速降序', () => {
|
||||
const res = service.list(q({ sort: 'max_speed_value', order: 'desc' }));
|
||||
expect(res.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('空结果不报错', () => {
|
||||
const res = service.list(q({ manufacturer: '不存在' }));
|
||||
expect(res.total).toBe(0);
|
||||
expect(res.items).toEqual([]);
|
||||
});
|
||||
|
||||
it('详情含解析后的 raw 字段', () => {
|
||||
const list = service.list(q({ category: '电力' }));
|
||||
const detail = service.getById(list.items[0].id);
|
||||
expect(detail.model_code).toBe('HXD1型');
|
||||
expect(detail.raw).toBeDefined();
|
||||
});
|
||||
|
||||
it('详情不存在抛 404', () => {
|
||||
expect(() => service.getById(99999)).toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { EDITABLE_MAP } from '../contrib/editable-fields';
|
||||
import { QueryModelsDto, SORTABLE_FIELDS } from './query-models.dto';
|
||||
|
||||
export interface PagedResult<T> {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ModelsService {
|
||||
constructor(
|
||||
private readonly database: DatabaseService,
|
||||
private readonly wdb: WritableDbService,
|
||||
) {}
|
||||
|
||||
/** 构造 WHERE 子句与绑定参数(参数化,避免注入)。*/
|
||||
private buildWhere(q: QueryModelsDto): { clause: string; params: any[] } {
|
||||
const conds: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (q.category) {
|
||||
if (/^\d+$/.test(q.category)) {
|
||||
conds.push('m.category_id = ?');
|
||||
params.push(Number(q.category));
|
||||
} else {
|
||||
conds.push('c.name LIKE ?');
|
||||
params.push(`%${q.category}%`);
|
||||
}
|
||||
}
|
||||
if (q.yearFrom != null) {
|
||||
conds.push('m.first_year >= ?');
|
||||
params.push(q.yearFrom);
|
||||
}
|
||||
if (q.yearTo != null) {
|
||||
conds.push('m.first_year <= ?');
|
||||
params.push(q.yearTo);
|
||||
}
|
||||
if (q.speedMin != null) {
|
||||
conds.push('m.max_speed_value >= ?');
|
||||
params.push(q.speedMin);
|
||||
}
|
||||
if (q.speedMax != null) {
|
||||
conds.push('m.max_speed_value <= ?');
|
||||
params.push(q.speedMax);
|
||||
}
|
||||
if (q.manufacturer) {
|
||||
conds.push('m.manufacturer LIKE ?');
|
||||
params.push(`%${q.manufacturer}%`);
|
||||
}
|
||||
if (q.country) {
|
||||
conds.push('m.country_type = ?');
|
||||
params.push(q.country);
|
||||
}
|
||||
if (q.status) {
|
||||
conds.push('m.status = ?');
|
||||
params.push(q.status);
|
||||
}
|
||||
if (q.q) {
|
||||
conds.push('(m.model_code LIKE ? OR m.full_name LIKE ? OR m.series LIKE ?)');
|
||||
params.push(`%${q.q}%`, `%${q.q}%`, `%${q.q}%`);
|
||||
}
|
||||
const clause = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
return { clause, params };
|
||||
}
|
||||
|
||||
list(q: QueryModelsDto): PagedResult<any> {
|
||||
const { clause, params } = this.buildWhere(q);
|
||||
const db = this.database.db;
|
||||
|
||||
const total = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM model m
|
||||
JOIN category c ON c.id = m.category_id ${clause}`,
|
||||
)
|
||||
.get(...params) as { n: number }
|
||||
).n;
|
||||
|
||||
const sort = (SORTABLE_FIELDS as readonly string[]).includes(q.sort)
|
||||
? q.sort
|
||||
: 'first_year';
|
||||
const order = q.order === 'desc' ? 'DESC' : 'ASC';
|
||||
const offset = (q.page - 1) * q.pageSize;
|
||||
|
||||
const items = db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, m.full_name, m.series, m.manufacturer,
|
||||
m.country, m.country_type, m.first_year, m.last_year, m.status,
|
||||
m.usage, m.max_speed_value, m.max_speed_unit,
|
||||
m.weight_value, m.weight_unit, m.axle_arrangement,
|
||||
c.name AS category, c.subcat
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
${clause}
|
||||
ORDER BY (m.${sort} IS NULL), m.${sort} ${order}, m.id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, q.pageSize, offset) as any[];
|
||||
|
||||
// 附加共享图库封面(每个车型最早一张照片)
|
||||
if (items.length) {
|
||||
const ids = items.map((i) => i.id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const covers = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.model_id, p.filename FROM photos p
|
||||
WHERE p.status='confirmed' AND p.model_id IN (${ph})
|
||||
AND p.id = (SELECT p2.id FROM photos p2
|
||||
WHERE p2.model_id = p.model_id AND p2.status='confirmed'
|
||||
ORDER BY p2.featured DESC, p2.id ASC LIMIT 1)`,
|
||||
)
|
||||
.all(...ids) as { model_id: number; filename: string }[];
|
||||
const map = new Map(covers.map((c) => [c.model_id, c.filename]));
|
||||
for (const it of items) {
|
||||
const f = map.get(it.id);
|
||||
it.cover_url = f ? `/uploads/${f}` : null;
|
||||
}
|
||||
}
|
||||
|
||||
return { total, page: q.page, pageSize: q.pageSize, items };
|
||||
}
|
||||
|
||||
getById(id: number): any {
|
||||
const db = this.database.db;
|
||||
const row: any = db
|
||||
.prepare(
|
||||
`SELECT m.*, c.name AS category, c.subcat
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
WHERE m.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!row) {
|
||||
throw new NotFoundException(`车型 ${id} 不存在`);
|
||||
}
|
||||
if (row.raw_json) {
|
||||
try {
|
||||
row.raw = JSON.parse(row.raw_json);
|
||||
} catch {
|
||||
row.raw = {};
|
||||
}
|
||||
}
|
||||
// 叠加众包字段覆盖(已审核通过的编辑)
|
||||
const overrides = this.wdb.db
|
||||
.prepare('SELECT field, value FROM model_overrides WHERE model_id = ?')
|
||||
.all(id) as { field: string; value: string }[];
|
||||
if (overrides.length) {
|
||||
row.overridden = [];
|
||||
for (const o of overrides) {
|
||||
const def = EDITABLE_MAP.get(o.field);
|
||||
row[o.field] =
|
||||
def?.type === 'int' ? (o.value === '' ? null : Number(o.value)) : o.value;
|
||||
row.overridden.push(o.field);
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** 技术族谱:按分类→系列聚合现有车型(series 字段)。*/
|
||||
families(category?: string) {
|
||||
const rows = this.database.db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, m.series, m.first_year, m.last_year,
|
||||
m.country_type, m.max_speed_value, m.max_speed_unit,
|
||||
c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
${category ? 'WHERE c.name = ?' : ''}
|
||||
ORDER BY c.name, m.series, (m.first_year IS NULL), m.first_year`,
|
||||
)
|
||||
.all(...(category ? [category] : [])) as any[];
|
||||
|
||||
const cats = new Map<string, Map<string, any[]>>();
|
||||
for (const r of rows) {
|
||||
if (!cats.has(r.category)) cats.set(r.category, new Map());
|
||||
const series = r.series || '(未归类)';
|
||||
const sm = cats.get(r.category)!;
|
||||
if (!sm.has(series)) sm.set(series, []);
|
||||
sm.get(series)!.push(r);
|
||||
}
|
||||
return [...cats.entries()].map(([cat, sm]) => ({
|
||||
category: cat,
|
||||
series: [...sm.entries()].map(([name, models]) => ({ name, models })),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/** 车型列表查询参数 — 对应 FR-1.1 / FR-1.2。*/
|
||||
export const SORTABLE_FIELDS = [
|
||||
'first_year',
|
||||
'last_year',
|
||||
'max_speed_value',
|
||||
'weight_value',
|
||||
'model_code',
|
||||
'id',
|
||||
] as const;
|
||||
|
||||
export class QueryModelsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page = 1;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(1000)
|
||||
pageSize = 20;
|
||||
|
||||
/** 分类:可传 category_id(数字)或分类名(模糊)。*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
yearFrom?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
yearTo?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
speedMin?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
speedMax?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
manufacturer?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
country?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
/** 快速关键字(型号/全称模糊)。*/
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SORTABLE_FIELDS as unknown as string[])
|
||||
sort: string = 'first_year';
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['asc', 'desc'])
|
||||
order: 'asc' | 'desc' = 'asc';
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { extname } from 'path';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { PhotosService } from './photos.service';
|
||||
import { uploadDir } from './uploads';
|
||||
import {
|
||||
CurrentUser,
|
||||
JwtAuthGuard,
|
||||
Roles,
|
||||
RolesGuard,
|
||||
type JwtPayload,
|
||||
} from '../auth/roles';
|
||||
|
||||
const ALLOWED = /\.(jpe?g|png|webp|gif)$/i;
|
||||
|
||||
const storage = diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir()),
|
||||
filename: (_req, file, cb) =>
|
||||
cb(null, `${Date.now()}-${randomBytes(6).toString('hex')}${extname(file.originalname).toLowerCase()}`),
|
||||
});
|
||||
|
||||
@Controller('api')
|
||||
export class PhotosController {
|
||||
constructor(private readonly photos: PhotosService) {}
|
||||
|
||||
/** 公共图库:某车型的全部照片(所有人可看)。*/
|
||||
@Get('models/:id/photos')
|
||||
list(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.photos.listForModel(id);
|
||||
}
|
||||
|
||||
/** 管理员候选审图:全站待确认照片。*/
|
||||
@Get('photos/candidates')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
candidates() {
|
||||
return this.photos.listCandidates();
|
||||
}
|
||||
|
||||
/** 管理员上传到共享图库。*/
|
||||
@Post('models/:id/photos')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
storage,
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) =>
|
||||
ALLOWED.test(file.originalname)
|
||||
? cb(null, true)
|
||||
: cb(new BadRequestException('仅支持 jpg/png/webp/gif'), false),
|
||||
}),
|
||||
)
|
||||
upload(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body('caption') caption: string,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
if (!file) throw new BadRequestException('未收到文件');
|
||||
return this.photos.add(id, user.sub, file.filename, {
|
||||
caption: caption ?? '',
|
||||
status: 'confirmed',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('photos/:pid/confirm')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
confirm(@Param('pid', ParseIntPipe) pid: number) {
|
||||
return this.photos.confirm(pid);
|
||||
}
|
||||
|
||||
@Post('photos/:pid/feature')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
feature(@Param('pid', ParseIntPipe) pid: number) {
|
||||
return this.photos.setFeatured(pid);
|
||||
}
|
||||
|
||||
@Delete('photos/:pid')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('admin')
|
||||
remove(@Param('pid', ParseIntPipe) pid: number) {
|
||||
return this.photos.remove(pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PhotosService } from './photos.service';
|
||||
import { PhotosController } from './photos.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [PhotosController],
|
||||
providers: [PhotosService],
|
||||
})
|
||||
export class PhotosModule {}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { unlinkSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
import { PUBLIC_PREFIX, uploadDir } from './uploads';
|
||||
|
||||
export interface AddPhotoOpts {
|
||||
caption?: string;
|
||||
status?: 'candidate' | 'confirmed';
|
||||
sourceUrl?: string;
|
||||
author?: string;
|
||||
license?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PhotosService {
|
||||
constructor(
|
||||
private readonly wdb: WritableDbService,
|
||||
private readonly ro: DatabaseService,
|
||||
) {}
|
||||
|
||||
private toPublic(row: any) {
|
||||
return {
|
||||
id: row.id,
|
||||
modelId: row.model_id,
|
||||
url: `${PUBLIC_PREFIX}/${row.filename}`,
|
||||
caption: row.caption,
|
||||
status: row.status,
|
||||
featured: !!row.featured,
|
||||
sourceUrl: row.source_url,
|
||||
author: row.author,
|
||||
license: row.license,
|
||||
uploaderId: row.uploader_id,
|
||||
uploaderName: row.uploader_name,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
add(modelId: number, uploaderId: number, filename: string, opts: AddPhotoOpts = {}) {
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO photos (model_id, uploader_id, filename, caption, status, source_url, author, license)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
modelId,
|
||||
uploaderId,
|
||||
filename,
|
||||
(opts.caption ?? '').trim(),
|
||||
opts.status ?? 'confirmed',
|
||||
opts.sourceUrl ?? '',
|
||||
opts.author ?? '',
|
||||
opts.license ?? '',
|
||||
);
|
||||
return this.get(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
get(id: number) {
|
||||
const row = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.*, u.display_name AS uploader_name
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id WHERE p.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
return this.toPublic(row);
|
||||
}
|
||||
|
||||
listForModel(modelId: number) {
|
||||
return (
|
||||
this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.*, u.display_name AS uploader_name
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.model_id = ?
|
||||
ORDER BY (p.status='confirmed') DESC, p.created_at ASC, p.id ASC`,
|
||||
)
|
||||
.all(modelId) as any[]
|
||||
).map((r) => this.toPublic(r));
|
||||
}
|
||||
|
||||
confirm(id: number) {
|
||||
const row = this.wdb.db.prepare('SELECT 1 FROM photos WHERE id = ?').get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
this.wdb.db.prepare("UPDATE photos SET status='confirmed' WHERE id = ?").run(id);
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** 设为该车型封面(同时置为已确认;清掉同车型其它封面标记)。*/
|
||||
setFeatured(id: number) {
|
||||
const row: any = this.wdb.db.prepare('SELECT model_id FROM photos WHERE id = ?').get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
const tx = this.wdb.db.transaction(() => {
|
||||
this.wdb.db.prepare('UPDATE photos SET featured=0 WHERE model_id=?').run(row.model_id);
|
||||
this.wdb.db
|
||||
.prepare("UPDATE photos SET featured=1, status='confirmed' WHERE id=?")
|
||||
.run(id);
|
||||
});
|
||||
tx();
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
/** 全站候选图(管理员审图页),附车型信息。*/
|
||||
listCandidates(limit = 300) {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT p.*, u.display_name AS uploader_name
|
||||
FROM photos p JOIN users u ON u.id = p.uploader_id
|
||||
WHERE p.status = 'candidate'
|
||||
ORDER BY p.model_id, p.id LIMIT ?`,
|
||||
)
|
||||
.all(limit) as any[];
|
||||
if (rows.length === 0) return [];
|
||||
const ids = [...new Set(rows.map((r) => r.model_id))];
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const models = this.ro.db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id WHERE m.id IN (${ph})`,
|
||||
)
|
||||
.all(...ids) as { id: number; model_code: string; category: string }[];
|
||||
const map = new Map(models.map((m) => [m.id, m]));
|
||||
return rows.map((r) => ({
|
||||
...this.toPublic(r),
|
||||
modelCode: map.get(r.model_id)?.model_code ?? `#${r.model_id}`,
|
||||
category: map.get(r.model_id)?.category ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
const row: any = this.wdb.db.prepare('SELECT * FROM photos WHERE id = ?').get(id);
|
||||
if (!row) throw new NotFoundException('照片不存在');
|
||||
try {
|
||||
unlinkSync(join(uploadDir(), row.filename));
|
||||
} catch {
|
||||
/* 文件可能已不存在,忽略 */
|
||||
}
|
||||
this.wdb.db.prepare('DELETE FROM photos WHERE id = ?').run(id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
|
||||
/** 图片上传存储目录(可用 UPLOAD_DIR 覆盖,测试用)。*/
|
||||
export function uploadDir(): string {
|
||||
const dir =
|
||||
process.env.UPLOAD_DIR ||
|
||||
resolve(join(__dirname, '..', '..', '..', '..', 'app', 'data', 'uploads'));
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
export const PUBLIC_PREFIX = '/uploads';
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Controller('api/search')
|
||||
export class SearchController {
|
||||
constructor(private readonly search: SearchService) {}
|
||||
|
||||
@Get()
|
||||
doSearch(@Query('q') q: string, @Query('limit') limit?: string) {
|
||||
const lim = limit ? Math.min(50, Math.max(1, Number(limit))) : 20;
|
||||
return this.search.search(q, lim);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SearchController } from './search.controller';
|
||||
import { SearchService } from './search.service';
|
||||
|
||||
@Module({
|
||||
controllers: [SearchController],
|
||||
providers: [SearchService],
|
||||
})
|
||||
export class SearchModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { SearchService } from './search.service';
|
||||
import { seedDatabase } from '../testing/seed';
|
||||
|
||||
/** SearchService 单元测试 — 对应 T-1.4 UT。*/
|
||||
describe('SearchService', () => {
|
||||
let service: SearchService;
|
||||
let db: Database.Database;
|
||||
|
||||
beforeAll(() => {
|
||||
db = new Database(':memory:');
|
||||
seedDatabase(db);
|
||||
service = new SearchService({ db } as any);
|
||||
});
|
||||
|
||||
afterAll(() => db.close());
|
||||
|
||||
it('空查询返回空结果', () => {
|
||||
expect(service.search('').results).toEqual([]);
|
||||
});
|
||||
|
||||
it('按型号命中', () => {
|
||||
const res = service.search('CR400');
|
||||
expect(res.results.length).toBeGreaterThan(0);
|
||||
expect(res.results[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('按生产商命中', () => {
|
||||
const res = service.search('株洲');
|
||||
expect(res.results.some((r) => r.model_code === 'HXD1型')).toBe(true);
|
||||
});
|
||||
|
||||
it('精确匹配排在前', () => {
|
||||
const res = service.search('CR400AF');
|
||||
expect(res.results[0].rank).toBe(0);
|
||||
});
|
||||
|
||||
it('特殊字符不崩溃', () => {
|
||||
expect(() => service.search("%_'")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
/**
|
||||
* 搜索服务 — 对应 FR-1.3。
|
||||
* 当前为 SQLite LIKE 实现(540 车型 / 307 个体规模足够),
|
||||
* 按匹配位置排序(精确 > 前缀 > 包含)。
|
||||
* Phase 1B 后续可替换为 Meilisearch(外部检索服务,本期延后)。
|
||||
*/
|
||||
@Injectable()
|
||||
export class SearchService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
search(q: string, limit = 20): { query: string; results: any[] } {
|
||||
const query = (q || '').trim();
|
||||
if (!query) {
|
||||
return { query, results: [] };
|
||||
}
|
||||
const like = `%${query}%`;
|
||||
const prefix = `${query}%`;
|
||||
const db = this.database.db;
|
||||
|
||||
const models = db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, m.full_name, m.manufacturer, m.series,
|
||||
c.name AS category,
|
||||
CASE
|
||||
WHEN m.model_code = ? THEN 0
|
||||
WHEN m.model_code LIKE ? THEN 1
|
||||
WHEN m.model_code LIKE ? THEN 2
|
||||
ELSE 3
|
||||
END AS rank
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
WHERE m.model_code LIKE ? OR m.full_name LIKE ?
|
||||
OR m.manufacturer LIKE ? OR m.series LIKE ?
|
||||
ORDER BY rank ASC, m.first_year IS NULL, m.first_year ASC
|
||||
LIMIT ?`,
|
||||
)
|
||||
.all(query, prefix, like, like, like, like, like, limit)
|
||||
.map((r: any) => ({ type: 'model', ...r }));
|
||||
|
||||
return { query, results: models };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IsLatitude,
|
||||
IsLongitude,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SightingsService } from './sightings.service';
|
||||
import { CurrentUser, JwtAuthGuard, type JwtPayload } from '../auth/roles';
|
||||
|
||||
class CreateSightingDto {
|
||||
@Type(() => Number) @IsLatitude()
|
||||
lat!: number;
|
||||
|
||||
@Type(() => Number) @IsLongitude()
|
||||
lng!: number;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(60)
|
||||
station?: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(20)
|
||||
spottedAt?: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(40)
|
||||
carNumber?: string;
|
||||
|
||||
@IsOptional() @IsString() @MaxLength(300)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@Controller('api')
|
||||
export class SightingsController {
|
||||
constructor(private readonly sightings: SightingsService) {}
|
||||
|
||||
@Get('models/:id/sightings')
|
||||
forModel(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.sightings.listForModel(id);
|
||||
}
|
||||
|
||||
@Post('models/:id/sightings')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
create(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: CreateSightingDto,
|
||||
@CurrentUser() user: JwtPayload,
|
||||
) {
|
||||
return this.sightings.create(user.sub, id, dto);
|
||||
}
|
||||
|
||||
@Get('sightings/recent')
|
||||
recent(@Query('limit') limit?: string) {
|
||||
return this.sightings.recent(limit ? Number(limit) : 30);
|
||||
}
|
||||
|
||||
@Get('sightings/map')
|
||||
map() {
|
||||
return this.sightings.mapPoints();
|
||||
}
|
||||
|
||||
@Get('sightings/spots')
|
||||
spots() {
|
||||
return this.sightings.spots();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SightingsService } from './sightings.service';
|
||||
import { SightingsController } from './sightings.controller';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [SightingsController],
|
||||
providers: [SightingsService],
|
||||
})
|
||||
export class SightingsModule {}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WritableDbService } from '../db/writable-db.service';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
export interface CreateSighting {
|
||||
lat: number;
|
||||
lng: number;
|
||||
station?: string;
|
||||
spottedAt?: string;
|
||||
carNumber?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SightingsService {
|
||||
constructor(
|
||||
private readonly wdb: WritableDbService,
|
||||
private readonly ro: DatabaseService,
|
||||
) {}
|
||||
|
||||
/** 给 sighting 行补充 model_code / category(来自只读库)。*/
|
||||
private enrich(rows: any[]): any[] {
|
||||
if (rows.length === 0) return rows;
|
||||
const ids = [...new Set(rows.map((r) => r.model_id))];
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const models = this.ro.db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
WHERE m.id IN (${ph})`,
|
||||
)
|
||||
.all(...ids) as { id: number; model_code: string; category: string }[];
|
||||
const map = new Map(models.map((m) => [m.id, m]));
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
model_code: map.get(r.model_id)?.model_code ?? `#${r.model_id}`,
|
||||
category: map.get(r.model_id)?.category ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
create(userId: number, modelId: number, dto: CreateSighting) {
|
||||
const exists = this.ro.db.prepare('SELECT 1 FROM model WHERE id = ?').get(modelId);
|
||||
if (!exists) throw new NotFoundException(`车型 ${modelId} 不存在`);
|
||||
const info = this.wdb.db
|
||||
.prepare(
|
||||
`INSERT INTO sightings (model_id, user_id, car_number, lat, lng, station, spotted_at, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
modelId,
|
||||
userId,
|
||||
(dto.carNumber ?? '').trim(),
|
||||
dto.lat,
|
||||
dto.lng,
|
||||
(dto.station ?? '').trim(),
|
||||
(dto.spottedAt ?? '').trim(),
|
||||
(dto.description ?? '').trim(),
|
||||
);
|
||||
return this.getOne(Number(info.lastInsertRowid));
|
||||
}
|
||||
|
||||
private getOne(id: number) {
|
||||
const row = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT s.*, u.display_name AS user_name
|
||||
FROM sightings s JOIN users u ON u.id = s.user_id WHERE s.id = ?`,
|
||||
)
|
||||
.get(id);
|
||||
return this.enrich([row])[0];
|
||||
}
|
||||
|
||||
listForModel(modelId: number) {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT s.*, u.display_name AS user_name
|
||||
FROM sightings s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.model_id = ? ORDER BY s.created_at DESC`,
|
||||
)
|
||||
.all(modelId);
|
||||
return this.enrich(rows as any[]);
|
||||
}
|
||||
|
||||
recent(limit = 30) {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT s.*, u.display_name AS user_name
|
||||
FROM sightings s JOIN users u ON u.id = s.user_id
|
||||
ORDER BY s.created_at DESC LIMIT ?`,
|
||||
)
|
||||
.all(Math.min(100, Math.max(1, limit)));
|
||||
return this.enrich(rows as any[]);
|
||||
}
|
||||
|
||||
mapPoints() {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT s.id, s.model_id, s.lat, s.lng, s.station, s.car_number
|
||||
FROM sightings s ORDER BY s.created_at DESC LIMIT 1000`,
|
||||
)
|
||||
.all();
|
||||
return this.enrich(rows as any[]);
|
||||
}
|
||||
|
||||
/** 拍车攻略:按车站聚合,列出该站出现过的车型。*/
|
||||
spots() {
|
||||
const rows = this.wdb.db
|
||||
.prepare(
|
||||
`SELECT station, model_id, COUNT(*) AS n
|
||||
FROM sightings
|
||||
WHERE station != ''
|
||||
GROUP BY station, model_id`,
|
||||
)
|
||||
.all() as { station: string; model_id: number; n: number }[];
|
||||
const enriched = this.enrich(rows as any[]);
|
||||
const byStation = new Map<string, { station: string; total: number; models: any[] }>();
|
||||
for (const r of enriched) {
|
||||
if (!byStation.has(r.station))
|
||||
byStation.set(r.station, { station: r.station, total: 0, models: [] });
|
||||
const e = byStation.get(r.station)!;
|
||||
e.total += r.n;
|
||||
e.models.push({ model_id: r.model_id, model_code: r.model_code, count: r.n });
|
||||
}
|
||||
return [...byStation.values()].sort((a, b) => b.total - a.total);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Controller, Get, Header } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
@Controller('api')
|
||||
export class StatsController {
|
||||
constructor(
|
||||
private readonly stats: StatsService,
|
||||
private readonly db: DatabaseService,
|
||||
) {}
|
||||
|
||||
@Get('stats')
|
||||
overview() {
|
||||
return this.stats.overview();
|
||||
}
|
||||
|
||||
/** 开放数据导出(JSON)。*/
|
||||
@Get('export/models.json')
|
||||
exportJson() {
|
||||
return this.db.db
|
||||
.prepare(
|
||||
`SELECT m.id, m.model_code, m.series, m.full_name, m.manufacturer,
|
||||
m.country, m.country_type, m.first_year, m.last_year, m.status,
|
||||
m.usage, m.max_speed_value, m.max_speed_unit, c.name AS category
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
ORDER BY m.id`,
|
||||
)
|
||||
.all();
|
||||
}
|
||||
|
||||
/** 开放数据导出(CSV)。*/
|
||||
@Get('export/models.csv')
|
||||
@Header('Content-Type', 'text/csv; charset=utf-8')
|
||||
@Header('Content-Disposition', 'attachment; filename="models.csv"')
|
||||
exportCsv(): string {
|
||||
const rows = this.exportJson() as Record<string, unknown>[];
|
||||
if (rows.length === 0) return '';
|
||||
const cols = Object.keys(rows[0]);
|
||||
const esc = (v: unknown) => {
|
||||
const s = v == null ? '' : String(v);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
};
|
||||
const head = cols.join(',');
|
||||
const body = rows.map((r) => cols.map((c) => esc(r[c])).join(',')).join('\n');
|
||||
return `${head}\n${body}\n`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { StatsService } from './stats.service';
|
||||
import { StatsController } from './stats.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [StatsController],
|
||||
providers: [StatsService],
|
||||
})
|
||||
export class StatsModule {}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class StatsService {
|
||||
constructor(private readonly db: DatabaseService) {}
|
||||
|
||||
overview() {
|
||||
const d = this.db.db;
|
||||
const models = (d.prepare('SELECT COUNT(*) n FROM model').get() as any).n;
|
||||
const units = (d.prepare('SELECT COUNT(*) n FROM unit').get() as any).n;
|
||||
const categories = (d.prepare('SELECT COUNT(*) n FROM category').get() as any).n;
|
||||
|
||||
const byCategory = d
|
||||
.prepare(
|
||||
`SELECT c.name AS label, COUNT(*) AS count
|
||||
FROM model m JOIN category c ON c.id = m.category_id
|
||||
GROUP BY c.name ORDER BY count DESC`,
|
||||
)
|
||||
.all();
|
||||
|
||||
const byDecade = d
|
||||
.prepare(
|
||||
`SELECT (m.first_year/10)*10 AS decade, COUNT(*) AS count
|
||||
FROM model m WHERE m.first_year IS NOT NULL
|
||||
GROUP BY decade ORDER BY decade`,
|
||||
)
|
||||
.all();
|
||||
|
||||
const speedByDecade = d
|
||||
.prepare(
|
||||
`SELECT (m.first_year/10)*10 AS decade,
|
||||
MAX(m.max_speed_value) AS maxSpeed,
|
||||
ROUND(AVG(m.max_speed_value)) AS avgSpeed
|
||||
FROM model m
|
||||
WHERE m.first_year IS NOT NULL AND m.max_speed_value IS NOT NULL
|
||||
GROUP BY decade ORDER BY decade`,
|
||||
)
|
||||
.all();
|
||||
|
||||
const byCountryType = d
|
||||
.prepare(
|
||||
`SELECT COALESCE(NULLIF(country_type,''),'未知') AS label, COUNT(*) AS count
|
||||
FROM model GROUP BY label ORDER BY count DESC`,
|
||||
)
|
||||
.all();
|
||||
|
||||
return { totals: { models, units, categories }, byCategory, byDecade, speedByDecade, byCountryType };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
/** 读取 ETL 的 schema.sql 并在给定 db 上建表 + 注入测试数据。*/
|
||||
export function seedDatabase(db: Database.Database): void {
|
||||
const schemaPath = join(
|
||||
__dirname,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'app',
|
||||
'etl',
|
||||
'schema.sql',
|
||||
);
|
||||
db.exec(readFileSync(schemaPath, 'utf-8'));
|
||||
|
||||
const cat = db.prepare(
|
||||
'INSERT INTO category(name, subcat) VALUES (?, ?)',
|
||||
);
|
||||
const elec = cat.run('电力机车', '').lastInsertRowid as number;
|
||||
const emu = cat.run('动车组', '复兴号').lastInsertRowid as number;
|
||||
const insp = cat.run('检测车', '普速检测').lastInsertRowid as number;
|
||||
|
||||
const model = db.prepare(
|
||||
`INSERT INTO model(category_id, series, model_code, full_name, manufacturer,
|
||||
country, country_type, first_year, last_year, status, usage,
|
||||
max_speed_value, max_speed_unit, weight_value, weight_unit, raw_json)
|
||||
VALUES (@category_id, @series, @model_code, @full_name, @manufacturer,
|
||||
@country, @country_type, @first_year, @last_year, @status, @usage,
|
||||
@max_speed_value, @max_speed_unit, @weight_value, @weight_unit, @raw_json)`,
|
||||
);
|
||||
model.run({
|
||||
category_id: elec, series: '和谐', model_code: 'HXD1型', full_name: '',
|
||||
manufacturer: '中车株洲', country: '中国', country_type: '国产',
|
||||
first_year: 2006, last_year: null, status: '现役', usage: '干线货运',
|
||||
max_speed_value: 120, max_speed_unit: 'km/h', weight_value: 150,
|
||||
weight_unit: 't', raw_json: JSON.stringify({ model_code: 'HXD1型' }),
|
||||
});
|
||||
model.run({
|
||||
category_id: emu, series: '', model_code: 'CR400AF', full_name: '复兴号',
|
||||
manufacturer: '中车四方', country: '中国', country_type: '国产',
|
||||
first_year: 2017, last_year: null, status: '现役', usage: '高速客运',
|
||||
max_speed_value: 350, max_speed_unit: 'km/h', weight_value: null,
|
||||
weight_unit: '', raw_json: JSON.stringify({ model_code: 'CR400AF' }),
|
||||
});
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO unit(category_id, car_number, model_name, function, depot, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
).run(insp, 'SYJZ-0001', '移动式线路动态加载试验车', '试验车', '国铁', '半封存');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { UnitsService } from './units.service';
|
||||
|
||||
@Controller('api/units')
|
||||
export class UnitsController {
|
||||
constructor(private readonly units: UnitsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('category') category?: string,
|
||||
@Query('depot') depot?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('q') q?: string,
|
||||
) {
|
||||
return this.units.list({
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
category,
|
||||
depot,
|
||||
status,
|
||||
q,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UnitsController } from './units.controller';
|
||||
import { UnitsService } from './units.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UnitsController],
|
||||
providers: [UnitsService],
|
||||
})
|
||||
export class UnitsModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DatabaseService } from '../db/database.service';
|
||||
|
||||
@Injectable()
|
||||
export class UnitsService {
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
list(opts: {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
category?: string;
|
||||
depot?: string;
|
||||
status?: string;
|
||||
q?: string;
|
||||
}): { total: number; page: number; pageSize: number; items: any[] } {
|
||||
const page = Math.max(1, Number(opts.page) || 1);
|
||||
const pageSize = Math.min(100, Math.max(1, Number(opts.pageSize) || 20));
|
||||
const conds: string[] = [];
|
||||
const params: any[] = [];
|
||||
|
||||
if (opts.category) {
|
||||
if (/^\d+$/.test(opts.category)) {
|
||||
conds.push('u.category_id = ?');
|
||||
params.push(Number(opts.category));
|
||||
} else {
|
||||
conds.push('c.name LIKE ?');
|
||||
params.push(`%${opts.category}%`);
|
||||
}
|
||||
}
|
||||
if (opts.depot) {
|
||||
conds.push('u.depot LIKE ?');
|
||||
params.push(`%${opts.depot}%`);
|
||||
}
|
||||
if (opts.status) {
|
||||
conds.push('u.status = ?');
|
||||
params.push(opts.status);
|
||||
}
|
||||
if (opts.q) {
|
||||
conds.push('(u.car_number LIKE ? OR u.model_name LIKE ?)');
|
||||
params.push(`%${opts.q}%`, `%${opts.q}%`);
|
||||
}
|
||||
const clause = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
|
||||
const db = this.database.db;
|
||||
|
||||
const total = (
|
||||
db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM unit u
|
||||
JOIN category c ON c.id = u.category_id ${clause}`,
|
||||
)
|
||||
.get(...params) as { n: number }
|
||||
).n;
|
||||
|
||||
const items = db
|
||||
.prepare(
|
||||
`SELECT u.id, u.car_number, u.model_name, u.function, u.depot,
|
||||
u.livery, u.status, u.location, c.name AS category, c.subcat
|
||||
FROM unit u JOIN category c ON c.id = u.category_id
|
||||
${clause}
|
||||
ORDER BY u.id ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...params, pageSize, (page - 1) * pageSize);
|
||||
|
||||
return { total, page, pageSize, items };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtempSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import request from 'supertest';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { seedDatabase } from '../src/testing/seed';
|
||||
|
||||
/** API 端到端测试 — 对应 T-1.3 / T-1.4 E2E。*/
|
||||
describe('API (e2e)', () => {
|
||||
let app: INestApplication;
|
||||
|
||||
beforeAll(async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'train-e2e-'));
|
||||
const dbPath = join(dir, 'test.db');
|
||||
const seedDb = new Database(dbPath);
|
||||
seedDatabase(seedDb);
|
||||
seedDb.close();
|
||||
process.env.DB_PATH = dbPath;
|
||||
process.env.APP_DB_PATH = join(dir, 'app.db'); // 可写库(用户等)
|
||||
process.env.ADMIN_EMAIL = 'admin@example.com'; // 引导管理员(审核测试)
|
||||
process.env.UPLOAD_DIR = join(dir, 'uploads'); // 图片上传目录(隔离)
|
||||
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
app = moduleRef.createNestApplication();
|
||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('GET /api/health', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/health').expect(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.models).toBe(2);
|
||||
expect(res.body.units).toBe(1);
|
||||
});
|
||||
|
||||
it('GET /api/categories', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/categories')
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body.length).toBe(3);
|
||||
});
|
||||
|
||||
it('GET /api/models 列表+分页', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/models?pageSize=1')
|
||||
.expect(200);
|
||||
expect(res.body.total).toBe(2);
|
||||
expect(res.body.items.length).toBe(1);
|
||||
});
|
||||
|
||||
it('GET /api/models 筛选+排序', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/models?sort=max_speed_value&order=desc')
|
||||
.expect(200);
|
||||
expect(res.body.items[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('GET /api/models 非法参数被拒', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/models?pageSize=99999')
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('GET /api/models/:id 详情', async () => {
|
||||
const list = await request(app.getHttpServer()).get('/api/models?q=HXD');
|
||||
const id = list.body.items[0].id;
|
||||
const res = await request(app.getHttpServer())
|
||||
.get(`/api/models/${id}`)
|
||||
.expect(200);
|
||||
expect(res.body.model_code).toBe('HXD1型');
|
||||
});
|
||||
|
||||
it('GET /api/models/:id 不存在 404', async () => {
|
||||
await request(app.getHttpServer()).get('/api/models/99999').expect(404);
|
||||
});
|
||||
|
||||
it('GET /api/search', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/search?q=CR400')
|
||||
.expect(200);
|
||||
expect(res.body.results[0].model_code).toBe('CR400AF');
|
||||
});
|
||||
|
||||
it('GET /api/units 列表', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/units')
|
||||
.expect(200);
|
||||
expect(res.body.total).toBe(1);
|
||||
expect(res.body.items[0].car_number).toBe('SYJZ-0001');
|
||||
});
|
||||
|
||||
// ===== T-2.1 账户体系 =====
|
||||
const cred = { email: 'tester@example.com', password: 'secret123', displayName: '测试员' };
|
||||
let token = '';
|
||||
|
||||
it('POST /api/auth/register 注册', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/auth/register')
|
||||
.send(cred)
|
||||
.expect(201);
|
||||
expect(res.body.token).toBeTruthy();
|
||||
expect(res.body.user.email).toBe('tester@example.com');
|
||||
expect(res.body.user.role).toBe('user');
|
||||
token = res.body.token;
|
||||
});
|
||||
|
||||
it('POST /api/auth/register 重复邮箱 409', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/register')
|
||||
.send(cred)
|
||||
.expect(409);
|
||||
});
|
||||
|
||||
it('POST /api/auth/register 非法输入 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/register')
|
||||
.send({ email: 'bad', password: '1', displayName: '' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('POST /api/auth/login 正确/错误', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/login')
|
||||
.send({ email: cred.email, password: cred.password })
|
||||
.expect(201);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/auth/login')
|
||||
.send({ email: cred.email, password: 'wrongpass' })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('GET /api/auth/me 需要 token', async () => {
|
||||
await request(app.getHttpServer()).get('/api/auth/me').expect(401);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(200);
|
||||
expect(res.body.email).toBe('tester@example.com');
|
||||
});
|
||||
|
||||
// ===== T-2.2 / T-2.3 编辑·修订(图鉴仅管理员可编辑)=====
|
||||
let adminToken = '';
|
||||
|
||||
it('普通用户提交编辑被拒 403(仅管理员可编辑图鉴)', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/revisions')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ changes: { usage: '干线货运(实测)' } })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('未登录提交编辑 401', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/revisions')
|
||||
.send({ changes: { usage: 'x' } })
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('管理员编辑 → 自动通过 + 覆盖生效 + 计分', async () => {
|
||||
const reg = await request(app.getHttpServer())
|
||||
.post('/api/auth/register')
|
||||
.send({ email: 'admin@example.com', password: 'secret123', displayName: '管理员' })
|
||||
.expect(201);
|
||||
adminToken = reg.body.token;
|
||||
expect(reg.body.user.role).toBe('admin');
|
||||
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/models/1/revisions')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ changes: { usage: '干线货运(实测)' }, note: '管理员维护' })
|
||||
.expect(201);
|
||||
expect(res.body.status).toBe('approved');
|
||||
|
||||
const detail = await request(app.getHttpServer()).get('/api/models/1').expect(200);
|
||||
expect(detail.body.usage).toBe('干线货运(实测)');
|
||||
expect(detail.body.overridden).toContain('usage');
|
||||
});
|
||||
|
||||
it('修订历史可见', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/models/1/revisions')
|
||||
.expect(200);
|
||||
expect(res.body.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('普通用户访问审核队列 403', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/revisions/pending')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('管理员编辑模型2立即生效', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/models/2/revisions')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ changes: { status: '封存' } })
|
||||
.expect(201);
|
||||
expect(res.body.status).toBe('approved');
|
||||
const detail = await request(app.getHttpServer()).get('/api/models/2').expect(200);
|
||||
expect(detail.body.status).toBe('封存');
|
||||
});
|
||||
|
||||
it('字段白名单外/非法值被拒 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/revisions')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ changes: { password_hash: 'hack' } })
|
||||
.expect(400);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/revisions')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ changes: { first_year: '99' } })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
// ===== T-2.4 荣誉与激励 =====
|
||||
it('GET /api/auth/me 含等级与徽章', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/auth/me')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
expect(res.body.level).toBeDefined();
|
||||
expect(res.body.level.title).toBeTruthy();
|
||||
expect(Array.isArray(res.body.badges)).toBe(true);
|
||||
expect(res.body.approvedCount).toBeGreaterThanOrEqual(1); // 管理员有被采纳修订
|
||||
});
|
||||
|
||||
it('GET /api/leaderboard 含贡献者与头衔', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/leaderboard')
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body[0].rank).toBe(1);
|
||||
expect(res.body[0].title).toBeTruthy();
|
||||
});
|
||||
|
||||
it('认领词条维护 → 出现在维护者列表', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/maintainers')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(201);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/models/1/maintainers')
|
||||
.expect(200);
|
||||
expect(res.body.some((mm: any) => mm.displayName === '测试员')).toBe(true);
|
||||
});
|
||||
|
||||
it('未登录认领维护 401', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/maintainers')
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
// ===== T-3.1/3.2 论坛 + 词条讨论 =====
|
||||
let threadId = 0;
|
||||
|
||||
it('创建帖子并回复', async () => {
|
||||
const t = await request(app.getHttpServer())
|
||||
.post('/api/threads')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ board: 'general', title: '大家好啊', body: '第一帖' })
|
||||
.expect(201);
|
||||
expect(t.body.title).toBe('大家好啊');
|
||||
threadId = t.body.id;
|
||||
|
||||
const r = await request(app.getHttpServer())
|
||||
.post(`/api/threads/${threadId}/replies`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ body: '沙发' })
|
||||
.expect(201);
|
||||
expect(r.body.reply_count).toBe(1);
|
||||
expect(r.body.replies[0].body).toBe('沙发');
|
||||
});
|
||||
|
||||
it('帖子列表按板块', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/threads?board=general')
|
||||
.expect(200);
|
||||
expect(res.body.some((t: any) => t.id === threadId)).toBe(true);
|
||||
});
|
||||
|
||||
it('词条讨论:绑定 model_id', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/threads')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ board: 'entry', modelId: 1, title: 'HXD1 求资料', body: '有铭牌照吗' })
|
||||
.expect(201);
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/threads?modelId=1')
|
||||
.expect(200);
|
||||
expect(res.body.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body[0].model_id).toBe(1);
|
||||
});
|
||||
|
||||
it('未登录发帖 401,非法输入 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/threads')
|
||||
.send({ board: 'general', title: 'x', body: 'y' })
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/threads')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ board: 'general', title: 'a', body: '' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
// ===== T-3.3 打卡(sightings)=====
|
||||
it('打卡 → 出现在车型打卡列表与近期动态', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/models/1/sightings')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ lat: 39.9, lng: 116.4, station: '北京站', carNumber: 'HXD1-0001', description: '清晨拍到' })
|
||||
.expect(201);
|
||||
expect(res.body.station).toBe('北京站');
|
||||
expect(res.body.model_code).toBe('HXD1型');
|
||||
expect(res.body.user_name).toBe('测试员');
|
||||
|
||||
const list = await request(app.getHttpServer())
|
||||
.get('/api/models/1/sightings')
|
||||
.expect(200);
|
||||
expect(list.body.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const recent = await request(app.getHttpServer())
|
||||
.get('/api/sightings/recent')
|
||||
.expect(200);
|
||||
expect(recent.body[0].model_code).toBe('HXD1型');
|
||||
|
||||
const map = await request(app.getHttpServer()).get('/api/sightings/map').expect(200);
|
||||
expect(map.body.some((p: any) => p.station === '北京站')).toBe(true);
|
||||
});
|
||||
|
||||
it('未登录打卡 401,非法坐标 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/sightings')
|
||||
.send({ lat: 39.9, lng: 116.4 })
|
||||
.expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/sightings')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ lat: 999, lng: 116.4 })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
// ===== T-4.2 族谱 / T-4.3 拍车攻略 =====
|
||||
it('GET /api/models/families 按系列聚合', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/models/families')
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body[0].series).toBeDefined();
|
||||
});
|
||||
|
||||
it('GET /api/sightings/spots 拍车攻略聚合', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/sightings/spots')
|
||||
.expect(200);
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
// 前面打卡过北京站
|
||||
expect(res.body.some((s: any) => s.station === '北京站')).toBe(true);
|
||||
expect(res.body[0].models[0].model_code).toBeTruthy();
|
||||
});
|
||||
|
||||
// ===== T-5.2 数据大屏 / 开放 API =====
|
||||
it('GET /api/stats 概览统计', async () => {
|
||||
const res = await request(app.getHttpServer()).get('/api/stats').expect(200);
|
||||
expect(res.body.totals.models).toBe(2);
|
||||
expect(res.body.byCategory.length).toBeGreaterThan(0);
|
||||
expect(Array.isArray(res.body.speedByDecade)).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /api/export/models.json 开放数据', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/export/models.json')
|
||||
.expect(200);
|
||||
expect(res.body.length).toBe(2);
|
||||
expect(res.body[0].model_code).toBeTruthy();
|
||||
});
|
||||
|
||||
it('GET /api/export/models.csv 导出 CSV', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/export/models.csv')
|
||||
.expect(200);
|
||||
expect(res.headers['content-type']).toContain('text/csv');
|
||||
expect(res.text.split('\n')[0]).toContain('model_code');
|
||||
});
|
||||
|
||||
// ===== 共享图库(管理员上传,所有人可看)=====
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
let photoId = 0;
|
||||
|
||||
it('普通用户上传照片 403', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/photos')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.attach('file', png, 'demo.png')
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('管理员上传照片 → 进入公共图库', async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.post('/api/models/1/photos')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.field('caption', '管理员示例照片')
|
||||
.attach('file', png, 'demo.png')
|
||||
.expect(201);
|
||||
expect(res.body.url).toMatch(/^\/uploads\/.+\.png$/);
|
||||
expect(res.body.caption).toBe('管理员示例照片');
|
||||
photoId = res.body.id;
|
||||
|
||||
const list = await request(app.getHttpServer())
|
||||
.get('/api/models/1/photos')
|
||||
.expect(200);
|
||||
expect(list.body.some((p: any) => p.id === photoId)).toBe(true);
|
||||
expect(list.body[0].uploaderName).toBe('管理员');
|
||||
});
|
||||
|
||||
it('非图片被拒 400', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/models/1/photos')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.attach('file', Buffer.from('hello'), 'note.txt')
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('管理员删除照片', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.delete(`/api/photos/${photoId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(200);
|
||||
const list = await request(app.getHttpServer()).get('/api/models/1/photos').expect(200);
|
||||
expect(list.body.some((p: any) => p.id === photoId)).toBe(false);
|
||||
});
|
||||
|
||||
it('候选照片确认流程', async () => {
|
||||
const up = await request(app.getHttpServer())
|
||||
.post('/api/models/3/photos')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.attach('file', png, 'demo.png')
|
||||
.expect(201);
|
||||
const pid = up.body.id;
|
||||
expect(up.body.status).toBe('confirmed');
|
||||
|
||||
// 直接置为候选(模拟取图脚本入库的 candidate)
|
||||
const db = new Database(process.env.APP_DB_PATH as string);
|
||||
db.prepare("UPDATE photos SET status='candidate' WHERE id=?").run(pid);
|
||||
db.close();
|
||||
|
||||
let list = await request(app.getHttpServer()).get('/api/models/3/photos').expect(200);
|
||||
expect(list.body.find((p: any) => p.id === pid).status).toBe('candidate');
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/photos/${pid}/confirm`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
|
||||
list = await request(app.getHttpServer()).get('/api/models/3/photos').expect(200);
|
||||
expect(list.body.find((p: any) => p.id === pid).status).toBe('confirmed');
|
||||
});
|
||||
|
||||
// ===== 封面优先(featured)=====
|
||||
it('设为封面:cover_url 优先 featured,其次最小 id;权限受控', async () => {
|
||||
// 上传两张确认照片到模型 2
|
||||
const a = await request(app.getHttpServer())
|
||||
.post('/api/models/2/photos')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.attach('file', png, 'a.png')
|
||||
.expect(201);
|
||||
const b = await request(app.getHttpServer())
|
||||
.post('/api/models/2/photos')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.attach('file', png, 'b.png')
|
||||
.expect(201);
|
||||
|
||||
// 默认封面 = 最早一张(a)
|
||||
const findCover = async () => {
|
||||
const res = await request(app.getHttpServer())
|
||||
.get('/api/models?pageSize=50')
|
||||
.expect(200);
|
||||
return res.body.items.find((m: any) => m.id === 2)?.cover_url as string | null;
|
||||
};
|
||||
const aFile = a.body.url.replace('/uploads/', '');
|
||||
const bFile = b.body.url.replace('/uploads/', '');
|
||||
expect(await findCover()).toBe(`/uploads/${aFile}`);
|
||||
|
||||
// 普通用户设为封面 403
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/photos/${b.body.id}/feature`)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.expect(403);
|
||||
|
||||
// 管理员把 b 设为封面 → cover_url 切到 b
|
||||
const feat = await request(app.getHttpServer())
|
||||
.post(`/api/photos/${b.body.id}/feature`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
expect(feat.body.featured).toBe(true);
|
||||
expect(feat.body.status).toBe('confirmed');
|
||||
expect(await findCover()).toBe(`/uploads/${bFile}`);
|
||||
|
||||
// 改 featured 到 a → 旧 featured 清除,cover 回到 a
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/photos/${a.body.id}/feature`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.expect(201);
|
||||
const photos = await request(app.getHttpServer()).get('/api/models/2/photos').expect(200);
|
||||
expect(photos.body.find((p: any) => p.id === a.body.id).featured).toBe(true);
|
||||
expect(photos.body.find((p: any) => p.id === b.body.id).featured).toBe(false);
|
||||
expect(await findCover()).toBe(`/uploads/${aFile}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"rootDir": ".",
|
||||
"testEnvironment": "node",
|
||||
"testRegex": ".e2e-spec.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": ["ts-jest", { "tsconfig": "<rootDir>/../tsconfig.json" }]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,29 @@
|
||||
# 中国机车图鉴 · 前端 (apps/web)
|
||||
|
||||
Vite + React + TypeScript,消费 `apps/api`(NestJS)的接口。对应 Phase 1C(T-1.5/1.6/1.7)。
|
||||
|
||||
## 功能
|
||||
- 列表页:分类/状态/国别/年代区间/时速筛选 + 排序 + 分页
|
||||
- 三视图切换:**列表卡片 / 时间轴(分类泳道)/ 图鉴卡牌(含收集占位)**
|
||||
- 详情页:技术参数表 + 原始数据(raw_json)保真表
|
||||
- 全局搜索框:防抖下拉,命中跳转详情
|
||||
|
||||
## 开发运行(需先启动后端)
|
||||
```bash
|
||||
# 1) 终端 A:启动后端 API(在 apps/api)
|
||||
cd apps/api && npm run build && node dist/main.js # http://localhost:3001
|
||||
|
||||
# 2) 终端 B:启动前端(在 apps/web)
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
Vite 已将 `/api` 代理到 `http://127.0.0.1:3001`,无需额外配置。
|
||||
|
||||
## 测试与构建
|
||||
```bash
|
||||
npm test # Vitest 单元/组件测试(23 项)
|
||||
npm run build # tsc 类型检查 + vite 生产构建
|
||||
```
|
||||
|
||||
## 说明
|
||||
- 时间轴布局、格式化、查询拼装等纯逻辑抽到 `src/lib`、`src/api`,便于单测。
|
||||
- 图鉴"已收集/未收集"为占位,收集逻辑待 Phase 3 打卡功能接入。
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// 回归:贡献榜列表曾因 class 名 `.lb` 与灯箱冲突变成全屏遮罩,导致无法离开页面
|
||||
test('从贡献榜可正常导航离开', async ({ page }) => {
|
||||
await page.goto('/leaderboard');
|
||||
await expect(page.getByRole('heading', { name: '贡献榜' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /图鉴/ }).click();
|
||||
await page.getByRole('menuitem', { name: '探索' }).click();
|
||||
await expect(page).toHaveURL(/\/explore/);
|
||||
});
|
||||
|
||||
// 回归:/api-docs 曾被 vite 的 /api 代理误吞(前缀匹配),导致路由 404
|
||||
test('开放API文档路由不被代理吞掉', async ({ page }) => {
|
||||
await page.goto('/api-docs');
|
||||
await expect(page.getByRole('heading', { name: /开放 API/ })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/** MVP 全链路冒烟 — 对应 T-1.8(已适配叙事首页 + 探索页 IA)。*/
|
||||
|
||||
test('叙事首页:英雄区 + 时代章节 + 代表车', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: /中国工业史/ })).toBeVisible();
|
||||
await expect(page.getByText('蒸汽时代')).toBeVisible();
|
||||
await expect(page.getByText('高铁时代')).toBeVisible();
|
||||
// 代表车 mini 卡存在
|
||||
await expect(page.locator('.mini').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('首页 → 探索(CTA)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: /进入图鉴探索/ }).click();
|
||||
await expect(page).toHaveURL(/\/explore/);
|
||||
await expect(page.getByTestId('gallery')).toBeVisible();
|
||||
});
|
||||
|
||||
test('探索页默认图鉴:分页(21/页) + 点击收集', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery-card').first()).toBeVisible();
|
||||
// 每页至多 21 个
|
||||
expect(await page.getByTestId('gallery-card').count()).toBeLessThanOrEqual(21);
|
||||
// 分页器存在(在图鉴上方)
|
||||
await expect(page.getByRole('button', { name: '下一页' })).toBeVisible();
|
||||
await expect(page.locator('.gcard-art .thumb').first()).toBeVisible();
|
||||
const firstCollect = page.locator('.gcard-collect').first();
|
||||
await expect(firstCollect).toHaveAttribute('aria-pressed', 'false');
|
||||
await firstCollect.click();
|
||||
await expect(firstCollect).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('筛选(折叠面板)→ 图鉴卡片 → 详情(全部详情, 全中文)', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await page.getByRole('button', { name: /筛选/ }).click();
|
||||
await page.locator('.filterbar select').first().selectOption('电力机车');
|
||||
const firstCard = page.locator('.gcard-art').first();
|
||||
await expect(firstCard).toBeVisible();
|
||||
await firstCard.click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
await expect(page.getByRole('heading', { name: '基本信息' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '动力与性能' })).toBeVisible();
|
||||
// 原始数据英文键已映射为中文
|
||||
await expect(page.getByText('model_code')).toHaveCount(0);
|
||||
await expect(page.getByText('max_speed_value')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('详情图册:管理员上传图片并灯箱缩放', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('heading', { name: '图册' }).scrollIntoViewIfNeeded();
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await page.setInputFiles('input[type=file]', {
|
||||
name: 'demo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
});
|
||||
await expect(page.locator('.img-cell img').first()).toBeVisible();
|
||||
await page.locator('.img-cell img').first().click();
|
||||
await expect(page.getByTestId('lightbox')).toBeVisible();
|
||||
await page.getByRole('button', { name: '放大' }).click();
|
||||
await expect(page.getByTestId('lightbox')).toContainText('%');
|
||||
});
|
||||
|
||||
test('时间轴视图渲染并可点击节点', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await page.getByRole('button', { name: '时间轴' }).click();
|
||||
await expect(page.getByTestId('timeline')).toBeVisible();
|
||||
await expect(page.locator('.error')).toHaveCount(0);
|
||||
expect(await page.locator('.node').count()).toBeGreaterThan(30);
|
||||
await page.locator('.node').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
});
|
||||
|
||||
test('全局搜索命中并跳转详情', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByPlaceholder('搜索型号 / 生产商 / 系列…').fill('HXD');
|
||||
await expect(page.locator('.search-dropdown li').first()).toBeVisible();
|
||||
await page.locator('.search-dropdown li').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
});
|
||||
|
||||
test('首页章节链接 → 探索并带分类筛选', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: /探索全部.*车型/ }).first().click();
|
||||
await expect(page).toHaveURL(/\/explore\?category=/);
|
||||
await expect(page.getByTestId('gallery')).toBeVisible();
|
||||
});
|
||||
|
||||
test('账户:注册 → 用户菜单 → 个人主页', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await expect(page.getByTestId('auth-modal')).toBeVisible();
|
||||
await page.getByRole('button', { name: '注册', exact: true }).click();
|
||||
await page.getByPlaceholder('昵称').fill('E2E用户');
|
||||
await page.getByPlaceholder('邮箱').fill(`e2e_${Date.now()}@test.com`);
|
||||
await page.getByPlaceholder('密码(至少 6 位)').fill('secret123');
|
||||
await page.getByRole('button', { name: '注册并登录' }).click();
|
||||
await expect(page.getByTestId('auth-modal')).toHaveCount(0);
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await page.locator('.user-chip').click();
|
||||
await expect(page).toHaveURL(/\/me/);
|
||||
await expect(page.getByText('贡献积分', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('管理员编辑词条 → 立即生效', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('button', { name: /编辑词条/ }).click();
|
||||
await expect(page.getByTestId('edit-modal')).toBeVisible();
|
||||
await page
|
||||
.locator('.edit-field', { hasText: '用途' })
|
||||
.locator('input')
|
||||
.fill(`用途修订 ${Date.now()}`);
|
||||
await page.getByRole('button', { name: '提交修改' }).click();
|
||||
await expect(page.locator('.notice')).toContainText('生效');
|
||||
});
|
||||
|
||||
test('普通用户看不到编辑/上传入口(图鉴只读)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await expect(page.getByRole('button', { name: /编辑词条/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '+ 添加图片' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function registerUser(page: import('@playwright/test').Page, name: string) {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: '注册', exact: true }).click();
|
||||
await page.getByPlaceholder('昵称').fill(name);
|
||||
await page.getByPlaceholder('邮箱').fill(`u_${Date.now()}@test.com`);
|
||||
await page.getByPlaceholder('密码(至少 6 位)').fill('secret123');
|
||||
await page.getByRole('button', { name: '注册并登录' }).click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
}
|
||||
|
||||
test('认领词条维护 → 维护者署名出现', async ({ page }) => {
|
||||
const name = `维护${Date.now() % 100000}`;
|
||||
await registerUser(page, name);
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('button', { name: '认领维护' }).click();
|
||||
await expect(page.locator('.maintainers')).toContainText(name);
|
||||
});
|
||||
|
||||
test('个人主页含等级与徽章区', async ({ page }) => {
|
||||
await registerUser(page, `等级${Date.now() % 100000}`);
|
||||
await page.locator('.user-chip').click();
|
||||
await expect(page).toHaveURL(/\/me/);
|
||||
await expect(page.locator('.level-bar')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '徽章' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('贡献榜页可访问', async ({ page }) => {
|
||||
await page.goto('/leaderboard');
|
||||
await expect(page.getByRole('heading', { name: '贡献榜' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('测试账号一键填入并登录', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toContainText('演示用户');
|
||||
});
|
||||
|
||||
test('管理员一键登录后可见审核入口', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '审核' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('社区:发帖 → 帖子页回复', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /社区/ }).click();
|
||||
await page.getByRole('menuitem', { name: '论坛' }).click();
|
||||
await expect(page).toHaveURL(/\/community/);
|
||||
await page.getByRole('button', { name: '+ 发帖' }).click();
|
||||
const title = `测试帖 ${Date.now() % 100000}`;
|
||||
await page.getByPlaceholder('标题').fill(title);
|
||||
await page.getByPlaceholder('正文…').fill('这是一条 e2e 测试帖');
|
||||
await page.getByRole('button', { name: '发布' }).click();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(title) }).click();
|
||||
await expect(page).toHaveURL(/\/thread\/\d+/);
|
||||
await page.getByPlaceholder('写下你的回复…').fill('e2e 回复');
|
||||
await page.getByRole('button', { name: '回复' }).click();
|
||||
await expect(page.locator('.replies')).toContainText('e2e 回复');
|
||||
});
|
||||
|
||||
test('打卡:详情页登记目击 → 出现在列表', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('heading', { name: '目击打卡' }).scrollIntoViewIfNeeded();
|
||||
await page.getByPlaceholder('纬度 lat').fill('39.90');
|
||||
await page.getByPlaceholder('经度 lng').fill('116.40');
|
||||
await page.getByPlaceholder('车站/地点').fill('北京南站');
|
||||
await page.getByRole('button', { name: '打卡', exact: true }).click();
|
||||
await expect(page.locator('.sighting-row').first()).toContainText('北京南站');
|
||||
});
|
||||
|
||||
test('打卡地图页加载', async ({ page }) => {
|
||||
await page.goto('/map');
|
||||
await expect(page.getByRole('heading', { name: '打卡地图' })).toBeVisible();
|
||||
await expect(page.getByTestId('map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('参数对比:加入车型 → 雷达图 + 表格', async ({ page }) => {
|
||||
await page.goto('/compare');
|
||||
await expect(page.getByRole('heading', { name: '参数对比' })).toBeVisible();
|
||||
await page.getByPlaceholder('搜索型号加入对比…').fill('CR400');
|
||||
await page.locator('.cmp-search .search-dropdown li').first().click();
|
||||
await page.getByPlaceholder('搜索型号加入对比…').fill('HXD1');
|
||||
await page.locator('.cmp-search .search-dropdown li').first().click();
|
||||
await expect(page.getByTestId('radar')).toBeVisible();
|
||||
await expect(page.locator('.cmp-table')).toContainText('最高时速');
|
||||
await expect(page.locator('.cmp-chip')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('技术族谱:按系列展示并可进详情', async ({ page }) => {
|
||||
await page.goto('/family');
|
||||
await expect(page.getByRole('heading', { name: '技术族谱' })).toBeVisible();
|
||||
await expect(page.locator('.family-series').first()).toBeVisible();
|
||||
await page.locator('.family-node').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
});
|
||||
|
||||
test('关注车型 → 个人主页"最新目击"区出现', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('button', { name: /关注/ }).click();
|
||||
await expect(page.getByRole('button', { name: '★ 已关注' })).toBeVisible();
|
||||
|
||||
await page.locator('.user-chip').click();
|
||||
await expect(page.getByRole('heading', { name: /关注的车型/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('数据大屏渲染 KPI 与图表', async ({ page }) => {
|
||||
await page.goto('/stats');
|
||||
await expect(page.getByRole('heading', { name: /数据大屏/ })).toBeVisible();
|
||||
await expect(page.locator('.kpi').first()).toBeVisible();
|
||||
await expect(page.locator('.donut').first()).toBeVisible();
|
||||
await expect(page.locator('.vbars')).toBeVisible();
|
||||
await expect(page.getByTestId('evolution-curve')).toBeVisible();
|
||||
});
|
||||
|
||||
test('开放 API 文档页与导出链接', async ({ page }) => {
|
||||
await page.goto('/api-docs');
|
||||
await expect(page.getByRole('heading', { name: /开放 API/ })).toBeVisible();
|
||||
await expect(page.locator('a[href="/api/export/models.csv"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AI 识车页:未登录提示 + 上传区', async ({ page }) => {
|
||||
await page.goto('/identify');
|
||||
await expect(page.getByRole('heading', { name: 'AI 识车' })).toBeVisible();
|
||||
await expect(page.locator('.identify-drop')).toBeVisible();
|
||||
await expect(page.locator('.notice')).toContainText('登录');
|
||||
});
|
||||
|
||||
test('管理员候选审图页可访问', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await page.getByRole('link', { name: '候选审图' }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/photos/);
|
||||
await expect(page.getByRole('heading', { name: '候选审图' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('普通用户无候选审图入口', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '候选审图' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('图鉴:输入型号关键字直接筛选', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery-card').first()).toBeVisible();
|
||||
await page.locator('.kw-input').fill('HXD');
|
||||
// 防抖后结果收敛,首张卡片型号含 HXD
|
||||
await expect(page.locator('.gcard-code').first()).toContainText('HXD');
|
||||
// 计数随筛选下降(全量 500+ → 少量)
|
||||
await expect(page.locator('.result-count')).toBeVisible();
|
||||
});
|
||||
|
||||
test('管理员:上传后可将照片设为封面', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/2');
|
||||
await page.getByRole('heading', { name: '图册' }).scrollIntoViewIfNeeded();
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await page.setInputFiles('input[type=file]', {
|
||||
name: 'cover.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
});
|
||||
// 已确认的图库照片出现"设为封面"★ 按钮
|
||||
const featureBtn = page.getByRole('button', { name: '设为封面' }).first();
|
||||
await expect(featureBtn).toBeVisible();
|
||||
await featureBtn.click();
|
||||
// 设为封面后按钮进入选中态(金色 on)
|
||||
await expect(page.locator('.img-feature.on')).toBeVisible();
|
||||
});
|
||||
|
||||
test('图鉴:翻到第 2 页进详情,返回仍停在第 2 页', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery-card').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一页' }).click();
|
||||
await expect(page).toHaveURL(/[?&]gp=2/);
|
||||
await expect(page.locator('.pagination')).toContainText('第 2');
|
||||
const firstCode = await page.locator('.gcard-code').first().textContent();
|
||||
|
||||
await page.locator('.gcard-art').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
|
||||
await page.getByRole('button', { name: /返回图鉴/ }).click();
|
||||
await expect(page).toHaveURL(/[?&]gp=2/);
|
||||
await expect(page.locator('.pagination')).toContainText('第 2');
|
||||
await expect(page.locator('.gcard-code').first()).toHaveText(firstCode || '');
|
||||
});
|
||||
|
||||
test('图鉴页不再显示"收集示例"按钮', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '收集示例' })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>中国机车图鉴</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@train/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright e2e 配置 — 对应 T-1.5/1.6/1.7/1.8 E2E。
|
||||
* 自动拉起后端 API(apps/api)与前端 dev server,再跑用例。
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: 'node dist/main.js',
|
||||
cwd: '../api',
|
||||
url: 'http://127.0.0.1:3001/api/health',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev -- --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, Route, Routes } from 'react-router-dom';
|
||||
import { SearchBox } from './components/SearchBox';
|
||||
import { AuthModal } from './components/AuthModal';
|
||||
import { NavMenu } from './components/NavMenu';
|
||||
import { IconBrand } from './components/icons';
|
||||
import { useAuth, ROLE_LABEL } from './lib/auth';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { ListPage } from './pages/ListPage';
|
||||
import { DetailPage } from './pages/DetailPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { ReviewPage } from './pages/ReviewPage';
|
||||
import { LeaderboardPage } from './pages/LeaderboardPage';
|
||||
import { ForumPage } from './pages/ForumPage';
|
||||
import { ThreadPage } from './pages/ThreadPage';
|
||||
import { MapPage } from './pages/MapPage';
|
||||
import { ComparePage } from './pages/ComparePage';
|
||||
import { FamilyPage } from './pages/FamilyPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
import { IdentifyPage } from './pages/IdentifyPage';
|
||||
import { AdminPhotosPage } from './pages/AdminPhotosPage';
|
||||
|
||||
const RANK: Record<string, number> = {
|
||||
guest: 0, user: 1, trusted: 2, moderator: 3, admin: 4,
|
||||
};
|
||||
|
||||
function UserMenu({ onLogin }: { onLogin: () => void }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
if (!user)
|
||||
return (
|
||||
<button className="login-btn" onClick={onLogin}>
|
||||
登录 / 注册
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Link to="/me" className="user-chip" title={ROLE_LABEL[user.role] ?? user.role}>
|
||||
<span className="user-avatar">{user.displayName.slice(0, 1)}</span>
|
||||
<span className="user-name">{user.displayName}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const canReview = !!user && RANK[user.role] >= RANK.moderator;
|
||||
const isAdmin = user?.role === 'admin';
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<Link to="/" className="brand">
|
||||
<IconBrand size={24} /> 中国机车图鉴
|
||||
</Link>
|
||||
<NavMenu canReview={canReview} isAdmin={isAdmin} />
|
||||
<SearchBox />
|
||||
<UserMenu onLogin={() => setShowAuth(true)} />
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/explore" element={<ListPage />} />
|
||||
<Route path="/models/:id" element={<DetailPage />} />
|
||||
<Route path="/me" element={<ProfilePage />} />
|
||||
<Route path="/review" element={<ReviewPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/community" element={<ForumPage />} />
|
||||
<Route path="/thread/:id" element={<ThreadPage />} />
|
||||
<Route path="/map" element={<MapPage />} />
|
||||
<Route path="/compare" element={<ComparePage />} />
|
||||
<Route path="/family" element={<FamilyPage />} />
|
||||
<Route path="/stats" element={<DashboardPage />} />
|
||||
<Route path="/api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="/identify" element={<IdentifyPage />} />
|
||||
<Route path="/admin/photos" element={<AdminPhotosPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="foot muted">
|
||||
<span>数据底座 · 沿技术演进主线呈现 · 众包共建</span>
|
||||
<nav className="foot-nav">
|
||||
<Link to="/api-docs">开放 API & 数据</Link>
|
||||
</nav>
|
||||
</footer>
|
||||
{showAuth && <AuthModal onClose={() => setShowAuth(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildQuery } from './client';
|
||||
|
||||
describe('buildQuery', () => {
|
||||
it('剔除空值', () => {
|
||||
expect(buildQuery({ a: 1, b: undefined, c: null, d: '' })).toBe('?a=1');
|
||||
});
|
||||
|
||||
it('多参数', () => {
|
||||
const s = buildQuery({ page: 2, category: '电力机车' });
|
||||
expect(s).toContain('page=2');
|
||||
expect(s).toContain('category=');
|
||||
});
|
||||
|
||||
it('空对象返回空串', () => {
|
||||
expect(buildQuery({})).toBe('');
|
||||
});
|
||||
|
||||
it('0 与 false 保留', () => {
|
||||
expect(buildQuery({ a: 0, b: false })).toBe('?a=0&b=false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
AuthResult,
|
||||
Board,
|
||||
Category,
|
||||
EditableField,
|
||||
Family,
|
||||
Identification,
|
||||
LeaderboardEntry,
|
||||
Maintainer,
|
||||
ModelDetail,
|
||||
ModelListItem,
|
||||
ModelQuery,
|
||||
Paged,
|
||||
Photo,
|
||||
PublicUser,
|
||||
Revision,
|
||||
SearchResult,
|
||||
Sighting,
|
||||
Spot,
|
||||
Stats,
|
||||
Thread,
|
||||
UserStats,
|
||||
} from '../types';
|
||||
|
||||
const BASE = import.meta.env?.VITE_API_BASE ?? '';
|
||||
const TOKEN_KEY = 'train.token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, t);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
export function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 把查询对象转为 URLSearchParams 字符串(剔除 null/undefined/空串)。*/
|
||||
export function buildQuery(params: Record<string, unknown>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const t = getToken();
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, { headers: { ...authHeaders() } });
|
||||
if (!res.ok) throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = Array.isArray((data as any)?.message)
|
||||
? (data as any).message.join(';')
|
||||
: (data as any)?.message || `请求失败 ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function deleteJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as any)?.message || `请求失败 ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function patchJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as any)?.message || `请求失败 ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function postForm<T>(path: string, form: FormData): Promise<T> {
|
||||
// 不手动设置 Content-Type,让浏览器带上 multipart boundary
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders() },
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = Array.isArray((data as any)?.message)
|
||||
? (data as any).message.join(';')
|
||||
: (data as any)?.message || `上传失败 ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
categories: () => getJson<Category[]>('/api/categories'),
|
||||
|
||||
models: (q: ModelQuery = {}) =>
|
||||
getJson<Paged<ModelListItem>>(
|
||||
`/api/models${buildQuery(q as Record<string, unknown>)}`,
|
||||
),
|
||||
|
||||
model: (id: number) => getJson<ModelDetail>(`/api/models/${id}`),
|
||||
|
||||
search: (q: string, limit = 10) =>
|
||||
getJson<{ query: string; results: SearchResult[] }>(
|
||||
`/api/search${buildQuery({ q, limit })}`,
|
||||
),
|
||||
|
||||
auth: {
|
||||
register: (email: string, password: string, displayName: string) =>
|
||||
postJson<AuthResult>('/api/auth/register', { email, password, displayName }),
|
||||
login: (email: string, password: string) =>
|
||||
postJson<AuthResult>('/api/auth/login', { email, password }),
|
||||
me: () => getJson<UserStats>('/api/auth/me'),
|
||||
},
|
||||
|
||||
editableFields: () => getJson<EditableField[]>('/api/editable-fields'),
|
||||
modelRevisions: (id: number) => getJson<Revision[]>(`/api/models/${id}/revisions`),
|
||||
submitRevision: (id: number, changes: Record<string, string>, note: string) =>
|
||||
postJson<Revision>(`/api/models/${id}/revisions`, { changes, note }),
|
||||
pendingRevisions: () => getJson<Revision[]>('/api/revisions/pending'),
|
||||
approveRevision: (rid: number) =>
|
||||
postJson<Revision>(`/api/revisions/${rid}/approve`, {}),
|
||||
rejectRevision: (rid: number) =>
|
||||
postJson<Revision>(`/api/revisions/${rid}/reject`, {}),
|
||||
|
||||
leaderboard: (limit = 20) =>
|
||||
getJson<LeaderboardEntry[]>(`/api/leaderboard${buildQuery({ limit })}`),
|
||||
maintainers: (id: number) => getJson<Maintainer[]>(`/api/models/${id}/maintainers`),
|
||||
claimMaintainer: (id: number) =>
|
||||
postJson<Maintainer[]>(`/api/models/${id}/maintainers`, {}),
|
||||
unclaimMaintainer: (id: number) =>
|
||||
deleteJson<Maintainer[]>(`/api/models/${id}/maintainers`),
|
||||
|
||||
boards: () => getJson<Board[]>('/api/boards'),
|
||||
threads: (params: { board?: string; modelId?: number } = {}) =>
|
||||
getJson<Thread[]>(`/api/threads${buildQuery(params)}`),
|
||||
thread: (id: number) => getJson<Thread>(`/api/threads/${id}`),
|
||||
createThread: (data: {
|
||||
board: string;
|
||||
modelId?: number;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => postJson<Thread>('/api/threads', data),
|
||||
addReply: (id: number, body: string) =>
|
||||
postJson<Thread>(`/api/threads/${id}/replies`, { body }),
|
||||
|
||||
modelSightings: (id: number) =>
|
||||
getJson<Sighting[]>(`/api/models/${id}/sightings`),
|
||||
createSighting: (
|
||||
id: number,
|
||||
data: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
station?: string;
|
||||
carNumber?: string;
|
||||
spottedAt?: string;
|
||||
description?: string;
|
||||
},
|
||||
) => postJson<Sighting>(`/api/models/${id}/sightings`, data),
|
||||
recentSightings: (limit = 30) =>
|
||||
getJson<Sighting[]>(`/api/sightings/recent${buildQuery({ limit })}`),
|
||||
sightingsMap: () => getJson<Sighting[]>('/api/sightings/map'),
|
||||
spots: () => getJson<Spot[]>('/api/sightings/spots'),
|
||||
families: (category?: string) =>
|
||||
getJson<Family[]>(`/api/models/families${buildQuery({ category: category ?? '' })}`),
|
||||
stats: () => getJson<Stats>('/api/stats'),
|
||||
|
||||
modelPhotos: (id: number) => getJson<Photo[]>(`/api/models/${id}/photos`),
|
||||
uploadPhoto: (id: number, file: File, caption = '') => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (caption) fd.append('caption', caption);
|
||||
return postForm<Photo>(`/api/models/${id}/photos`, fd);
|
||||
},
|
||||
deletePhoto: (pid: number) => deleteJson<{ ok: boolean }>(`/api/photos/${pid}`),
|
||||
confirmPhoto: (pid: number) => postJson<Photo>(`/api/photos/${pid}/confirm`, {}),
|
||||
featurePhoto: (pid: number) => postJson<Photo>(`/api/photos/${pid}/feature`, {}),
|
||||
candidatePhotos: () =>
|
||||
getJson<(Photo & { modelCode: string; category: string })[]>(
|
||||
'/api/photos/candidates',
|
||||
),
|
||||
|
||||
identify: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return postForm<Identification>('/api/identify', fd);
|
||||
},
|
||||
identifications: () => getJson<Identification[]>('/api/identifications'),
|
||||
updateIdentification: (id: number, note: string) =>
|
||||
patchJson<Identification>(`/api/identifications/${id}`, { note }),
|
||||
deleteIdentification: (id: number) =>
|
||||
deleteJson<{ ok: boolean }>(`/api/identifications/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { IconClose } from './icons';
|
||||
|
||||
const TEST_ACCOUNTS = [
|
||||
{ label: '管理员', email: 'admin@demo.com', password: 'demo1234' },
|
||||
{ label: '版主', email: 'mod@demo.com', password: 'demo1234' },
|
||||
{ label: '普通用户', email: 'user@demo.com', password: 'demo1234' },
|
||||
];
|
||||
|
||||
export function AuthModal({ onClose }: { onClose: () => void }) {
|
||||
const { login, register } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const fill = (acc: { email: string; password: string }) => {
|
||||
setMode('login');
|
||||
setEmail(acc.email);
|
||||
setPassword(acc.password);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
if (mode === 'login') await login(email, password);
|
||||
else await register(email, password, displayName);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} data-testid="auth-modal">
|
||||
<div className="modal-tabs">
|
||||
<button
|
||||
className={mode === 'login' ? 'active' : ''}
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'register' ? 'active' : ''}
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="auth-form">
|
||||
{mode === 'register' && (
|
||||
<input
|
||||
placeholder="昵称"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码(至少 6 位)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" className="auth-submit" disabled={busy}>
|
||||
{busy ? '处理中…' : mode === 'login' ? '登录' : '注册并登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{mode === 'login' && (
|
||||
<div className="test-accounts">
|
||||
<span className="muted">测试账号(点击填入,密码 demo1234):</span>
|
||||
<div className="test-account-btns">
|
||||
{TEST_ACCOUNTS.map((a) => (
|
||||
<button
|
||||
key={a.email}
|
||||
type="button"
|
||||
className="test-account"
|
||||
onClick={() => fill(a)}
|
||||
>
|
||||
<b>{a.label}</b>
|
||||
<small>{a.email}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭">
|
||||
<IconClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Button } from './ui';
|
||||
import { IconClose } from './icons';
|
||||
import type { EditableField, ModelDetail } from '../types';
|
||||
|
||||
const STATUS_ENUM = ['现役', '封存', '半封存', '退役', '报废', '保存', '试验', '未知'];
|
||||
const COUNTRY_TYPE_ENUM = ['国产', '进口', '引进仿制', '中外合资', '未知'];
|
||||
|
||||
export function EditModal({
|
||||
model,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
model: ModelDetail;
|
||||
onClose: () => void;
|
||||
onSaved: (msg: string) => void;
|
||||
}) {
|
||||
const [fields, setFields] = useState<EditableField[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [note, setNote] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.editableFields().then((fs) => {
|
||||
setFields(fs);
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of fs) {
|
||||
const v = (model as any)[f.field];
|
||||
init[f.field] = v == null ? '' : String(v);
|
||||
}
|
||||
setValues(init);
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const changes: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
const cur = (model as any)[f.field];
|
||||
const curStr = cur == null ? '' : String(cur);
|
||||
if (values[f.field] !== curStr) changes[f.field] = values[f.field];
|
||||
}
|
||||
if (Object.keys(changes).length === 0) {
|
||||
setError('没有任何改动');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const rev = await api.submitRevision(model.id, changes, note);
|
||||
onSaved(
|
||||
rev.status === 'approved'
|
||||
? '修改已通过并生效(你的权限可直接生效)'
|
||||
: '修改已提交,等待版主审核',
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '提交失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="modal modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="edit-modal"
|
||||
>
|
||||
<h2>编辑「{model.model_code}」</h2>
|
||||
<p className="muted edit-hint">
|
||||
提交后进入审核(信任用户及以上可直接生效)。修改有完整历史,可回溯。
|
||||
</p>
|
||||
<form onSubmit={submit} className="edit-form">
|
||||
<div className="edit-grid">
|
||||
{fields.map((f) => (
|
||||
<label key={f.field} className="edit-field">
|
||||
<span>{f.label}</span>
|
||||
{f.field === 'status' ? (
|
||||
<select
|
||||
value={values[f.field] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{STATUS_ENUM.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.field === 'country_type' ? (
|
||||
<select
|
||||
value={values[f.field] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{COUNTRY_TYPE_ENUM.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === 'int' ? 'number' : 'text'}
|
||||
value={values[f.field] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="edit-note"
|
||||
placeholder="修改说明 / 来源(可选)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="edit-actions">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{busy ? '提交中…' : '提交修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭"><IconClose size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { EvolutionPoint } from '../lib/eras';
|
||||
|
||||
/** 时速演进曲线(SVG):横轴年代、纵轴最高时速,体现"科学感"主线。*/
|
||||
export function EvolutionCurve({ points }: { points: EvolutionPoint[] }) {
|
||||
if (points.length < 2) return null;
|
||||
const W = 720;
|
||||
const H = 180;
|
||||
const padX = 36;
|
||||
const padY = 24;
|
||||
const decades = points.map((p) => p.decade);
|
||||
const speeds = points.map((p) => p.maxSpeed);
|
||||
const minD = Math.min(...decades);
|
||||
const maxD = Math.max(...decades);
|
||||
const maxS = Math.max(...speeds);
|
||||
const spanD = Math.max(1, maxD - minD);
|
||||
|
||||
const x = (d: number) => padX + ((d - minD) / spanD) * (W - padX * 2);
|
||||
const y = (s: number) => H - padY - (s / maxS) * (H - padY * 2);
|
||||
|
||||
const line = points.map((p) => `${x(p.decade)},${y(p.maxSpeed)}`).join(' ');
|
||||
const area = `${padX},${H - padY} ${line} ${W - padX},${H - padY}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="evo"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="中国机车最高时速演进曲线"
|
||||
data-testid="evolution-curve"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="evoFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#4ea1ff" stopOpacity="0.35" />
|
||||
<stop offset="1" stopColor="#4ea1ff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={area} fill="url(#evoFill)" />
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="#4ea1ff"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{points.map((p) => (
|
||||
<g key={p.decade}>
|
||||
<circle cx={x(p.decade)} cy={y(p.maxSpeed)} r="3.5" fill="#9ed0ff" />
|
||||
{(p.decade === minD ||
|
||||
p.decade === maxD ||
|
||||
p.maxSpeed === maxS) && (
|
||||
<text
|
||||
x={x(p.decade)}
|
||||
y={y(p.maxSpeed) - 10}
|
||||
fontSize="11"
|
||||
fill="#cdd5e0"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{p.maxSpeed}km/h
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
x={x(p.decade)}
|
||||
y={H - 6}
|
||||
fontSize="10"
|
||||
fill="#8b93a1"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{p.decade}s
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Category, ModelQuery } from '../types';
|
||||
|
||||
const STATUSES = ['现役', '封存', '半封存', '退役', '报废', '试验', '保存', '未知'];
|
||||
const COUNTRIES = ['国产', '进口', '引进仿制', '中外合资', '未知'];
|
||||
|
||||
export function FilterBar({
|
||||
categories,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
categories: Category[];
|
||||
query: ModelQuery;
|
||||
onChange: (patch: Partial<ModelQuery>) => void;
|
||||
}) {
|
||||
const catNames = Array.from(new Set(categories.map((c) => c.name)));
|
||||
return (
|
||||
<div className="filterbar">
|
||||
<select
|
||||
value={query.category ?? ''}
|
||||
onChange={(e) => onChange({ category: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
{catNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={query.status ?? ''}
|
||||
onChange={(e) => onChange({ status: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={query.country ?? ''}
|
||||
onChange={(e) => onChange({ country: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">全部国别</option>
|
||||
{COUNTRIES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
placeholder="起始年"
|
||||
value={query.yearFrom ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ yearFrom: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="截止年"
|
||||
value={query.yearTo ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ yearTo: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="时速≥"
|
||||
value={query.speedMin ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ speedMin: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={`${query.sort}:${query.order}`}
|
||||
onChange={(e) => {
|
||||
const [sort, order] = e.target.value.split(':');
|
||||
onChange({ sort, order: order as 'asc' | 'desc' });
|
||||
}}
|
||||
>
|
||||
<option value="first_year:asc">年代 ↑</option>
|
||||
<option value="first_year:desc">年代 ↓</option>
|
||||
<option value="max_speed_value:desc">时速 ↓</option>
|
||||
<option value="max_speed_value:asc">时速 ↑</option>
|
||||
<option value="model_code:asc">型号 A→Z</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { GalleryCard } from './GalleryCard';
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
const m = {
|
||||
id: 7,
|
||||
model_code: 'CR400AF',
|
||||
full_name: '复兴号',
|
||||
series: '',
|
||||
manufacturer: '中车四方',
|
||||
country: '中国',
|
||||
country_type: '国产',
|
||||
first_year: 2017,
|
||||
last_year: null,
|
||||
status: '现役',
|
||||
usage: '高速客运',
|
||||
max_speed_value: 350,
|
||||
max_speed_unit: 'km/h',
|
||||
weight_value: null,
|
||||
weight_unit: '',
|
||||
axle_arrangement: '',
|
||||
category: '动车组',
|
||||
subcat: '复兴号',
|
||||
} as ModelListItem;
|
||||
|
||||
const renderCard = (collected: boolean, onToggle = vi.fn()) =>
|
||||
render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<GalleryCard m={m} index={1} collected={collected} onToggle={onToggle} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('GalleryCard', () => {
|
||||
it('展示缩略图、编号、收藏按钮', () => {
|
||||
renderCard(false);
|
||||
expect(screen.getByRole('img', { name: /CR400AF/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('#001')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收藏' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('已收藏:按钮变为已收藏', () => {
|
||||
renderCard(true);
|
||||
expect(screen.getByRole('button', { name: '已收藏' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击收藏触发 onToggle(id)', () => {
|
||||
const onToggle = vi.fn();
|
||||
renderCard(false, onToggle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '收藏' }));
|
||||
expect(onToggle).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('缩略图链接指向详情', () => {
|
||||
renderCard(false);
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.some((l) => l.getAttribute('href') === '/models/7')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ModelListItem } from '../types';
|
||||
import { fmtEra } from '../lib/format';
|
||||
import { Thumb } from './Thumb';
|
||||
import { IconStarFilled, IconStarOutline } from './icons';
|
||||
|
||||
/**
|
||||
* 图鉴卡:封面优先级 共享图库封面(cover_url,本地图库) > 分类示意图。
|
||||
* 真实照片已批量下载到本地共享图库,不再实时访问外部图源;★ 为个人收藏书签。
|
||||
*/
|
||||
export function GalleryCard({
|
||||
m,
|
||||
index,
|
||||
collected,
|
||||
onToggle,
|
||||
}: {
|
||||
m: ModelListItem;
|
||||
index: number;
|
||||
collected: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const no = `#${String(index).padStart(3, '0')}`;
|
||||
const cover = m.cover_url ?? null;
|
||||
|
||||
return (
|
||||
<div className={`gcard${collected ? ' collected' : ''}${cover ? ' bright' : ''}`} data-testid="gallery-card">
|
||||
<span className="gcard-no">{no}</span>
|
||||
<button
|
||||
className="gcard-collect"
|
||||
aria-pressed={collected}
|
||||
aria-label={collected ? '已收藏' : '收藏'}
|
||||
title={collected ? '已收藏' : '收藏'}
|
||||
onClick={() => onToggle(m.id)}
|
||||
>
|
||||
{collected ? <IconStarFilled size={16} /> : <IconStarOutline size={16} />}
|
||||
</button>
|
||||
<Link to={`/models/${m.id}`} className="gcard-art">
|
||||
{cover ? (
|
||||
<img src={cover} alt={`${m.model_code} 照片`} loading="lazy" />
|
||||
) : (
|
||||
<Thumb category={m.category} code={m.model_code} />
|
||||
)}
|
||||
<span className="gcard-caption">
|
||||
<span className="gcard-code">{m.model_code}</span>
|
||||
<span className="gcard-meta">
|
||||
{m.category} · {fmtEra(m.first_year, m.last_year)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ModelListItem } from '../types';
|
||||
import { GalleryCard } from './GalleryCard';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
export const GALLERY_PAGE_SIZE = 21;
|
||||
|
||||
/**
|
||||
* 图鉴卡牌视图(T-1.7)。图片优先的卡牌墙 + 收集进度 + 分页(21/页,分页器在上)。
|
||||
* 分页受控(page/onPage 由上层管理,落在 URL,便于详情返回后回到原页)。
|
||||
*/
|
||||
export function GalleryView({
|
||||
models,
|
||||
collectedIds,
|
||||
onToggle,
|
||||
page,
|
||||
onPage,
|
||||
}: {
|
||||
models: ModelListItem[];
|
||||
collectedIds: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
page: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const collectedHere = models.filter((m) => collectedIds.has(m.id)).length;
|
||||
const pct = models.length ? Math.round((collectedHere / models.length) * 100) : 0;
|
||||
const pageCount = Math.max(1, Math.ceil(models.length / GALLERY_PAGE_SIZE));
|
||||
const safePage = Math.min(Math.max(1, page), pageCount);
|
||||
const start = (safePage - 1) * GALLERY_PAGE_SIZE;
|
||||
const pageItems = models.slice(start, start + GALLERY_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div data-testid="gallery">
|
||||
<div className="collect-progress">
|
||||
<span>
|
||||
已收集 <strong>{collectedHere}</strong> / {models.length}(当前筛选)
|
||||
</span>
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分页器在上 */}
|
||||
<Pagination
|
||||
total={models.length}
|
||||
page={safePage}
|
||||
pageSize={GALLERY_PAGE_SIZE}
|
||||
onPage={onPage}
|
||||
/>
|
||||
|
||||
<div className="gallery">
|
||||
{pageItems.map((m, i) => (
|
||||
<GalleryCard
|
||||
key={m.id}
|
||||
m={m}
|
||||
index={start + i + 1}
|
||||
collected={collectedIds.has(m.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { GalleryImage } from '../types';
|
||||
import { IconClose, IconPrev, IconNext, IconZoomIn, IconZoomOut, IconReset } from './icons';
|
||||
|
||||
/** 图片灯箱:缩放(按钮/滚轮)、平移(拖拽)、上一张/下一张、Esc 关闭,含署名。*/
|
||||
export function Lightbox({
|
||||
images,
|
||||
index,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onIndexChange: (i: number) => void;
|
||||
}) {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [drag, setDrag] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
reset();
|
||||
onIndexChange((index - 1 + images.length) % images.length);
|
||||
}, [index, images.length, onIndexChange, reset]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
reset();
|
||||
onIndexChange((index + 1) % images.length);
|
||||
}, [index, images.length, onIndexChange, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if (e.key === 'ArrowLeft') prev();
|
||||
else if (e.key === 'ArrowRight') next();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
const img = images[index];
|
||||
if (!img) return null;
|
||||
|
||||
const zoom = (delta: number) =>
|
||||
setScale((s) => Math.min(5, Math.max(1, +(s + delta).toFixed(2))));
|
||||
|
||||
return (
|
||||
<div className="lb" onClick={onClose} data-testid="lightbox">
|
||||
<div className="lb-stage" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.caption}
|
||||
className="lb-img"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
cursor: scale > 1 ? (drag ? 'grabbing' : 'grab') : 'auto',
|
||||
}}
|
||||
draggable={false}
|
||||
onWheel={(e) => zoom(e.deltaY < 0 ? 0.2 : -0.2)}
|
||||
onMouseDown={(e) =>
|
||||
scale > 1 && setDrag({ x: e.clientX - offset.x, y: e.clientY - offset.y })
|
||||
}
|
||||
onMouseMove={(e) =>
|
||||
drag && setOffset({ x: e.clientX - drag.x, y: e.clientY - drag.y })
|
||||
}
|
||||
onMouseUp={() => setDrag(null)}
|
||||
onMouseLeave={() => setDrag(null)}
|
||||
/>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button className="lb-nav lb-prev" onClick={prev} aria-label="上一张">
|
||||
<IconPrev size={32} />
|
||||
</button>
|
||||
<button className="lb-nav lb-next" onClick={next} aria-label="下一张">
|
||||
<IconNext size={32} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="lb-toolbar">
|
||||
<button onClick={() => zoom(-0.3)} aria-label="缩小">
|
||||
<IconZoomOut size={18} />
|
||||
</button>
|
||||
<span>{Math.round(scale * 100)}%</span>
|
||||
<button onClick={() => zoom(0.3)} aria-label="放大">
|
||||
<IconZoomIn size={18} />
|
||||
</button>
|
||||
<button onClick={reset} aria-label="重置">
|
||||
<IconReset size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lb-caption">
|
||||
<span>
|
||||
{img.caption}
|
||||
{images.length > 1 ? ` · ${index + 1}/${images.length}` : ''}
|
||||
</span>
|
||||
{img.attribution && (
|
||||
<span className="lb-attr">
|
||||
© {img.attribution.author || '未署名'}
|
||||
{img.attribution.license ? ` · ${img.attribution.license}` : ''}
|
||||
{img.attribution.url ? (
|
||||
<>
|
||||
{' · '}
|
||||
<a href={img.attribution.url} target="_blank" rel="noreferrer noopener">
|
||||
来源
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="lb-close" onClick={onClose} aria-label="关闭">
|
||||
<IconClose size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ModelCard } from './ModelCard';
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
const m: ModelListItem = {
|
||||
id: 42,
|
||||
model_code: 'HXD1型',
|
||||
full_name: '',
|
||||
series: '和谐',
|
||||
manufacturer: '中车株洲',
|
||||
country: '中国',
|
||||
country_type: '国产',
|
||||
first_year: 2006,
|
||||
last_year: null,
|
||||
status: '现役',
|
||||
usage: '干线货运',
|
||||
max_speed_value: 120,
|
||||
max_speed_unit: 'km/h',
|
||||
weight_value: 150,
|
||||
weight_unit: 't',
|
||||
axle_arrangement: 'Co-Co',
|
||||
category: '电力机车',
|
||||
subcat: '',
|
||||
};
|
||||
|
||||
describe('ModelCard', () => {
|
||||
const renderCard = (item: ModelListItem) =>
|
||||
render(
|
||||
<MemoryRouter
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
<ModelCard m={item} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
it('展示型号/分类/参数', () => {
|
||||
renderCard(m);
|
||||
expect(screen.getByText('HXD1型')).toBeInTheDocument();
|
||||
expect(screen.getByText('120km/h')).toBeInTheDocument();
|
||||
expect(screen.getByText('现役')).toBeInTheDocument();
|
||||
expect(screen.getByText(/中车株洲/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('链接指向详情页', () => {
|
||||
renderCard(m);
|
||||
const link = screen.getByTestId('model-card');
|
||||
expect(link).toHaveAttribute('href', '/models/42');
|
||||
});
|
||||
|
||||
it('缺失数值优雅留白', () => {
|
||||
renderCard({ ...m, max_speed_value: null });
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ModelListItem } from '../types';
|
||||
import { fmtEra, fmtValueUnit, statusClass } from '../lib/format';
|
||||
|
||||
export function ModelCard({ m }: { m: ModelListItem }) {
|
||||
return (
|
||||
<Link to={`/models/${m.id}`} className="card" data-testid="model-card">
|
||||
<div className="card-head">
|
||||
<span className="card-code">{m.model_code}</span>
|
||||
<span className={statusClass(m.status)}>{m.status}</span>
|
||||
</div>
|
||||
<div className="card-cat">
|
||||
{m.category}
|
||||
{m.subcat ? ` · ${m.subcat}` : ''}
|
||||
{m.series ? ` · ${m.series}` : ''}
|
||||
</div>
|
||||
<dl className="card-spec">
|
||||
<div>
|
||||
<dt>年代</dt>
|
||||
<dd>{fmtEra(m.first_year, m.last_year)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最高时速</dt>
|
||||
<dd>{fmtValueUnit(m.max_speed_value, m.max_speed_unit)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>整备重量</dt>
|
||||
<dd>{fmtValueUnit(m.weight_value, m.weight_unit)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>国别</dt>
|
||||
<dd>{m.country_type || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="card-maker">{m.manufacturer || '生产商未知'}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { IconCaret } from './icons';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: '图鉴',
|
||||
items: [
|
||||
{ to: '/explore', label: '探索图鉴' },
|
||||
{ to: '/stats', label: '数据大屏' },
|
||||
{ to: '/family', label: '技术族谱' },
|
||||
{ to: '/compare', label: '参数对比' },
|
||||
{ to: '/identify', label: 'AI 识车' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '社区',
|
||||
items: [
|
||||
{ to: '/community', label: '论坛' },
|
||||
{ to: '/map', label: '打卡地图' },
|
||||
{ to: '/leaderboard', label: '贡献榜' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function NavMenu({ canReview, isAdmin }: { canReview: boolean; isAdmin: boolean }) {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const loc = useLocation();
|
||||
|
||||
// 路由变化时关闭
|
||||
useEffect(() => setOpen(null), [loc.pathname]);
|
||||
|
||||
// 点击外部 / Esc 关闭
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(null);
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="mainnav" ref={ref}>
|
||||
<NavLink to="/" end className="nav-top">
|
||||
故事
|
||||
</NavLink>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const active = g.items.some((it) => loc.pathname.startsWith(it.to));
|
||||
return (
|
||||
<div className="nav-group" key={g.label}>
|
||||
<button
|
||||
className={`nav-top nav-trigger${active ? ' active' : ''}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open === g.label}
|
||||
onClick={() => setOpen((o) => (o === g.label ? null : g.label))}
|
||||
>
|
||||
{g.label}
|
||||
<span className="caret"><IconCaret size={16} /></span>
|
||||
</button>
|
||||
{open === g.label && (
|
||||
<div className="nav-dropdown" role="menu">
|
||||
{g.items.map((it) => (
|
||||
<NavLink key={it.to} to={it.to} className="nav-item" role="menuitem">
|
||||
{it.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canReview && (
|
||||
<NavLink to="/review" className="nav-top">
|
||||
审核
|
||||
</NavLink>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavLink to="/admin/photos" className="nav-top">
|
||||
候选审图
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function Pagination({
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPage,
|
||||
}: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => onPage(page - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {page} / {pages} 页 · 共 {total} 条
|
||||
</span>
|
||||
<button disabled={page >= pages} onClick={() => onPage(page + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CompareAxis, NormalizedSeries } from '../lib/compare';
|
||||
|
||||
const COLORS = ['#4ea1ff', '#4ade80', '#fbbf24', '#c084fc'];
|
||||
|
||||
export function RadarChart({
|
||||
axes,
|
||||
series,
|
||||
}: {
|
||||
axes: CompareAxis[];
|
||||
series: NormalizedSeries[];
|
||||
}) {
|
||||
const size = 360;
|
||||
const c = size / 2;
|
||||
const r = size / 2 - 60;
|
||||
const n = axes.length;
|
||||
const angle = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
|
||||
const pt = (i: number, radius: number) => ({
|
||||
x: c + radius * Math.cos(angle(i)),
|
||||
y: c + radius * Math.sin(angle(i)),
|
||||
});
|
||||
|
||||
const rings = [0.25, 0.5, 0.75, 1];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="radar"
|
||||
role="img"
|
||||
aria-label="参数对比雷达图"
|
||||
data-testid="radar"
|
||||
>
|
||||
{/* 网格环 */}
|
||||
{rings.map((ring) => (
|
||||
<polygon
|
||||
key={ring}
|
||||
points={axes
|
||||
.map((_, i) => {
|
||||
const p = pt(i, r * ring);
|
||||
return `${p.x},${p.y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke="#2a2f3a"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{/* 轴线 + 标签 */}
|
||||
{axes.map((a, i) => {
|
||||
const edge = pt(i, r);
|
||||
const label = pt(i, r + 26);
|
||||
return (
|
||||
<g key={a.key}>
|
||||
<line x1={c} y1={c} x2={edge.x} y2={edge.y} stroke="#2a2f3a" />
|
||||
<text
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
fontSize="11"
|
||||
fill="#8b93a1"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{a.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* 数据多边形 */}
|
||||
{series.map((s, si) => {
|
||||
const color = COLORS[si % COLORS.length];
|
||||
const pts = s.points
|
||||
.map((p, i) => {
|
||||
const pp = pt(i, r * p.norm);
|
||||
return `${pp.x},${pp.y}`;
|
||||
})
|
||||
.join(' ');
|
||||
return (
|
||||
<polygon
|
||||
key={s.id}
|
||||
points={pts}
|
||||
fill={color}
|
||||
fillOpacity="0.15"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { COLORS as RADAR_COLORS };
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Revision } from '../types';
|
||||
import { labelOf } from '../lib/fieldLabels';
|
||||
import { Button } from './ui';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
pending: 'tag tag-amber',
|
||||
approved: 'tag tag-green',
|
||||
rejected: 'tag tag-gray',
|
||||
};
|
||||
|
||||
export function RevisionList({
|
||||
revisions,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
revisions: Revision[];
|
||||
onApprove?: (id: number) => void;
|
||||
onReject?: (id: number) => void;
|
||||
}) {
|
||||
if (revisions.length === 0)
|
||||
return <p className="muted">暂无修订记录。</p>;
|
||||
return (
|
||||
<ul className="rev-list">
|
||||
{revisions.map((r) => (
|
||||
<li key={r.id} className="rev-item">
|
||||
<div className="rev-head">
|
||||
<span className={STATUS_CLS[r.status]}>{STATUS_LABEL[r.status]}</span>
|
||||
<span className="rev-author">{r.author_name}</span>
|
||||
<span className="muted rev-date">
|
||||
{new Date(r.created_at + 'Z').toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
{r.note && <p className="rev-note">{r.note}</p>}
|
||||
<ul className="rev-changes">
|
||||
{r.changes.map((c) => (
|
||||
<li key={c.field}>
|
||||
<b>{labelOf(c.field)}</b>:
|
||||
<span className="old">{c.old_value || '—'}</span>
|
||||
{' → '}
|
||||
<span className="new">{c.new_value || '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{r.status === 'pending' && (onApprove || onReject) && (
|
||||
<div className="rev-actions">
|
||||
{onApprove && (
|
||||
<Button variant="primary" size="sm" onClick={() => onApprove(r.id)}>
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
{onReject && (
|
||||
<Button variant="danger" size="sm" onClick={() => onReject(r.id)}>
|
||||
驳回
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { SearchResult } from '../types';
|
||||
|
||||
export function SearchBox() {
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.search(q.trim(), 8);
|
||||
setResults(res.results);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="searchbox">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索型号 / 生产商 / 系列…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onFocus={() => results.length && setOpen(true)}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<ul className="search-dropdown">
|
||||
{results.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQ('');
|
||||
navigate(`/models/${r.id}`);
|
||||
}}
|
||||
>
|
||||
<strong>{r.model_code}</strong>
|
||||
<span className="muted"> · {r.category}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { gradientCss } from '../lib/thumb';
|
||||
import { CategoryIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 缩略图:分类配色渐变 + 专业图标。替代原 emoji/data-URI 方案。
|
||||
* role=img + aria-label 保证可访问性与可测试性。
|
||||
*/
|
||||
export function Thumb({
|
||||
category,
|
||||
code,
|
||||
size = 'md',
|
||||
}: {
|
||||
category: string;
|
||||
code: string;
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`thumb thumb-${size}`}
|
||||
style={{ background: gradientCss(category) }}
|
||||
role="img"
|
||||
aria-label={`${code} 缩略图`}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={category}
|
||||
size={size === 'sm' ? 30 : 46}
|
||||
className="thumb-glyph"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ModelListItem } from '../types';
|
||||
import { axisTicks, buildTimeline } from '../lib/timeline';
|
||||
|
||||
export function TimelineView({ models }: { models: ModelListItem[] }) {
|
||||
const navigate = useNavigate();
|
||||
const layout = buildTimeline(models);
|
||||
|
||||
if (layout.lanes.length === 0) {
|
||||
return <p className="muted">当前结果无可定位年代的数据。</p>;
|
||||
}
|
||||
const ticks = axisTicks(layout.minYear, layout.maxYear);
|
||||
const span = Math.max(1, layout.maxYear - layout.minYear);
|
||||
|
||||
return (
|
||||
<div className="timeline" data-testid="timeline">
|
||||
<div className="timeline-axis">
|
||||
{ticks.map((y) => (
|
||||
<span
|
||||
key={y}
|
||||
className="tick"
|
||||
style={{ left: `${((y - layout.minYear) / span) * 100}%` }}
|
||||
>
|
||||
{y}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{layout.lanes.map((lane) => (
|
||||
<div className="lane" key={lane.category}>
|
||||
<div className="lane-label">{lane.category}</div>
|
||||
<div
|
||||
className="lane-track"
|
||||
style={{ height: `${Math.max(34, lane.rows * 16 + 10)}px` }}
|
||||
>
|
||||
{lane.nodes.map((n) => (
|
||||
<button
|
||||
key={n.m.id}
|
||||
className="node"
|
||||
style={{
|
||||
left: `${n.xPct}%`,
|
||||
top: `${10 + n.row * 16}px`,
|
||||
}}
|
||||
title={`${n.m.model_code}(${n.year})`}
|
||||
onClick={() => navigate(`/models/${n.m.id}`)}
|
||||
>
|
||||
<span className="node-dot" />
|
||||
<span className="node-label">{n.m.model_code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{layout.undated.length > 0 && (
|
||||
<p className="muted">另有 {layout.undated.length} 个车型缺年代信息,未在轴上显示。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { IconType } from 'react-icons';
|
||||
import { FaTrain } from 'react-icons/fa6';
|
||||
import { GiSteamLocomotive, GiCoalWagon } from 'react-icons/gi';
|
||||
import {
|
||||
MdTrain,
|
||||
MdDirectionsRailway,
|
||||
MdEventSeat,
|
||||
MdTroubleshoot,
|
||||
MdTour,
|
||||
MdLock,
|
||||
MdStar,
|
||||
MdStarOutline,
|
||||
MdGridView,
|
||||
MdTimeline,
|
||||
MdFilterList,
|
||||
MdEdit,
|
||||
MdBalance,
|
||||
MdCheck,
|
||||
MdClose,
|
||||
MdDelete,
|
||||
MdHandyman,
|
||||
MdMyLocation,
|
||||
MdArrowBack,
|
||||
MdArrowForward,
|
||||
MdWarningAmber,
|
||||
MdArrowDropDown,
|
||||
MdForum,
|
||||
MdImage,
|
||||
MdEmojiEvents,
|
||||
MdPlace,
|
||||
MdChevronLeft,
|
||||
MdChevronRight,
|
||||
MdZoomIn,
|
||||
MdZoomOut,
|
||||
MdRefresh,
|
||||
} from 'react-icons/md';
|
||||
|
||||
/** 分类 → 专业图标(react-icons),统一替代 emoji。*/
|
||||
const CATEGORY_ICON: Record<string, IconType> = {
|
||||
蒸汽机车: GiSteamLocomotive,
|
||||
内燃机车: FaTrain,
|
||||
电力机车: MdDirectionsRailway,
|
||||
动车组: MdTrain,
|
||||
客车: MdEventSeat,
|
||||
货车: GiCoalWagon,
|
||||
检测车: MdTroubleshoot,
|
||||
旅游列车: MdTour,
|
||||
};
|
||||
|
||||
export function categoryIconType(category: string): IconType {
|
||||
return CATEGORY_ICON[category] ?? FaTrain;
|
||||
}
|
||||
|
||||
export function CategoryIcon({
|
||||
category,
|
||||
size = 20,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
category: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const Icon = categoryIconType(category);
|
||||
return <Icon size={size} className={className} title={title ?? category} />;
|
||||
}
|
||||
|
||||
// UI 图标(统一出口,便于替换/复用)
|
||||
export const IconBrand = MdTrain;
|
||||
export const IconLock = MdLock;
|
||||
export const IconStarFilled = MdStar;
|
||||
export const IconStarOutline = MdStarOutline;
|
||||
export const IconGallery = MdGridView;
|
||||
export const IconTimeline = MdTimeline;
|
||||
export const IconFilter = MdFilterList;
|
||||
export const IconEdit = MdEdit;
|
||||
export const IconCompare = MdBalance;
|
||||
export const IconConfirm = MdCheck;
|
||||
export const IconClose = MdClose;
|
||||
export const IconDelete = MdDelete;
|
||||
export const IconMaintain = MdHandyman;
|
||||
export const IconLocation = MdMyLocation;
|
||||
export const IconBack = MdArrowBack;
|
||||
export const IconForward = MdArrowForward;
|
||||
export const IconWarning = MdWarningAmber;
|
||||
export const IconCaret = MdArrowDropDown;
|
||||
export const IconForum = MdForum;
|
||||
export const IconImage = MdImage;
|
||||
export const IconTrophy = MdEmojiEvents;
|
||||
export const IconStation = MdPlace;
|
||||
export const IconPrev = MdChevronLeft;
|
||||
export const IconNext = MdChevronRight;
|
||||
export const IconZoomIn = MdZoomIn;
|
||||
export const IconZoomOut = MdZoomOut;
|
||||
export const IconReset = MdRefresh;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { IconStation } from './icons';
|
||||
|
||||
/** 统一按钮:variant = primary / secondary / ghost / danger,size = sm/md。*/
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
return <button className={`btn btn-${variant} btn-${size} ${className}`} {...rest} />;
|
||||
}
|
||||
|
||||
/** 统一页头:标题 + 副标题 + 右侧操作区。*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="page-sub">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一空状态。*/
|
||||
export function EmptyState({
|
||||
icon,
|
||||
text,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="empty-icon">{icon ?? <IconStation size={30} />}</span>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一标签。*/
|
||||
export function Tag({
|
||||
children,
|
||||
tone = 'default',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'green' | 'amber' | 'blue' | 'purple';
|
||||
}) {
|
||||
return <span className={`tag tag-${tone}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { api, clearToken, getToken, setToken } from '../api/client';
|
||||
import type { PublicUser } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
user: PublicUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, displayName: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthCtx = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
api.auth
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch(() => clearToken())
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await api.auth.login(email, password);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (email: string, password: string, displayName: string) => {
|
||||
const res = await api.auth.register(email, password, displayName);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthCtx);
|
||||
if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const ROLE_LABEL: Record<string, string> = {
|
||||
user: '注册用户',
|
||||
trusted: '信任用户',
|
||||
moderator: '版主',
|
||||
admin: '管理员',
|
||||
guest: '游客',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { COMPARE_AXES, normalizeForRadar } from './compare';
|
||||
import type { ModelDetail } from '../types';
|
||||
|
||||
const mk = (id: number, over: Record<string, unknown>): ModelDetail =>
|
||||
({ id, model_code: `M${id}`, ...over }) as unknown as ModelDetail;
|
||||
|
||||
describe('normalizeForRadar', () => {
|
||||
it('按每轴最大值归一', () => {
|
||||
const res = normalizeForRadar([
|
||||
mk(1, { max_speed_value: 200, weight_value: 100 }),
|
||||
mk(2, { max_speed_value: 400, weight_value: 50 }),
|
||||
]);
|
||||
// 第一轴 max_speed:200/400=0.5,400/400=1
|
||||
expect(res[0].points[0].norm).toBeCloseTo(0.5);
|
||||
expect(res[1].points[0].norm).toBe(1);
|
||||
// 第二轴 weight:100/100=1,50/100=0.5
|
||||
expect(res[0].points[1].norm).toBe(1);
|
||||
expect(res[1].points[1].norm).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('缺失值 norm=0 且 raw=null', () => {
|
||||
const res = normalizeForRadar([mk(1, { max_speed_value: 300 })]);
|
||||
const weightIdx = COMPARE_AXES.findIndex((a) => a.key === 'weight_value');
|
||||
expect(res[0].points[weightIdx].raw).toBeNull();
|
||||
expect(res[0].points[weightIdx].norm).toBe(0);
|
||||
});
|
||||
|
||||
it('某轴全缺失不除零', () => {
|
||||
const res = normalizeForRadar([mk(1, {}), mk(2, {})]);
|
||||
expect(res[0].points.every((p) => p.norm === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ModelDetail } from '../types';
|
||||
|
||||
export interface CompareAxis {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/** 参与雷达对比的数值轴(取 model 的 *_value 字段)。*/
|
||||
export const COMPARE_AXES: CompareAxis[] = [
|
||||
{ key: 'max_speed_value', label: '最高时速', unit: 'km/h' },
|
||||
{ key: 'weight_value', label: '整备重量', unit: 't' },
|
||||
{ key: 'axle_load_value', label: '轴重', unit: 't' },
|
||||
{ key: 'tractive_start_value', label: '起动牵引力', unit: 'kN' },
|
||||
{ key: 'tractive_cont_value', label: '持续牵引力', unit: 'kN' },
|
||||
];
|
||||
|
||||
export interface NormalizedPoint {
|
||||
raw: number | null;
|
||||
norm: number; // 0..1(按所选车型每轴最大值归一;缺失=0)
|
||||
}
|
||||
|
||||
export interface NormalizedSeries {
|
||||
id: number;
|
||||
label: string;
|
||||
points: NormalizedPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所选车型在各轴上归一化(每轴除以该轴最大值)。纯函数,便于单测。
|
||||
*/
|
||||
export function normalizeForRadar(
|
||||
models: ModelDetail[],
|
||||
axes: CompareAxis[] = COMPARE_AXES,
|
||||
): NormalizedSeries[] {
|
||||
const max = axes.map((a) =>
|
||||
Math.max(
|
||||
0,
|
||||
...models.map((m) => {
|
||||
const v = (m as any)[a.key];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}),
|
||||
),
|
||||
);
|
||||
return models.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.model_code,
|
||||
points: axes.map((a, i) => {
|
||||
const v = (m as any)[a.key];
|
||||
const raw = typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
const norm = raw != null && max[i] > 0 ? raw / max[i] : 0;
|
||||
return { raw, norm };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeSpeedEvolution, ERAS, pickRepresentatives } from './eras';
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
const mk = (over: Partial<ModelListItem>): ModelListItem =>
|
||||
({
|
||||
id: Math.random(),
|
||||
model_code: 'X',
|
||||
full_name: '',
|
||||
series: '',
|
||||
manufacturer: '',
|
||||
country: '中国',
|
||||
country_type: '国产',
|
||||
first_year: null,
|
||||
last_year: null,
|
||||
status: '未知',
|
||||
usage: '',
|
||||
max_speed_value: null,
|
||||
max_speed_unit: '',
|
||||
weight_value: null,
|
||||
weight_unit: '',
|
||||
axle_arrangement: '',
|
||||
category: '电力机车',
|
||||
subcat: '',
|
||||
...over,
|
||||
}) as ModelListItem;
|
||||
|
||||
describe('ERAS', () => {
|
||||
it('四个时代覆盖主线分类', () => {
|
||||
expect(ERAS.map((e) => e.key)).toEqual(['steam', 'diesel', 'electric', 'hsr']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickRepresentatives', () => {
|
||||
const data = [
|
||||
mk({ id: 1, category: '动车组', model_code: 'A', max_speed_value: 350 }),
|
||||
mk({ id: 2, category: '动车组', model_code: 'B', max_speed_value: 400 }),
|
||||
mk({ id: 3, category: '电力机车', model_code: 'C', max_speed_value: 120 }),
|
||||
];
|
||||
const hsr = ERAS.find((e) => e.key === 'hsr')!;
|
||||
|
||||
it('只取对应分类并按时速降序', () => {
|
||||
const reps = pickRepresentatives(data, hsr, 4);
|
||||
expect(reps.map((m) => m.model_code)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('限制数量', () => {
|
||||
expect(pickRepresentatives(data, hsr, 1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSpeedEvolution', () => {
|
||||
it('按十年取最高时速并升序', () => {
|
||||
const pts = computeSpeedEvolution([
|
||||
mk({ first_year: 1958, max_speed_value: 100 }),
|
||||
mk({ first_year: 1965, max_speed_value: 120 }),
|
||||
mk({ first_year: 2017, max_speed_value: 350 }),
|
||||
]);
|
||||
expect(pts).toEqual([
|
||||
{ decade: 1950, maxSpeed: 100 },
|
||||
{ decade: 1960, maxSpeed: 120 },
|
||||
{ decade: 2010, maxSpeed: 350 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('忽略缺年代/缺时速', () => {
|
||||
const pts = computeSpeedEvolution([
|
||||
mk({ first_year: null, max_speed_value: 999 }),
|
||||
mk({ first_year: 2000, max_speed_value: null }),
|
||||
]);
|
||||
expect(pts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
/** 主线:中国机车技术演进的四个时代。*/
|
||||
export interface Era {
|
||||
key: string;
|
||||
title: string;
|
||||
period: string;
|
||||
categories: string[];
|
||||
blurb: string;
|
||||
accent: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const ERAS: Era[] = [
|
||||
{
|
||||
key: 'steam',
|
||||
title: '蒸汽时代',
|
||||
period: '1881 – 1990s',
|
||||
categories: ['蒸汽机车'],
|
||||
blurb: '钢铁与煤火的咆哮。从进口机车到自主制造的前进型、建设型,蒸汽机车牵引了中国铁路的第一个世纪。',
|
||||
accent: '#a8743f',
|
||||
icon: '🚂',
|
||||
},
|
||||
{
|
||||
key: 'diesel',
|
||||
title: '内燃时代',
|
||||
period: '1958 – 至今',
|
||||
categories: ['内燃机车'],
|
||||
blurb: '东风奔驰。内燃机车以更高效率取代蒸汽,从仿制到东风系列的自主谱系,撑起干线与调车的主力。',
|
||||
accent: '#c8862f',
|
||||
icon: '🛤️',
|
||||
},
|
||||
{
|
||||
key: 'electric',
|
||||
title: '电力时代',
|
||||
period: '1958 – 至今',
|
||||
categories: ['电力机车'],
|
||||
blurb: '从引进法国 8K 到韶山系列国产化,再到和谐号 HXD,电气化让重载与提速成为可能。',
|
||||
accent: '#3a86c8',
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
key: 'hsr',
|
||||
title: '高铁时代',
|
||||
period: '2007 – 至今',
|
||||
categories: ['动车组'],
|
||||
blurb: '和谐号到复兴号,从引进消化到全面自主,CR400 与 CR450 把中国带入全球最快的运营速度。',
|
||||
accent: '#2bb39a',
|
||||
icon: '🚄',
|
||||
},
|
||||
];
|
||||
|
||||
/** 从数据集中挑选某时代的代表车型:优先有真实封面图者,其次按最高时速降序。*/
|
||||
export function pickRepresentatives(
|
||||
models: ModelListItem[],
|
||||
era: Era,
|
||||
n = 4,
|
||||
): ModelListItem[] {
|
||||
const hasCover = (m: ModelListItem) => (m.cover_url ? 1 : 0);
|
||||
return models
|
||||
.filter((m) => era.categories.includes(m.category))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
hasCover(b) - hasCover(a) ||
|
||||
(b.max_speed_value ?? -1) - (a.max_speed_value ?? -1),
|
||||
)
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
export interface EvolutionPoint {
|
||||
decade: number;
|
||||
maxSpeed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算"时速演进":按十年取该年代出现车型的最高时速。
|
||||
* 用于首页主线的科学感曲线。纯函数,便于单测。
|
||||
*/
|
||||
export function computeSpeedEvolution(models: ModelListItem[]): EvolutionPoint[] {
|
||||
const byDecade = new Map<number, number>();
|
||||
for (const m of models) {
|
||||
if (typeof m.first_year !== 'number' || m.max_speed_value == null) continue;
|
||||
const decade = Math.floor(m.first_year / 10) * 10;
|
||||
byDecade.set(decade, Math.max(byDecade.get(decade) ?? 0, m.max_speed_value));
|
||||
}
|
||||
return [...byDecade.entries()]
|
||||
.map(([decade, maxSpeed]) => ({ decade, maxSpeed }))
|
||||
.sort((a, b) => a.decade - b.decade);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user