init: AI培训与智能巡检系统

This commit is contained in:
selfrelease
2026-06-16 00:55:20 +08:00
commit c55598494b
201 changed files with 53131 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
*.log
test-results/
playwright-report/
+29
View File
@@ -0,0 +1,29 @@
# 中国机车图鉴 · 前端 (apps/web)
Vite + React + TypeScript,消费 `apps/api`NestJS)的接口。对应 Phase 1CT-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 打卡功能接入。
+16
View File
@@ -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();
});
+374
View File
@@ -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);
});
+12
View File
@@ -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>
+3548
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -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"
}
}
+34
View File
@@ -0,0 +1,34 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Playwright e2e 配置 — 对应 T-1.5/1.6/1.7/1.8 E2E。
* 自动拉起后端 APIapps/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,
},
],
});
+88
View File
@@ -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>
);
}
+22
View File
@@ -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');
});
});
+226
View File
@@ -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}`),
};
+115
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}
+94
View File
@@ -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"> AZ</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);
});
});
+52
View File
@@ -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>
);
}
+63
View File
@@ -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>
);
}
+126
View File
@@ -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();
});
});
+38
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}
+91
View File
@@ -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 };
+67
View File
@@ -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>
);
}
+59
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+96
View File
@@ -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;
+63
View File
@@ -0,0 +1,63 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { IconStation } from './icons';
/** 统一按钮:variant = primary / secondary / ghost / dangersize = 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>;
}
+77
View File
@@ -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: '游客',
};
+33
View File
@@ -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_speed200/400=0.5400/400=1
expect(res[0].points[0].norm).toBeCloseTo(0.5);
expect(res[1].points[0].norm).toBe(1);
// 第二轴 weight100/100=150/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);
});
});
+55
View File
@@ -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 };
}),
}));
}
+73
View File
@@ -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([]);
});
});
+89
View File
@@ -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);
}
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { labelOf } from './fieldLabels';
describe('labelOf', () => {
it('英文规范键映射为中文', () => {
expect(labelOf('model_code')).toBe('型号');
expect(labelOf('max_speed')).toBe('最高时速');
expect(labelOf('country_type')).toBe('国别属性');
expect(labelOf('tractive_start')).toBe('起动牵引力');
});
it('未知键原样返回', () => {
expect(labelOf('型号')).toBe('型号');
expect(labelOf('unknown_key')).toBe('unknown_key');
});
});
+49
View File
@@ -0,0 +1,49 @@
/** 规范字段(英文)→ 中文标签。用于详情页"原始数据"等处把英文键映射为中文。*/
export const FIELD_LABELS: Record<string, string> = {
model_code: '型号',
series: '系列',
full_name: '车型全称',
aliases: '别名',
manufacturer: '生产商',
country: '制造国/地区',
country_type: '国别属性',
first_year: '首产年',
last_year: '停产年',
status: '状态',
usage: '用途',
production_count: '产量',
axle_arrangement: '轴列式',
drive: '传动/供电方式',
efficiency: '传动效率',
length: '车长',
width: '车宽',
height: '车高',
wheelbase: '轴距',
weight: '整备重量',
axle_load: '轴重',
load: '载重',
tractive_start: '起动牵引力',
tractive_cont: '持续牵引力',
power_kw: '功率',
max_speed: '最高时速',
capacity: '容积',
car_number: '车号',
function: '功能',
depot: '配属',
livery: '涂装',
side_mark: '侧标',
note: '备注',
location: '存放位置',
formation: '编组(动力车/拖车)',
predecessor: '前身',
lifespan: '使用寿命',
tour_name: '旅游列车名称',
tractor_models: '牵引机车常用型号',
bogie: '转向架型号',
coupler: '车钩类型',
};
/** 把字段键映射为中文;已是中文或未知则原样返回。*/
export function labelOf(key: string): string {
return FIELD_LABELS[key] ?? key;
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { fmtEra, fmtValueUnit, statusClass } from './format';
describe('fmtValueUnit', () => {
it('数值+单位', () => expect(fmtValueUnit(120, 'km/h')).toBe('120km/h'));
it('缺失留白', () => {
expect(fmtValueUnit(null, 'km/h')).toBe('—');
expect(fmtValueUnit(undefined, 't')).toBe('—');
});
});
describe('fmtEra', () => {
it('区间', () => expect(fmtEra(2006, 2014)).toBe('20062014'));
it('同年', () => expect(fmtEra(2006, 2006)).toBe('2006'));
it('仅首年', () => expect(fmtEra(2006, null)).toBe('2006'));
it('全缺', () => expect(fmtEra(null, null)).toBe('—'));
});
describe('statusClass', () => {
it('现役为绿', () => expect(statusClass('现役')).toContain('tag-green'));
it('封存为琥珀', () => expect(statusClass('半封存')).toContain('tag-amber'));
it('未知为默认', () => expect(statusClass('未知')).toBe('tag'));
});
+43
View File
@@ -0,0 +1,43 @@
import type { ModelListItem } from '../types';
/** 数值+单位展示,缺失优雅留白。*/
export function fmtValueUnit(
value: number | null | undefined,
unit: string | null | undefined,
): string {
if (value === null || value === undefined) return '—';
return `${value}${unit ?? ''}`;
}
/** 年代区间展示:20062014 / 2006 / —。*/
export function fmtEra(first?: number | null, last?: number | null): string {
if (!first && !last) return '—';
if (first && last) return first === last ? `${first}` : `${first}${last}`;
if (first) return `${first}`;
return `${last}`;
}
/** 状态对应的色彩标签 class。*/
export function statusClass(status: string): string {
switch (status) {
case '现役':
return 'tag tag-green';
case '封存':
case '半封存':
return 'tag tag-amber';
case '退役':
case '报废':
return 'tag tag-gray';
case '试验':
return 'tag tag-blue';
case '保存':
return 'tag tag-purple';
default:
return 'tag';
}
}
/** 用于时间轴:返回用于定位的年份(缺失返回 null)。*/
export function timelineYear(m: ModelListItem): number | null {
return m.first_year ?? null;
}
+201
View File
@@ -0,0 +1,201 @@
import type { StoredImage } from './useImages';
import { themeFor } from './thumb';
/**
* 生成内置"示意图"(蓝图风格 SVG),让每个车型详情都有多张可缩放欣赏的图片,
* 便于测试多图 + 灯箱。明确标注"示意图 · 非真实照片"。
* Phase 2/3 众包真实照片上线后,可只展示用户上传图。
*/
export interface SampleModel {
model_code: string;
category: string;
first_year?: number | null;
last_year?: number | null;
manufacturer?: string;
country?: string;
max_speed_value?: number | null;
max_speed_unit?: string;
}
type LocoType =
| 'steam'
| 'electric'
| 'emu'
| 'diesel'
| 'freight'
| 'passenger'
| 'inspection'
| 'tourist'
| 'generic';
function typeFor(category: string): LocoType {
return (
(
{
: 'steam',
: 'electric',
: 'emu',
: 'diesel',
: 'freight',
: 'passenger',
: 'inspection',
: 'tourist',
} as Record<string, LocoType>
)[category] ?? 'generic'
);
}
const esc = (s: string) =>
String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const W = 1200;
const H = 800;
function frame(accent: string, viewLabel: string, m: SampleModel, inner: string): string {
const grid: string[] = [];
for (let gx = 0; gx <= W; gx += 40)
grid.push(`<line x1="${gx}" y1="0" x2="${gx}" y2="${H}"/>`);
for (let gy = 0; gy <= H; gy += 40)
grid.push(`<line x1="0" y1="${gy}" x2="${W}" y2="${gy}"/>`);
return (
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" font-family="sans-serif">` +
`<rect width="${W}" height="${H}" fill="#0c1622"/>` +
`<g stroke="${accent}" stroke-opacity="0.10" stroke-width="1">${grid.join('')}</g>` +
`<rect x="20" y="20" width="${W - 40}" height="${H - 40}" fill="none" stroke="${accent}" stroke-opacity="0.5" stroke-width="2"/>` +
// 标题块
`<text x="48" y="78" font-size="44" font-weight="700" fill="#ffffff">${esc(m.model_code)}</text>` +
`<text x="48" y="110" font-size="20" fill="${accent}">${esc(m.category)} · ${esc(viewLabel)}</text>` +
// 右上角铭牌信息
`<text x="${W - 48}" y="60" font-size="16" fill="#aebacb" text-anchor="end">中国机车图鉴 · 示意图(非真实照片)</text>` +
inner +
// 页脚细节(缩放可读)
`<text x="48" y="${H - 36}" font-size="13" fill="#7e8ba0">制造商 ${esc(m.manufacturer || '—')} 制造国 ${esc(m.country || '—')} 年代 ${m.first_year ?? '—'}${m.last_year ? '' + m.last_year : ''}</text>` +
`<text x="48" y="${H - 16}" font-size="11" fill="#5d6878">① 车体 ② 转向架 ③ 走行部 ④ 牵引/受电 — 缩放查看标注细节</text>` +
`</svg>`
);
}
function dataUri(svg: string): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}
function wheels(cx0: number, count: number, accent: string): string {
const out: string[] = [];
for (let i = 0; i < count; i++) {
const cx = cx0 + i * 120;
out.push(
`<circle cx="${cx}" cy="600" r="34" fill="#0c1622" stroke="${accent}" stroke-width="6"/>` +
`<circle cx="${cx}" cy="600" r="10" fill="${accent}"/>`,
);
}
return out.join('');
}
function windows(x: number, y: number, n: number, accent: string): string {
const out: string[] = [];
for (let i = 0; i < n; i++)
out.push(
`<rect x="${x + i * 95}" y="${y}" width="64" height="60" rx="6" fill="#8fd0ff" fill-opacity="0.18" stroke="${accent}" stroke-width="2"/>`,
);
return out.join('');
}
function sideView(t: LocoType, accent: string, m: SampleModel): string {
const bodyTop = 430;
let body = '';
if (t === 'emu') {
body =
`<path d="M180 600 Q180 470 320 450 L1000 450 Q1040 450 1040 500 L1040 600 Z" fill="${accent}" fill-opacity="0.30" stroke="${accent}" stroke-width="4"/>` +
windows(360, 480, 6, accent);
} else if (t === 'freight') {
body =
`<path d="M220 600 L220 470 L1020 470 L1020 600 Z" fill="${accent}" fill-opacity="0.22" stroke="${accent}" stroke-width="4"/>` +
`<line x1="220" y1="510" x2="1020" y2="510" stroke="${accent}" stroke-width="2" stroke-dasharray="6 6"/>`;
} else {
body =
`<rect x="220" y="${bodyTop}" width="800" height="170" rx="18" fill="${accent}" fill-opacity="0.28" stroke="${accent}" stroke-width="4"/>` +
windows(t === 'passenger' ? 280 : 300, bodyTop + 30, t === 'passenger' ? 7 : 4, accent);
}
let extra = '';
if (t === 'steam')
extra =
`<circle cx="300" cy="520" r="60" fill="#0c1622" stroke="${accent}" stroke-width="5"/>` + // boiler front
`<rect x="360" y="360" width="40" height="70" fill="${accent}" fill-opacity="0.5"/>` + // chimney
`<path d="M380 360 q-10 -28 18 -40 q-12 -22 16 -34" fill="none" stroke="${accent}" stroke-width="3" stroke-opacity="0.6"/>`; // smoke
if (t === 'electric' || t === 'emu')
extra =
`<path d="M520 ${t === 'emu' ? 450 : bodyTop} l60 -70 l60 70" fill="none" stroke="${accent}" stroke-width="4"/>` + // pantograph
`<line x1="200" y1="360" x2="1040" y2="360" stroke="${accent}" stroke-width="2" stroke-opacity="0.5"/>`; // catenary
if (t === 'diesel')
extra = `<rect x="360" y="402" width="120" height="28" rx="6" fill="${accent}" fill-opacity="0.5"/>`; // roof vent
// 走行部 + 尺寸标注
const wh = wheels(330, t === 'emu' ? 6 : 4, accent);
const dims =
`<line x1="220" y1="690" x2="1020" y2="690" stroke="#7e8ba0" stroke-width="1.5"/>` +
`<line x1="220" y1="680" x2="220" y2="700" stroke="#7e8ba0"/>` +
`<line x1="1020" y1="680" x2="1020" y2="700" stroke="#7e8ba0"/>` +
`<text x="620" y="712" font-size="14" fill="#7e8ba0" text-anchor="middle">车体长度(示意)</text>` +
`<text x="1030" y="455" font-size="14" fill="${accent}">最高 ${m.max_speed_value ?? '—'}${m.max_speed_unit || 'km/h'}</text>`;
return `<g stroke-linejoin="round">${body}${extra}${wh}${dims}</g>`;
}
function frontView(accent: string): string {
return (
`<g stroke-linejoin="round">` +
`<path d="M480 620 Q480 360 600 320 Q720 360 720 620 Z" fill="${accent}" fill-opacity="0.28" stroke="${accent}" stroke-width="4"/>` +
`<path d="M520 470 Q600 440 680 470 L680 540 L520 540 Z" fill="#8fd0ff" fill-opacity="0.2" stroke="${accent}" stroke-width="3"/>` + // windshield
`<circle cx="540" cy="585" r="14" fill="#ffe08a"/><circle cx="660" cy="585" r="14" fill="#ffe08a"/>` + // headlights
`<rect x="560" y="624" width="80" height="16" rx="4" fill="${accent}"/>` + // coupler
`<text x="600" y="690" font-size="14" fill="#7e8ba0" text-anchor="middle">正视图(示意)</text>` +
`</g>`
);
}
function plateView(accent: string, m: SampleModel): string {
const rivets: string[] = [];
for (let i = 0; i < 8; i++) {
rivets.push(`<circle cx="${300 + i * 86}" cy="300" r="6" fill="${accent}"/>`);
rivets.push(`<circle cx="${300 + i * 86}" cy="560" r="6" fill="${accent}"/>`);
}
return (
`<g>` +
`<rect x="260" y="270" width="680" height="320" rx="14" fill="${accent}" fill-opacity="0.16" stroke="${accent}" stroke-width="4"/>` +
rivets.join('') +
`<text x="600" y="380" font-size="56" font-weight="800" fill="#ffffff" text-anchor="middle" letter-spacing="3">${esc(m.model_code)}</text>` +
`<text x="600" y="430" font-size="22" fill="#cdd5e0" text-anchor="middle">${esc(m.manufacturer || '制造厂未知')}</text>` +
`<text x="600" y="480" font-size="18" fill="#aebacb" text-anchor="middle">${esc(m.category)} ${m.first_year ?? '—'}${m.last_year ? '' + m.last_year : ''}</text>` +
`<text x="600" y="520" font-size="16" fill="${accent}" text-anchor="middle">铭牌(示意)</text>` +
`</g>`
);
}
/** 为给定车型生成 3 张示意图(侧视 / 正视 / 铭牌)。*/
export function sampleImagesFor(m: SampleModel): StoredImage[] {
const accent = themeFor(m.category).c1;
const t = typeFor(m.category);
const code = m.model_code.replace(/[^\w\u4e00-\u9fa5]/g, '');
return [
{
id: `sample-${code}-side`,
caption: `${m.model_code} 侧视示意图`,
dataUrl: dataUri(frame(accent, '侧视图', m, sideView(t, accent, m))),
},
{
id: `sample-${code}-front`,
caption: `${m.model_code} 正视示意图`,
dataUrl: dataUri(frame(accent, '正视图', m, frontView(accent))),
},
{
id: `sample-${code}-plate`,
caption: `${m.model_code} 铭牌示意`,
dataUrl: dataUri(frame(accent, '铭牌', m, plateView(accent, m))),
},
];
}
export function isSampleImage(id: string): boolean {
return id.startsWith('sample-');
}
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { gradientCss, themeFor } from './thumb';
describe('themeFor', () => {
it('已知分类返回专属配色', () => {
expect(themeFor('电力机车').c1).toBe('#2e5d8f');
expect(themeFor('蒸汽机车').c1).toBe('#5b4636');
});
it('未知分类回退', () => {
expect(themeFor('外星机车')).toEqual({ c1: '#3a4250', c2: '#1c212a' });
});
});
describe('gradientCss', () => {
it('生成线性渐变并含两端色', () => {
const g = gradientCss('动车组');
expect(g.startsWith('linear-gradient(')).toBe(true);
expect(g).toContain('#1f7a6b');
expect(g).toContain('#0d3a33');
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* 按分类提供缩略图配色(渐变两端)。图标由 <Thumb> 用专业图标库渲染。
* 真实车照待 Phase 2/3 众包上传后替换。
*/
export interface Theme {
c1: string;
c2: string;
}
const CATEGORY_THEME: Record<string, Theme> = {
: { c1: '#5b4636', c2: '#2b211a' },
: { c1: '#2e5d8f', c2: '#143049' },
: { c1: '#9c5a23', c2: '#4d2c10' },
: { c1: '#1f7a6b', c2: '#0d3a33' },
: { c1: '#4a5a8f', c2: '#222c49' },
: { c1: '#7a6a1f', c2: '#3a330d' },
: { c1: '#6b3f7a', c2: '#341d3a' },
: { c1: '#2f8f5d', c2: '#13492d' },
};
const FALLBACK: Theme = { c1: '#3a4250', c2: '#1c212a' };
export function themeFor(category: string): Theme {
return CATEGORY_THEME[category] ?? FALLBACK;
}
/** 生成缩略图背景的 CSS 渐变。*/
export function gradientCss(category: string): string {
const { c1, c2 } = themeFor(category);
return `linear-gradient(135deg, ${c1}, ${c2})`;
}
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { axisTicks, buildTimeline } from './timeline';
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('buildTimeline', () => {
it('空输入返回空泳道', () => {
const t = buildTimeline([]);
expect(t.lanes).toEqual([]);
expect(t.undated).toEqual([]);
});
it('按分类分泳道并计算位置', () => {
const t = buildTimeline([
mk({ id: 1, category: '电力机车', first_year: 2000 }),
mk({ id: 2, category: '电力机车', first_year: 2010 }),
mk({ id: 3, category: '内燃机车', first_year: 2005 }),
]);
expect(t.minYear).toBe(2000);
expect(t.maxYear).toBe(2010);
expect(t.lanes.length).toBe(2);
const elec = t.lanes.find((l) => l.category === '电力机车')!;
expect(elec.nodes[0].xPct).toBe(0);
expect(elec.nodes[1].xPct).toBe(100);
});
it('节点按年份升序', () => {
const t = buildTimeline([
mk({ id: 1, category: 'A', first_year: 2010 }),
mk({ id: 2, category: 'A', first_year: 1990 }),
]);
expect(t.lanes[0].nodes.map((n) => n.year)).toEqual([1990, 2010]);
});
it('同年同泳道节点错位分行(避免重叠不可点)', () => {
const t = buildTimeline([
mk({ id: 1, category: 'A', first_year: 1958 }),
mk({ id: 2, category: 'A', first_year: 1958 }),
mk({ id: 3, category: 'A', first_year: 1958 }),
]);
const rows = t.lanes[0].nodes.map((n) => n.row);
expect(new Set(rows).size).toBe(3); // 三个不同的行
expect(t.lanes[0].rows).toBe(3);
});
it('相距较远的同泳道节点复用第 0 行', () => {
const t = buildTimeline([
mk({ id: 1, category: 'A', first_year: 1960 }),
mk({ id: 2, category: 'A', first_year: 2020 }),
]);
expect(t.lanes[0].nodes.every((n) => n.row === 0)).toBe(true);
expect(t.lanes[0].rows).toBe(1);
});
it('无年代车型进入 undated', () => {
const t = buildTimeline([
mk({ id: 1, category: 'A', first_year: 2000 }),
mk({ id: 2, category: 'A', first_year: null }),
]);
expect(t.undated.length).toBe(1);
});
it('单一年份不除零', () => {
const t = buildTimeline([mk({ first_year: 2000 }), mk({ first_year: 2000 })]);
expect(t.lanes[0].nodes.every((n) => Number.isFinite(n.xPct))).toBe(true);
});
});
describe('axisTicks', () => {
it('按十年生成刻度并含末年', () => {
const ticks = axisTicks(1995, 2018);
expect(ticks[0]).toBe(1995 >= 1990 ? 2000 : 1990);
expect(ticks).toContain(2018);
});
it('单年返回自身', () => {
expect(axisTicks(2000, 2000)).toEqual([2000]);
});
});
+93
View File
@@ -0,0 +1,93 @@
import type { ModelListItem } from '../types';
export interface TimelineNode {
m: ModelListItem;
year: number;
xPct: number; // 0..100 横轴位置
row: number; // 同位置碰撞时的堆叠行,避免节点重叠不可点击
}
export interface TimelineLane {
category: string;
nodes: TimelineNode[];
rows: number; // 该泳道占用的堆叠行数
}
export interface TimelineLayout {
minYear: number;
maxYear: number;
lanes: TimelineLane[];
/** 无年代信息、无法定位的车型 */
undated: ModelListItem[];
}
/**
* 计算时间轴布局:按分类分泳道,按 first_year 映射到 0..100 的横轴位置。
* 纯函数,便于单元测试(T-1.6 UT)。
*/
export function buildTimeline(models: ModelListItem[]): TimelineLayout {
const dated = models.filter((m) => typeof m.first_year === 'number');
const undated = models.filter((m) => typeof m.first_year !== 'number');
if (dated.length === 0) {
return { minYear: 0, maxYear: 0, lanes: [], undated };
}
const years = dated.map((m) => m.first_year as number);
const minYear = Math.min(...years);
const maxYear = Math.max(...years);
const span = Math.max(1, maxYear - minYear);
const laneMap = new Map<string, TimelineNode[]>();
for (const m of dated) {
const year = m.first_year as number;
const xPct = ((year - minYear) / span) * 100;
const lane = laneMap.get(m.category) ?? [];
lane.push({ m, year, xPct, row: 0 });
laneMap.set(m.category, lane);
}
// 碰撞分行:同泳道内 x 距离过近的节点错位堆叠,保证各自可点击。
const COLLISION_PCT = 2.2;
const assignRows = (nodes: TimelineNode[]): number => {
const lastXByRow: number[] = [];
for (const n of nodes) {
let placed = false;
for (let r = 0; r < lastXByRow.length; r++) {
if (n.xPct - lastXByRow[r] >= COLLISION_PCT) {
n.row = r;
lastXByRow[r] = n.xPct;
placed = true;
break;
}
}
if (!placed) {
n.row = lastXByRow.length;
lastXByRow.push(n.xPct);
}
}
return Math.max(1, lastXByRow.length);
};
const lanes: TimelineLane[] = Array.from(laneMap.entries())
.map(([category, nodes]) => {
const sorted = nodes.sort((a, b) => a.year - b.year);
const rows = assignRows(sorted);
return { category, nodes: sorted, rows };
})
.sort((a, b) => a.category.localeCompare(b.category, 'zh'));
return { minYear, maxYear, lanes, undated };
}
/** 生成坐标轴刻度(按十年取整)。*/
export function axisTicks(minYear: number, maxYear: number): number[] {
if (maxYear <= minYear) return [minYear];
const start = Math.floor(minYear / 10) * 10;
const ticks: number[] = [];
for (let y = start; y <= maxYear; y += 10) {
if (y >= minYear) ticks.push(y);
}
if (ticks[ticks.length - 1] !== maxYear) ticks.push(maxYear);
return ticks;
}
+43
View File
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from 'react';
/**
* 轻量"收集"功能(MVP):用 localStorage 记录已收集车型 id。
* Phase 3 账户/打卡上线后迁移到云端。
*/
const KEY = 'train.collected.v1';
function load(): Set<number> {
try {
const raw = localStorage.getItem(KEY);
if (!raw) return new Set();
const arr = JSON.parse(raw) as number[];
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
export function useCollection() {
const [ids, setIds] = useState<Set<number>>(() => load());
useEffect(() => {
try {
localStorage.setItem(KEY, JSON.stringify([...ids]));
} catch {
/* 忽略写入失败(隐私模式等) */
}
}, [ids]);
const toggle = useCallback((id: number) => {
setIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const clear = useCallback(() => setIds(new Set()), []);
return { collectedIds: ids, toggle, clear, count: ids.size };
}
+35
View File
@@ -0,0 +1,35 @@
import { useCallback, useEffect, useState } from 'react';
/** 关注车型(localStorage)。用于"稀有车提醒"的轻量版:在站内汇总关注车型的最新目击。*/
const KEY = 'train.follows.v1';
function load(): Set<number> {
try {
const raw = localStorage.getItem(KEY);
return new Set(raw ? (JSON.parse(raw) as number[]) : []);
} catch {
return new Set();
}
}
export function useFollow() {
const [ids, setIds] = useState<Set<number>>(() => load());
useEffect(() => {
try {
localStorage.setItem(KEY, JSON.stringify([...ids]));
} catch {
/* ignore */
}
}, [ids]);
const toggle = useCallback((id: number) => {
setIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
return { followIds: ids, toggle, isFollowing: (id: number) => ids.has(id) };
}
+83
View File
@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from 'react';
/**
* 每个车型的本地图册(localStorage)。MVP 让爱好者本地添加多张图片并欣赏;
* Phase 2/3 众包上传上线后迁移到云端对象存储。
*/
export interface StoredImage {
id: string;
dataUrl: string;
caption: string;
}
const KEY = 'train.images.v1';
type Store = Record<string, StoredImage[]>;
function loadStore(): Store {
try {
return JSON.parse(localStorage.getItem(KEY) || '{}') as Store;
} catch {
return {};
}
}
function saveStore(store: Store) {
try {
localStorage.setItem(KEY, JSON.stringify(store));
} catch {
/* 配额超限等,忽略 */
}
}
function readFileAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(String(fr.result));
fr.onerror = reject;
fr.readAsDataURL(file);
});
}
export function useImages(modelId: number | undefined) {
const [images, setImages] = useState<StoredImage[]>([]);
useEffect(() => {
if (modelId == null) return;
setImages(loadStore()[String(modelId)] ?? []);
}, [modelId]);
const persist = useCallback(
(next: StoredImage[]) => {
setImages(next);
if (modelId == null) return;
const store = loadStore();
store[String(modelId)] = next;
saveStore(store);
},
[modelId],
);
const addFiles = useCallback(
async (files: FileList | File[]) => {
const arr = Array.from(files).filter((f) => f.type.startsWith('image/'));
const added: StoredImage[] = [];
for (const f of arr) {
added.push({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
dataUrl: await readFileAsDataUrl(f),
caption: f.name.replace(/\.[^.]+$/, ''),
});
}
persist([...images, ...added]);
},
[images, persist],
);
const remove = useCallback(
(id: string) => persist(images.filter((im) => im.id !== id)),
[images, persist],
);
return { images, addFiles, remove };
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import { AuthProvider } from './lib/auth';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>,
);
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../lib/auth';
import { Button, PageHeader, EmptyState } from '../components/ui';
import { IconImage, IconBack } from '../components/icons';
import type { Photo } from '../types';
type Candidate = Photo & { modelCode: string; category: string };
export function AdminPhotosPage() {
const { user } = useAuth();
const [items, setItems] = useState<Candidate[]>([]);
const [busy, setBusy] = useState(false);
const isAdmin = user?.role === 'admin';
const load = () => api.candidatePhotos().then(setItems).catch(() => {});
useEffect(() => {
if (isAdmin) load();
}, [isAdmin]);
if (!user) {
return (
<div className="page">
<p className="muted"></p>
<Link to="/" className="back"><IconBack size={14} /> </Link>
</div>
);
}
if (!isAdmin) {
return (
<div className="page">
<p className="muted">访</p>
<Link to="/" className="back"><IconBack size={14} /> </Link>
</div>
);
}
const confirm = async (pid: number) => {
await api.confirmPhoto(pid).catch(() => {});
setItems((xs) => xs.filter((x) => x.id !== pid));
};
const remove = async (pid: number) => {
await api.deletePhoto(pid).catch(() => {});
setItems((xs) => xs.filter((x) => x.id !== pid));
};
const confirmAll = async () => {
setBusy(true);
for (const it of items) await api.confirmPhoto(it.id).catch(() => {});
setBusy(false);
load();
};
return (
<div className="page">
<PageHeader
title="候选审图"
subtitle="批量取图脚本入库的候选照片,确认后即成为图鉴封面与公共图库内容。"
actions={
items.length > 0 ? (
<Button variant="primary" onClick={confirmAll} disabled={busy}>
{busy ? '处理中…' : `全部确认(${items.length}`}
</Button>
) : undefined
}
/>
{items.length === 0 ? (
<EmptyState icon={<IconImage size={30} />} text="没有待确认的候选照片。可在本机运行 npm run fetch-images 灌入候选。" />
) : (
<div className="review-grid">
{items.map((p) => (
<div className="review-card" key={p.id}>
<a href={p.url} target="_blank" rel="noreferrer" className="review-img">
<img src={p.url} alt={p.modelCode} loading="lazy" />
</a>
<div className="review-meta">
<Link to={`/models/${p.modelId}`} className="review-code">
{p.modelCode}
</Link>
<span className="muted">{p.category}</span>
<span className="muted review-attr">
© {p.author || '未署名'}
{p.license ? ` · ${p.license}` : ''}
{p.sourceUrl ? (
<>
{' · '}
<a href={p.sourceUrl} target="_blank" rel="noreferrer"></a>
</>
) : null}
</span>
</div>
<div className="review-actions">
<Button variant="primary" size="sm" onClick={() => confirm(p.id)}></Button>
<Button variant="danger" size="sm" onClick={() => remove(p.id)}></Button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { PageHeader } from '../components/ui';
const ENDPOINTS = [
['GET', '/api/categories', '分类列表 + 计数'],
['GET', '/api/models', '车型列表(筛选/排序/分页)'],
['GET', '/api/models/:id', '车型详情(含众包覆盖)'],
['GET', '/api/models/families', '按系列聚合的族谱'],
['GET', '/api/search?q=', '搜索车型'],
['GET', '/api/stats', '统计概览(大屏数据源)'],
['GET', '/api/sightings/recent', '最新打卡动态'],
['GET', '/api/sightings/spots', '拍车攻略(按车站聚合)'],
['GET', '/api/export/models.json', '开放数据导出(JSON'],
['GET', '/api/export/models.csv', '开放数据导出(CSV'],
];
export function ApiDocsPage() {
return (
<div className="page">
<PageHeader
title="开放 API & 数据"
subtitle="本平台的车型数据以开放只读 API 提供,便于研究者与开发者使用。数据以 CC 精神开放(众包内容含来源标注)。"
/>
<div className="export-btns">
<a className="btn btn-primary btn-md" href="/api/export/models.json" target="_blank" rel="noreferrer">
JSON
</a>
<a className="btn btn-ghost btn-md" href="/api/export/models.csv">
CSV
</a>
</div>
<h2></h2>
<table className="cmp-table api-table">
<thead>
<tr><th></th><th></th><th></th></tr>
</thead>
<tbody>
{ENDPOINTS.map(([m, p, d]) => (
<tr key={p}>
<td><code>{m}</code></td>
<td><code>{p}</code></td>
<td>{d}</td>
</tr>
))}
</tbody>
</table>
<p className="muted">
/// <code>Authorization: Bearer &lt;token&gt;</code>
</p>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { COMPARE_AXES, normalizeForRadar } from '../lib/compare';
import { fmtValueUnit } from '../lib/format';
import { RadarChart, RADAR_COLORS } from '../components/RadarChart';
import { PageHeader, EmptyState } from '../components/ui';
import { IconCompare, IconClose } from '../components/icons';
import type { ModelDetail, SearchResult } from '../types';
const MAX = 4;
export function ComparePage() {
const [params, setParams] = useSearchParams();
const [models, setModels] = useState<ModelDetail[]>([]);
const [q, setQ] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const timer = useRef<ReturnType<typeof setTimeout>>();
const ids = (params.get('ids') || '')
.split(',')
.map(Number)
.filter((n) => Number.isFinite(n) && n > 0);
useEffect(() => {
Promise.all(ids.map((id) => api.model(id).catch(() => null))).then((rs) =>
setModels(rs.filter(Boolean) as ModelDetail[]),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [params.get('ids')]);
useEffect(() => {
clearTimeout(timer.current);
if (!q.trim()) {
setResults([]);
return;
}
timer.current = setTimeout(() => {
api.search(q.trim(), 6).then((r) => setResults(r.results)).catch(() => {});
}, 250);
return () => clearTimeout(timer.current);
}, [q]);
const setIds = (next: number[]) => {
const p = new URLSearchParams(params);
if (next.length) p.set('ids', next.join(','));
else p.delete('ids');
setParams(p, { replace: true });
};
const add = (id: number) => {
if (ids.includes(id) || ids.length >= MAX) return;
setIds([...ids, id]);
setQ('');
setResults([]);
};
const remove = (id: number) => setIds(ids.filter((x) => x !== id));
const series = normalizeForRadar(models, COMPARE_AXES);
return (
<div className="page">
<PageHeader
title="参数对比"
subtitle={`选 2–4 个车型,雷达图 + 表格并排比较(最多 ${MAX} 个)。`}
/>
<div className="cmp-add">
<div className="searchbox cmp-search">
<input
type="search"
placeholder="搜索型号加入对比…"
value={q}
onChange={(e) => setQ(e.target.value)}
disabled={ids.length >= MAX}
/>
{results.length > 0 && (
<ul className="search-dropdown">
{results.map((r) => (
<li key={r.id} onClick={() => add(r.id)}>
<strong>{r.model_code}</strong>
<span className="muted"> · {r.category}</span>
</li>
))}
</ul>
)}
</div>
<div className="cmp-chips">
{models.map((m, i) => (
<span className="cmp-chip" key={m.id} style={{ borderColor: RADAR_COLORS[i] }}>
<i style={{ background: RADAR_COLORS[i] }} />
{m.model_code}
<button onClick={() => remove(m.id)} aria-label="移除"><IconClose size={13} /></button>
</span>
))}
</div>
</div>
{models.length === 0 ? (
<EmptyState icon={<IconCompare size={30} />} text='还没有选择车型。用上方搜索框加入,或从车型详情页点"加入对比"。' />
) : (
<div className="cmp-body">
<RadarChart axes={COMPARE_AXES} series={series} />
<div className="cmp-table-wrap">
<table className="cmp-table">
<thead>
<tr>
<th></th>
{models.map((m, i) => (
<th key={m.id} style={{ color: RADAR_COLORS[i] }}>{m.model_code}</th>
))}
</tr>
</thead>
<tbody>
{COMPARE_AXES.map((a) => (
<tr key={a.key}>
<th>{a.label}</th>
{models.map((m) => (
<td key={m.id}>
{fmtValueUnit((m as any)[a.key], a.unit)}
</td>
))}
</tr>
))}
<tr>
<th> / </th>
{models.map((m) => (
<td key={m.id}>
{m.category} · {m.first_year ?? '—'}
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { EvolutionCurve } from '../components/EvolutionCurve';
import {
IconBrand,
IconStation,
IconGallery,
IconTimeline,
} from '../components/icons';
import { MdSpeed, MdPublic } from 'react-icons/md';
import type { Stats } from '../types';
const SEG_COLORS = [
'#4ea1ff', '#2bb39a', '#fbbf24', '#f87171', '#c084fc',
'#38bdf8', '#a3e635', '#fb923c', '#94a3b8', '#f472b6',
];
/** SVG 环形图(甜甜圈)+ 图例。*/
function Donut({ data }: { data: { label: string; count: number }[] }) {
const total = data.reduce((s, d) => s + d.count, 0) || 1;
const R = 70;
const r = 45;
const c = 90;
let acc = 0;
const segs = data.map((d, i) => {
const frac = d.count / total;
const a0 = acc * 2 * Math.PI - Math.PI / 2;
acc += frac;
const a1 = acc * 2 * Math.PI - Math.PI / 2;
const large = frac > 0.5 ? 1 : 0;
const p = (rad: number, ang: number) => [c + rad * Math.cos(ang), c + rad * Math.sin(ang)];
const [x0, y0] = p(R, a0);
const [x1, y1] = p(R, a1);
const [xi1, yi1] = p(r, a1);
const [xi0, yi0] = p(r, a0);
const path =
data.length === 1
? `M${c - R} ${c} A${R} ${R} 0 1 1 ${c + R} ${c} A${R} ${R} 0 1 1 ${c - R} ${c} ` +
`M${c - r} ${c} A${r} ${r} 0 1 0 ${c + r} ${c} A${r} ${r} 0 1 0 ${c - r} ${c} Z`
: `M${x0} ${y0} A${R} ${R} 0 ${large} 1 ${x1} ${y1} L${xi1} ${yi1} A${r} ${r} 0 ${large} 0 ${xi0} ${yi0} Z`;
return { path, color: SEG_COLORS[i % SEG_COLORS.length], label: d.label, count: d.count, frac };
});
return (
<div className="donut-wrap">
<svg viewBox="0 0 180 180" className="donut" role="img" aria-label="构成环形图">
{segs.map((s, i) => (
<path key={i} d={s.path} fill={s.color} fillRule="evenodd" />
))}
<text x="90" y="86" textAnchor="middle" className="donut-total">{total}</text>
<text x="90" y="106" textAnchor="middle" className="donut-total-label"></text>
</svg>
<ul className="donut-legend">
{segs.map((s, i) => (
<li key={i}>
<i style={{ background: s.color }} />
<span className="dl-label">{s.label}</span>
<b>{s.count}</b>
<span className="muted dl-pct">{Math.round(s.frac * 100)}%</span>
</li>
))}
</ul>
</div>
);
}
/** 纵向柱状图。*/
function VBars({ data }: { data: { label: string; count: number }[] }) {
const max = Math.max(1, ...data.map((d) => d.count));
return (
<div className="vbars">
{data.map((d) => (
<div className="vbar-col" key={d.label}>
<span className="vbar-val">{d.count}</span>
<span className="vbar-track">
<span className="vbar-fill" style={{ height: `${(d.count / max) * 100}%` }} />
</span>
<span className="vbar-x">{d.label}</span>
</div>
))}
</div>
);
}
function Kpi({
icon,
value,
label,
accent,
}: {
icon: React.ReactNode;
value: React.ReactNode;
label: string;
accent: string;
}) {
return (
<div className="kpi" style={{ ['--kpi' as string]: accent }}>
<span className="kpi-icon">{icon}</span>
<div className="kpi-body">
<b>{value}</b>
<span>{label}</span>
</div>
</div>
);
}
export function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
api.stats().then(setStats).catch(() => {});
}, []);
useEffect(() => {
const t = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(t);
}, []);
if (!stats) return <div className="page"><p className="muted"></p></div>;
const evo = stats.speedByDecade.map((d) => ({ decade: d.decade, maxSpeed: d.maxSpeed }));
const topSpeed = Math.max(0, ...stats.speedByDecade.map((d) => d.maxSpeed));
const decades = stats.byDecade.map((d) => d.decade).filter((n) => Number.isFinite(n));
const span = decades.length ? Math.max(...decades) - Math.min(...decades) : 0;
return (
<div className="dashboard">
<header className="dash-topbar">
<div className="dash-title">
<IconBrand size={26} />
<div>
<h1></h1>
<span className="dash-sub">CHINA LOCOMOTIVE DATA · </span>
</div>
</div>
<div className="dash-clock">
<b>{now.toLocaleTimeString('zh-CN', { hour12: false })}</b>
<span>{now.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short' })}</span>
</div>
</header>
<div className="kpi-row">
<Kpi icon={<IconGallery size={22} />} value={stats.totals.models} label="收录车型" accent="#4ea1ff" />
<Kpi icon={<IconStation size={22} />} value={stats.totals.units} label="车辆个体" accent="#2bb39a" />
<Kpi icon={<IconTimeline size={22} />} value={stats.totals.categories} label="车型分类" accent="#c084fc" />
<Kpi icon={<MdSpeed size={22} />} value={topSpeed || '—'} label="最高时速 km/h" accent="#fbbf24" />
<Kpi icon={<MdPublic size={22} />} value={span ? `${span}+` : '—'} label="跨越年代(年)" accent="#f87171" />
</div>
<div className="dash-grid">
<section className="dash-card span-2">
<div className="dash-card-head">
<h2></h2>
<span className="dash-tag">km/h · </span>
</div>
<EvolutionCurve points={evo} />
</section>
<section className="dash-card">
<div className="dash-card-head">
<h2></h2>
<span className="dash-tag">{stats.byCategory.length} </span>
</div>
<Donut data={stats.byCategory} />
</section>
<section className="dash-card">
<div className="dash-card-head">
<h2></h2>
<span className="dash-tag">{stats.byCountryType.length} </span>
</div>
<Donut data={stats.byCountryType} />
</section>
<section className="dash-card span-2">
<div className="dash-card-head">
<h2></h2>
<span className="dash-tag"> · </span>
</div>
<VBars data={stats.byDecade.map((d) => ({ label: `${d.decade}s`, count: d.count }))} />
</section>
</div>
<p className="dash-foot muted">{stats.totals.models} / {stats.totals.units} </p>
</div>
);
}
+592
View File
@@ -0,0 +1,592 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useParams, useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { ModelDetail } from '../types';
import { fmtEra, fmtValueUnit, statusClass } from '../lib/format';
import { labelOf } from '../lib/fieldLabels';
import { sampleImagesFor } from '../lib/sampleImages';
import { useFollow } from '../lib/useFollow';
import { useAuth } from '../lib/auth';
import type { GalleryImage, Revision, Maintainer, Thread, Sighting, Photo } from '../types';
import { Thumb } from '../components/Thumb';
import { Lightbox } from '../components/Lightbox';
import { EditModal } from '../components/EditModal';
import { RevisionList } from '../components/RevisionList';
import { CategoryIcon, IconBack, IconEdit, IconCompare, IconStarFilled, IconStarOutline, IconConfirm, IconDelete, IconMaintain, IconLocation, IconForward } from '../components/icons';
import { Button } from '../components/ui';
type Row = { label: string; value: string };
const val = (v: unknown) => {
const s = v == null ? '' : String(v).trim();
return s || '—';
};
function basicRows(m: ModelDetail): Row[] {
return [
{ label: '分类', value: `${m.category}${m.subcat ? ' · ' + m.subcat : ''}` },
{ label: '系列', value: val(m.series) },
{ label: '别名', value: val((m as any).aliases) },
{ label: '生产商', value: val(m.manufacturer) },
{ label: '制造国/地区', value: val(m.country) },
{ label: '国别属性', value: val(m.country_type) },
{ label: '首产年', value: m.first_year ? String(m.first_year) : '—' },
{ label: '停产年', value: m.last_year ? String(m.last_year) : '—' },
{ label: '用途', value: val(m.usage) },
{ label: '产量', value: val((m as any).production_count) },
{ label: '数据来源', value: val((m as any).source_sheet) },
];
}
function sizeRows(m: ModelDetail): Row[] {
const f = (k: string) =>
fmtValueUnit((m as any)[`${k}_value`], (m as any)[`${k}_unit`]);
return [
{ label: '车长', value: f('length') },
{ label: '车宽', value: f('width') },
{ label: '车高', value: f('height') },
{ label: '轴距', value: f('wheelbase') },
{ label: '整备重量', value: f('weight') },
{ label: '轴重', value: f('axle_load') },
{ label: '载重', value: f('load') },
];
}
function powerRows(m: ModelDetail): Row[] {
const f = (k: string) =>
fmtValueUnit((m as any)[`${k}_value`], (m as any)[`${k}_unit`]);
return [
{ label: '最高时速', value: f('max_speed') },
{ label: '起动牵引力', value: f('tractive_start') },
{ label: '持续牵引力', value: f('tractive_cont') },
{ label: '功率', value: f('power_kw') },
{ label: '容积', value: f('capacity') },
{ label: '传动效率', value: val((m as any).efficiency) },
{ label: '传动/供电', value: val(m.drive as string) },
{ label: '轴列式', value: val(m.axle_arrangement) },
];
}
function Section({ title, rows }: { title: string; rows: Row[] }) {
return (
<section className="d-section">
<h2>{title}</h2>
<dl className="d-grid">
{rows.map((r) => (
<div key={r.label} className={r.value === '—' ? 'd-row empty' : 'd-row'}>
<dt>{r.label}</dt>
<dd>{r.value}</dd>
</div>
))}
</dl>
</section>
);
}
export function DetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const { user } = useAuth();
const isAdmin = user?.role === 'admin';
const [m, setM] = useState<ModelDetail | null>(null);
const [error, setError] = useState('');
const [lbIndex, setLbIndex] = useState<number | null>(null);
const [showEdit, setShowEdit] = useState(false);
const [revisions, setRevisions] = useState<Revision[]>([]);
const [maintainers, setMaintainers] = useState<Maintainer[]>([]);
const [threads, setThreads] = useState<Thread[]>([]);
const [discTitle, setDiscTitle] = useState('');
const [discBody, setDiscBody] = useState('');
const [sightings, setSightings] = useState<Sighting[]>([]);
const [spot, setSpot] = useState({ lat: '', lng: '', station: '', carNumber: '' });
const [spotErr, setSpotErr] = useState('');
const [notice, setNotice] = useState('');
const [reload, setReload] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const [photos, setPhotos] = useState<Photo[]>([]);
const { isFollowing, toggle: toggleFollow } = useFollow();
useEffect(() => {
if (!id) return;
setM(null);
setError('');
api
.model(Number(id))
.then(setM)
.catch((e) => setError(String(e)));
api.modelRevisions(Number(id)).then(setRevisions).catch(() => {});
api.maintainers(Number(id)).then(setMaintainers).catch(() => {});
api.threads({ modelId: Number(id) }).then(setThreads).catch(() => {});
api.modelSightings(Number(id)).then(setSightings).catch(() => {});
api.modelPhotos(Number(id)).then(setPhotos).catch(() => {});
}, [id, reload]);
const uploadPhotos = async (files: FileList) => {
if (!id) return;
for (const f of Array.from(files)) {
try {
await api.uploadPhoto(Number(id), f);
} catch {
/* ignore single failure */
}
}
api.modelPhotos(Number(id)).then(setPhotos).catch(() => {});
};
const removePhoto = async (pid: number) => {
if (!id) return;
try {
await api.deletePhoto(pid);
api.modelPhotos(Number(id)).then(setPhotos);
} catch {
/* ignore */
}
};
const confirmPhoto = async (pid: number) => {
if (!id) return;
try {
await api.confirmPhoto(pid);
api.modelPhotos(Number(id)).then(setPhotos);
} catch {
/* ignore */
}
};
const confirmAll = async () => {
if (!id) return;
for (const p of photos.filter((x) => x.status === 'candidate')) {
try {
await api.confirmPhoto(p.id);
} catch {
/* ignore */
}
}
api.modelPhotos(Number(id)).then(setPhotos);
};
const featurePhoto = async (pid: number) => {
if (!id) return;
try {
await api.featurePhoto(pid);
api.modelPhotos(Number(id)).then(setPhotos);
} catch {
/* ignore */
}
};
const useMyLocation = () => {
if (!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition((pos) => {
setSpot((s) => ({
...s,
lat: pos.coords.latitude.toFixed(5),
lng: pos.coords.longitude.toFixed(5),
}));
});
};
const checkIn = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) return;
setSpotErr('');
const lat = Number(spot.lat);
const lng = Number(spot.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng) || lat < -90 || lat > 90 || lng < -180 || lng > 180) {
setSpotErr('请填写有效的经纬度(可点"用我的位置"');
return;
}
try {
await api.createSighting(Number(id), {
lat,
lng,
station: spot.station,
carNumber: spot.carNumber,
});
setSpot({ lat: '', lng: '', station: '', carNumber: '' });
api.modelSightings(Number(id)).then(setSightings);
} catch (err) {
setSpotErr(err instanceof Error ? err.message : '打卡失败');
}
};
const startDiscussion = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) return;
try {
await api.createThread({
board: 'entry',
modelId: Number(id),
title: discTitle,
body: discBody,
});
setDiscTitle('');
setDiscBody('');
api.threads({ modelId: Number(id) }).then(setThreads);
} catch {
/* ignore */
}
};
const iAmMaintainer = !!user && maintainers.some((mm) => mm.id === user.id);
const toggleMaintain = async () => {
if (!id) return;
try {
const next = iAmMaintainer
? await api.unclaimMaintainer(Number(id))
: await api.claimMaintainer(Number(id));
setMaintainers(next);
} catch {
/* ignore */
}
};
if (error) return <p className="error">{error}</p>;
if (!m) return <p className="muted"></p>;
// 返回:优先回到来路(保留图鉴所在页码);无历史时回图鉴首页
const goBack = () => {
const st = window.history.state as { idx?: number } | null;
if (st && typeof st.idx === 'number' && st.idx > 0) navigate(-1);
else navigate('/explore');
};
// 原始数据:英文键映射为中文
const rawEntries = m.raw
? Object.entries(m.raw).map(([k, v]) => [labelOf(k), v] as [string, unknown])
: [];
// 图册:共享图库(本地,含从 Commons 下载的照片)→ 系统示意图(兜底)
const allImages: GalleryImage[] = [
...photos.map((p) => ({
id: 'photo-' + p.id,
src: p.url,
caption: p.caption || `${m.model_code} · ${p.uploaderName}`,
removable: isAdmin,
source: 'photo' as const,
photoId: p.id,
pending: p.status === 'candidate',
featured: p.featured,
attribution: p.sourceUrl || p.author
? { author: p.author, license: p.license, url: p.sourceUrl }
: undefined,
})),
...sampleImagesFor(m).map((s) => ({
id: s.id,
src: s.dataUrl,
caption: s.caption,
removable: false,
source: 'sample' as const,
})),
];
// 详情头图:封面照片(featured 优先) → 已确认图库 → 示意图
const heroPhoto =
photos.find((p) => p.featured && p.status === 'confirmed') ??
photos.find((p) => p.status === 'confirmed');
const heroImg = heroPhoto?.url ?? null;
return (
<div className="detail">
<button type="button" className="back" onClick={goBack}>
<IconBack size={14} />
</button>
<div className="detail-hero" style={{ ['--accent' as string]: '#4ea1ff' }}>
<div className="detail-thumb">
{heroImg ? (
<img src={heroImg} alt={m.model_code} className="detail-hero-img" />
) : (
<Thumb category={m.category} code={m.model_code} />
)}
</div>
<div className="detail-head">
<div className="detail-title">
<CategoryIcon category={m.category} size={22} />
<h1>{m.model_code}</h1>
<span className={statusClass(m.status)}>{m.status}</span>
</div>
{m.full_name ? <p className="muted">{m.full_name}</p> : null}
<div className="detail-tags">
<span className="chip">{m.category}</span>
{m.series ? <span className="chip">{m.series}</span> : null}
<span className="chip">{fmtEra(m.first_year, m.last_year)}</span>
<span className="chip">
{fmtValueUnit(m.max_speed_value, m.max_speed_unit)}
</span>
<span className="chip">{m.country_type}</span>
</div>
<div className="detail-actions">
{isAdmin && (
<Button variant="ghost" size="sm" onClick={() => setShowEdit(true)}>
<IconEdit size={15} />
</Button>
)}
<Link to={`/compare?ids=${m.id}`} className="btn btn-secondary btn-sm">
<IconCompare size={15} />
</Link>
<Button variant="secondary" size="sm" onClick={() => toggleFollow(m.id)}>
{isFollowing(m.id) ? (
<>
<IconStarFilled size={15} />
</>
) : (
<>
<IconStarOutline size={15} />
</>
)}
</Button>
</div>
</div>
</div>
{notice && <p className="notice">{notice}</p>}
{/* 图册:本地多图 + 灯箱缩放 */}
<section className="d-section">
<div className="gallery-head">
<h2></h2>
{isAdmin && (
<>
<Button variant="ghost" size="sm" onClick={() => fileRef.current?.click()}>
+
</Button>
{photos.some((p) => p.status === 'candidate') && (
<Button variant="primary" size="sm" onClick={confirmAll}>
</Button>
)}
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
hidden
onChange={(e) => {
if (e.target.files?.length) uploadPhotos(e.target.files);
e.target.value = '';
}}
/>
</>
)}
</div>
{allImages.length === 0 ? (
<p className="muted img-empty"></p>
) : (
<div className="img-grid">
{allImages.map((im, i) => (
<div className="img-cell" key={im.id}>
<img
src={im.src}
alt={im.caption}
loading="lazy"
onClick={() => setLbIndex(i)}
/>
<span className={`img-badge badge-${im.source}`}>
{im.source === 'photo'
? im.pending
? '候选'
: '图库'
: '示意图'}
</span>
{im.removable && im.photoId != null && (
<div className="img-admin">
{im.pending ? (
<button
className="img-confirm"
aria-label="确认照片"
title="确认"
onClick={() => confirmPhoto(im.photoId!)}
>
<IconConfirm size={14} />
</button>
) : (
<button
className={im.featured ? 'img-feature on' : 'img-feature'}
aria-label="设为封面"
title={im.featured ? '当前封面' : '设为封面'}
onClick={() => featurePhoto(im.photoId!)}
>
<IconStarFilled size={14} />
</button>
)}
<button
className="img-del"
aria-label="删除图片"
onClick={() => removePhoto(im.photoId!)}
>
<IconDelete size={14} />
</button>
</div>
)}
</div>
))}
</div>
)}
<p className="muted img-hint">
Wikimedia Commons
</p>
</section>
<Section title="基本信息" rows={basicRows(m)} />
<Section title="尺寸与重量" rows={sizeRows(m)} />
<Section title="动力与性能" rows={powerRows(m)} />
{rawEntries.length > 0 && (
<section className="d-section">
<h2></h2>
<dl className="d-grid">
{rawEntries.map(([k, v]) => (
<div key={k} className="d-row">
<dt>{k}</dt>
<dd>{String(v)}</dd>
</div>
))}
</dl>
</section>
)}
{lbIndex !== null && (
<Lightbox
images={allImages}
index={lbIndex}
onClose={() => setLbIndex(null)}
onIndexChange={setLbIndex}
/>
)}
<section className="d-section">
<div className="gallery-head">
<h2></h2>
{user && (
<Button variant="ghost" size="sm" onClick={toggleMaintain}>
{iAmMaintainer ? '取消认领' : '认领维护'}
</Button>
)}
</div>
{maintainers.length === 0 ? (
<p className="muted"></p>
) : (
<div className="maintainers">
{maintainers.map((mm) => (
<span className="chip" key={mm.id}><IconMaintain size={13} /> {mm.displayName}</span>
))}
</div>
)}
</section>
<section className="d-section">
<h2></h2>
<RevisionList revisions={revisions} />
</section>
<section className="d-section">
<h2></h2>
{sightings.length === 0 ? (
<p className="muted"></p>
) : (
<ul className="feed-list">
{sightings.map((s) => (
<li key={s.id} className="sighting-row">
<b>{s.station || '未知地点'}</b>
<span className="muted">
{' '}· {s.user_name} · {s.car_number || '—'}
{s.description ? ` · ${s.description}` : ''}
</span>
</li>
))}
</ul>
)}
{user ? (
<form className="new-thread checkin-form" onSubmit={checkIn}>
<div className="checkin-row">
<input
placeholder="纬度 lat"
value={spot.lat}
onChange={(e) => setSpot((s) => ({ ...s, lat: e.target.value }))}
/>
<input
placeholder="经度 lng"
value={spot.lng}
onChange={(e) => setSpot((s) => ({ ...s, lng: e.target.value }))}
/>
<button type="button" onClick={useMyLocation} className="loc-btn">
<IconLocation size={14} />
</button>
</div>
<div className="checkin-row">
<input
placeholder="车站/地点"
value={spot.station}
onChange={(e) => setSpot((s) => ({ ...s, station: e.target.value }))}
/>
<input
placeholder="车号(可选)"
value={spot.carNumber}
onChange={(e) => setSpot((s) => ({ ...s, carNumber: e.target.value }))}
/>
</div>
{spotErr && <p className="error">{spotErr}</p>}
<Button variant="primary" type="submit"></Button>
<Link to="/map" className="muted map-link"> <IconForward size={13} /></Link>
</form>
) : (
<p className="muted"></p>
)}
</section>
<section className="d-section">
<h2></h2>
{threads.length === 0 ? (
<p className="muted"></p>
) : (
<ul className="thread-list">
{threads.map((t) => (
<li key={t.id}>
<Link to={`/thread/${t.id}`} className="thread-row">
<span className="thread-title">{t.title}</span>
<span className="muted thread-meta">
{t.author_name} · {t.reply_count}
</span>
</Link>
</li>
))}
</ul>
)}
{user ? (
<form className="new-thread" onSubmit={startDiscussion}>
<input
placeholder="讨论标题"
value={discTitle}
onChange={(e) => setDiscTitle(e.target.value)}
required
/>
<textarea
placeholder="说点什么…"
value={discBody}
onChange={(e) => setDiscBody(e.target.value)}
rows={3}
required
/>
<Button variant="primary" type="submit"></Button>
</form>
) : (
<p className="muted"></p>
)}
</section>
{showEdit && (
<EditModal
model={m}
onClose={() => setShowEdit(false)}
onSaved={(msg) => {
setShowEdit(false);
setNotice(msg);
setReload((n) => n + 1);
}}
/>
)}
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { Category, Family } from '../types';
import { fmtEra, fmtValueUnit } from '../lib/format';
import { Thumb } from '../components/Thumb';
import { PageHeader, EmptyState } from '../components/ui';
export function FamilyPage() {
const [categories, setCategories] = useState<Category[]>([]);
const [category, setCategory] = useState('电力机车');
const [family, setFamily] = useState<Family | null>(null);
useEffect(() => {
api.categories().then(setCategories).catch(() => {});
}, []);
useEffect(() => {
api.families(category).then((fs) => setFamily(fs[0] ?? null)).catch(() => {});
}, [category]);
const catNames = Array.from(new Set(categories.map((c) => c.name)));
return (
<div className="page">
<PageHeader
title="技术族谱"
subtitle="按“系列”梳理车型谱系,呈现同系列的演进脉络(如 东风系列、和谐系列);跨型号/跨国原型关系将随众包数据逐步接入。"
/>
<div className="board-tabs">
{catNames.map((n) => (
<button
key={n}
className={category === n ? 'active' : ''}
onClick={() => setCategory(n)}
>
{n}
</button>
))}
</div>
{!family || family.series.length === 0 ? (
<EmptyState text="该分类暂无数据。" />
) : (
<div className="family">
{family.series.map((s) => (
<div className="family-series" key={s.name}>
<div className="family-series-name">
{s.name}
<span className="muted"> · {s.models.length}</span>
</div>
<div className="family-track">
{s.models.map((m) => (
<Link key={m.id} to={`/models/${m.id}`} className="family-node">
<span className="fn-thumb">
<Thumb category={m.category} code={m.model_code} size="sm" />
</span>
<span className="fn-code">{m.model_code}</span>
<span className="fn-meta">
{fmtEra(m.first_year, m.last_year)} ·{' '}
{fmtValueUnit(m.max_speed_value, m.max_speed_unit)}
</span>
{m.country_type && m.country_type !== '国产' && (
<span className="fn-flag">{m.country_type}</span>
)}
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../lib/auth';
import { Button, PageHeader, EmptyState } from '../components/ui';
import { IconForum } from '../components/icons';
import type { Board, Thread } from '../types';
export function ForumPage() {
const { user } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [board, setBoard] = useState('general');
const [threads, setThreads] = useState<Thread[]>([]);
const [showNew, setShowNew] = useState(false);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [error, setError] = useState('');
useEffect(() => {
api.boards().then(setBoards).catch(() => {});
}, []);
useEffect(() => {
api.threads({ board }).then(setThreads).catch(() => {});
}, [board]);
const create = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
await api.createThread({ board, title, body });
setTitle('');
setBody('');
setShowNew(false);
api.threads({ board }).then(setThreads);
} catch (err) {
setError(err instanceof Error ? err.message : '发帖失败');
}
};
return (
<div className="page">
<PageHeader title="社区" subtitle="按板块发帖讨论:综合、拍车打卡、历史考证。" />
<div className="board-tabs">
{boards.map((b) => (
<button
key={b.key}
className={board === b.key ? 'active' : ''}
onClick={() => setBoard(b.key)}
>
{b.label}
</button>
))}
{user && (
<Button variant="ghost" size="sm" className="new-thread-btn" onClick={() => setShowNew((s) => !s)}>
{showNew ? '取消' : '+ 发帖'}
</Button>
)}
</div>
{showNew && user && (
<form className="new-thread" onSubmit={create}>
<input
placeholder="标题"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<textarea
placeholder="正文…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={4}
required
/>
{error && <p className="error">{error}</p>}
<Button variant="primary" type="submit"></Button>
</form>
)}
{threads.length === 0 ? (
<EmptyState icon={<IconForum size={30} />} text="这个板块还没有帖子,来发第一帖吧。" />
) : (
<ul className="thread-list">
{threads.map((t) => (
<li key={t.id}>
<Link to={`/thread/${t.id}`} className="thread-row">
<span className="thread-title">{t.title}</span>
<span className="muted thread-meta">
{t.author_name} · {t.reply_count}
</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import type { ModelListItem } from '../types';
import {
computeSpeedEvolution,
ERAS,
pickRepresentatives,
} from '../lib/eras';
import { Thumb } from '../components/Thumb';
import { CategoryIcon, IconForward } from '../components/icons';
import { fmtEra, fmtValueUnit } from '../lib/format';
import { EvolutionCurve } from '../components/EvolutionCurve';
export function HomePage() {
const [models, setModels] = useState<ModelListItem[]>([]);
useEffect(() => {
api.models({ pageSize: 1000 }).then((d) => setModels(d.items)).catch(() => {});
}, []);
const evo = computeSpeedEvolution(models);
const total = models.length;
const years = models
.map((m) => m.first_year)
.filter((y): y is number => typeof y === 'number');
const minYear = years.length ? Math.min(...years) : 1881;
const topSpeed = Math.max(0, ...models.map((m) => m.max_speed_value ?? 0));
return (
<div className="home">
{/* 英雄区 + 时速演进:并排一行 */}
<section className="hero-row">
<div className="hero">
<h1></h1>
<p className="hero-sub">
线
<strong></strong>
</p>
<div className="hero-stats">
<div>
<b>{total || '—'}</b>
<span></span>
</div>
<div>
<b>{minYear}</b>
<span></span>
</div>
<div>
<b>{topSpeed || '—'}</b>
<span> km/h</span>
</div>
<div>
<b>4</b>
<span></span>
</div>
</div>
<Link to="/explore" className="hero-cta">
<IconForward size={16} />
</Link>
</div>
<div className="hero-evo">
<h2></h2>
<p className="muted eras-intro">
线
</p>
{evo.length >= 2 && <EvolutionCurve points={evo} />}
</div>
</section>
{/* 主线:四个时代章节 */}
<section className="eras">
<h2>沿</h2>
<div className="era-rail">
{ERAS.map((era, i) => {
const reps = pickRepresentatives(models, era, 4);
return (
<article
className="era"
key={era.key}
style={{ ['--accent' as string]: era.accent }}
>
<div className="era-head">
<span className="era-icon">
<CategoryIcon category={era.categories[0]} size={26} />
</span>
<div>
<h3>
<span className="era-step">{i + 1}</span>
{era.title}
</h3>
<span className="era-period">{era.period}</span>
</div>
</div>
<p className="era-blurb">{era.blurb}</p>
<div className="era-reps">
{reps.map((m) => (
<Link key={m.id} to={`/models/${m.id}`} className="mini">
{m.cover_url ? (
<img src={m.cover_url} alt={m.model_code} loading="lazy" />
) : (
<Thumb category={m.category} code={m.model_code} size="sm" />
)}
<span className="mini-code">{m.model_code}</span>
<span className="mini-meta">
{fmtValueUnit(m.max_speed_value, m.max_speed_unit)} ·{' '}
{fmtEra(m.first_year, m.last_year)}
</span>
</Link>
))}
</div>
<Link
to={`/explore?category=${encodeURIComponent(era.categories[0])}`}
className="era-more"
>
{era.title.replace('时代', '')} <IconForward size={14} />
</Link>
</article>
);
})}
</div>
</section>
</div>
);
}
+281
View File
@@ -0,0 +1,281 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../lib/auth';
import { PageHeader, Button } from '../components/ui';
import { CategoryIcon, IconImage, IconForward, IconDelete } from '../components/icons';
import type { Identification } from '../types';
const pct = (c: number) => `${Math.round((c || 0) * 100)}%`;
const fmtTime = (s: string) =>
new Date((s || '').replace(' ', 'T') + 'Z').toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
});
/** 历史记录项:含缩略图、候选、可编辑备注、删除。*/
function HistoryItem({
it,
onSaveNote,
onDelete,
}: {
it: Identification;
onSaveNote: (id: number, note: string) => Promise<void>;
onDelete: (id: number) => void;
}) {
const [note, setNote] = useState(it.note);
const [saving, setSaving] = useState(false);
const dirty = note !== it.note;
const top = it.guesses[0];
return (
<div className="hist-item">
<img className="hist-thumb" src={it.url} alt={it.topCode || '识别图片'} loading="lazy" />
<div className="hist-body">
<div className="hist-head">
<span className="hist-code">{it.topCode || it.topName || (it.error ? '识别失败' : '未识别')}</span>
{top && <span className="hist-conf">{pct(top.confidence)}</span>}
{it.cached && <span className="hist-badge"></span>}
<span className="muted hist-time">{fmtTime(it.createdAt)}</span>
<button className="hist-del" aria-label="删除" title="删除" onClick={() => onDelete(it.id)}>
<IconDelete size={15} />
</button>
</div>
{it.error ? (
<p className="error hist-err">{it.error}</p>
) : (
it.summary && <p className="hist-summary">{it.summary}</p>
)}
{it.matches.length > 0 && (
<div className="hist-matches">
{it.matches.slice(0, 5).map((m) => (
<Link key={m.id} to={`/models/${m.id}`} className="hist-match">
<CategoryIcon category={m.category} size={14} /> {m.model_code}
</Link>
))}
</div>
)}
<div className="hist-note">
<input
placeholder="加条备注 / 人工纠正…"
value={note}
onChange={(e) => setNote(e.target.value)}
/>
{dirty && (
<Button
variant="ghost"
size="sm"
disabled={saving}
onClick={async () => {
setSaving(true);
await onSaveNote(it.id, note);
setSaving(false);
}}
>
</Button>
)}
</div>
</div>
</div>
);
}
export function IdentifyPage() {
const { user } = useAuth();
const [preview, setPreview] = useState('');
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<Identification | null>(null);
const [history, setHistory] = useState<Identification[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (user) api.identifications().then(setHistory).catch(() => {});
else setHistory([]);
}, [user]);
const pick = (f: File) => {
setFile(f);
setResult(null);
setError('');
setPreview(URL.createObjectURL(f));
};
const run = async () => {
if (!file) return;
setLoading(true);
setError('');
setResult(null);
try {
const r = await api.identify(file);
setResult(r);
setHistory((h) => [r, ...h]);
} catch (e) {
setError(e instanceof Error ? e.message : '识别失败');
} finally {
setLoading(false);
}
};
const saveNote = async (id: number, note: string) => {
try {
const updated = await api.updateIdentification(id, note);
setHistory((h) => h.map((x) => (x.id === id ? updated : x)));
} catch {
/* ignore */
}
};
const del = async (id: number) => {
if (!window.confirm('确定删除这条识别记录?删除后不可恢复。')) return;
try {
await api.deleteIdentification(id);
setHistory((h) => h.filter((x) => x.id !== id));
setResult((r) => (r && r.id === id ? null : r));
} catch {
/* ignore */
}
};
return (
<div className="page">
<PageHeader
title="AI 识车"
subtitle="上传一张机车 / 动车组照片,由通义千问视觉模型识别车型;结果自动保存到历史。"
/>
{!user && (
<div className="notice">
使 AI <Link to="/"></Link>
</div>
)}
<div className="identify-layout">
<div className="identify-left">
<label className="identify-drop">
{preview ? (
<img src={preview} alt="预览" />
) : (
<span className="identify-drop-hint">
<IconImage size={34} />
<span></span>
<small className="muted"> jpg / png / webp 8MB</small>
</span>
)}
<input
ref={inputRef}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const f = e.target.files?.[0];
if (f) pick(f);
e.target.value = '';
}}
/>
</label>
<div className="identify-actions">
<Button variant="secondary" onClick={() => inputRef.current?.click()}>
</Button>
<Button variant="primary" onClick={run} disabled={!file || !user || loading}>
{loading ? '识别中…' : '开始识别'}
</Button>
</div>
</div>
<div className="identify-right">
{error && <p className="error">{error}</p>}
{loading && (
<div className="identify-loading">
<span className="spinner" />
<p className="muted"></p>
</div>
)}
{result?.error && <div className="notice notice-warn">{result.error}</div>}
{result && !result.error && (
<>
{result.summary && (
<p className="identify-summary">
{result.summary}
{result.cached && <span className="hist-badge"></span>}
</p>
)}
<h2></h2>
{result.guesses.length === 0 ? (
<p className="muted"></p>
) : (
<ol className="guess-list">
{result.guesses.map((g, i) => (
<li className="guess-row" key={i}>
<div className="guess-head">
<span className="guess-code">{g.model_code || g.name || '未知'}</span>
{g.name && g.name !== g.model_code && (
<span className="muted guess-name">{g.name}</span>
)}
<span className="guess-conf">{pct(g.confidence)}</span>
</div>
<div className="guess-bar">
<span style={{ width: pct(g.confidence) }} />
</div>
{g.reason && <p className="guess-reason muted">{g.reason}</p>}
</li>
))}
</ol>
)}
{result.matches.length > 0 && (
<>
<h2></h2>
<div className="match-list">
{result.matches.map((m) => (
<Link key={m.id} to={`/models/${m.id}`} className="match-card">
<CategoryIcon category={m.category} size={20} />
<span className="match-code">{m.model_code}</span>
<span className="muted match-cat">{m.category}</span>
<IconForward size={14} />
</Link>
))}
</div>
</>
)}
<p className="muted identify-foot">
</p>
</>
)}
{!result && !loading && !error && (
<div className="identify-placeholder muted">
<IconImage size={40} />
<p>AI </p>
</div>
)}
</div>
</div>
{user && (
<section className="identify-history">
<div className="gallery-head">
<h2>{history.length}</h2>
</div>
{history.length === 0 ? (
<p className="muted"></p>
) : (
<div className="hist-list">
{history.map((it) => (
<HistoryItem key={it.id} it={it} onSaveNote={saveNote} onDelete={del} />
))}
</div>
)}
</section>
)}
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { ROLE_LABEL } from '../lib/auth';
import { PageHeader, EmptyState } from '../components/ui';
import { IconTrophy } from '../components/icons';
import type { LeaderboardEntry } from '../types';
export function LeaderboardPage() {
const [rows, setRows] = useState<LeaderboardEntry[]>([]);
const [error, setError] = useState('');
useEffect(() => {
api.leaderboard(50).then(setRows).catch((e) => setError(String(e)));
}, []);
return (
<div className="page">
<PageHeader title="贡献榜" subtitle="众包共建者的荣誉墙 · 按累计贡献积分排名。" />
{error && <p className="error">{error}</p>}
{rows.length === 0 ? (
<EmptyState icon={<IconTrophy size={30} />} text="还没有贡献者。成为第一个编辑词条的人吧!" />
) : (
<ol className="leaderboard">
{rows.map((r) => (
<li key={r.id} className={`lb-row rank-${r.rank}`}>
<span className="lb-rank">{r.rank}</span>
<span className="lb-name">{r.displayName}</span>
<span className="chip">{r.title}</span>
<span className="muted lb-role">{ROLE_LABEL[r.role] ?? r.role}</span>
<span className="lb-points">{r.points}</span>
</li>
))}
</ol>
)}
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import type { Category, ModelListItem, ModelQuery, Paged } from '../types';
import { FilterBar } from '../components/FilterBar';
import { TimelineView } from '../components/TimelineView';
import { GalleryView } from '../components/GalleryView';
import { useCollection } from '../lib/useCollection';
import { IconGallery, IconTimeline, IconFilter } from '../components/icons';
import { Button } from '../components/ui';
type ViewMode = 'gallery' | 'timeline';
export function ListPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [categories, setCategories] = useState<Category[]>([]);
const [query, setQuery] = useState<ModelQuery>({
page: 1,
pageSize: 1000,
sort: 'first_year',
order: 'asc',
category: searchParams.get('category') ?? undefined,
});
const [data, setData] = useState<Paged<ModelListItem> | null>(null);
const [view, setView] = useState<ViewMode>('gallery');
const [showFilters, setShowFilters] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [kw, setKw] = useState('');
const { collectedIds, toggle } = useCollection();
useEffect(() => {
api.categories().then(setCategories).catch(() => {});
}, []);
// URL ?category= 变化时同步(从首页章节跳入)
useEffect(() => {
const cat = searchParams.get('category') ?? undefined;
setQuery((q) => (q.category === cat ? q : { ...q, category: cat, page: 1 }));
}, [searchParams]);
useEffect(() => {
setLoading(true);
setError('');
api
.models(query)
.then(setData)
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, [query]);
const patch = (p: Partial<ModelQuery>) => {
setQuery((q) => ({ ...q, ...p, page: 1 }));
// 任意筛选变化都回到图鉴第 1 页
const next = new URLSearchParams(searchParams);
next.delete('gp');
if ('category' in p) {
if (p.category) next.set('category', p.category);
else next.delete('category');
}
setSearchParams(next, { replace: true });
};
// 图鉴分页:页码存于 URL ?gp=,从详情返回时可回到原页
const galleryPage = Math.max(1, Number(searchParams.get('gp')) || 1);
const setGalleryPage = (gp: number) => {
const next = new URLSearchParams(searchParams);
if (gp <= 1) next.delete('gp');
else next.set('gp', String(gp));
setSearchParams(next, { replace: true });
};
// 关键字(型号)输入防抖 → query.q
useEffect(() => {
const t = setTimeout(() => {
setQuery((q) =>
q.q === (kw || undefined) ? q : { ...q, q: kw || undefined, page: 1 },
);
}, 300);
return () => clearTimeout(t);
}, [kw]);
const activeFilterCount = useMemo(() => {
let n = 0;
if (query.category) n++;
if (query.status) n++;
if (query.country) n++;
if (query.yearFrom) n++;
if (query.yearTo) n++;
if (query.speedMin) n++;
return n;
}, [query]);
return (
<div className="page">
<div className="explore-bar">
<div className="viewswitch">
<button
className={view === 'gallery' ? 'active' : ''}
onClick={() => setView('gallery')}
>
<IconGallery size={16} />
</button>
<button
className={view === 'timeline' ? 'active' : ''}
onClick={() => setView('timeline')}
>
<IconTimeline size={16} />
</button>
</div>
<input
className="kw-input"
type="search"
placeholder="输入型号 / 关键字筛选…"
value={kw}
onChange={(e) => {
setKw(e.target.value);
setGalleryPage(1);
}}
/>
<Button
variant="secondary"
className="filter-toggle"
onClick={() => setShowFilters((s) => !s)}
>
<IconFilter size={16} /> {activeFilterCount ? ` · ${activeFilterCount}` : ''}
</Button>
{data && <span className="result-count muted">{data.total} </span>}
</div>
{showFilters && (
<div className="filter-panel">
<FilterBar categories={categories} query={query} onChange={patch} />
</div>
)}
{error && <p className="error">{error}</p>}
{loading && <p className="muted"></p>}
{data && !loading && (
<>
{view === 'gallery' && (
<GalleryView
models={data.items}
collectedIds={collectedIds}
onToggle={toggle}
page={galleryPage}
onPage={setGalleryPage}
/>
)}
{view === 'timeline' && <TimelineView models={data.items} />}
</>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router-dom';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { api } from '../api/client';
import { PageHeader, EmptyState } from '../components/ui';
import type { Sighting, Spot } from '../types';
// OpenStreetMap 栅格瓦片(免费,需署名;遵守 OSM 瓦片使用政策)
const OSM_STYLE: maplibregl.StyleSpecification = {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors',
},
},
layers: [{ id: 'osm', type: 'raster', source: 'osm' }],
};
export function MapPage() {
const mapEl = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [sightings, setSightings] = useState<Sighting[]>([]);
const [spots, setSpots] = useState<Spot[]>([]);
useEffect(() => {
api.recentSightings(100).then(setSightings).catch(() => {});
api.spots().then(setSpots).catch(() => {});
}, []);
useEffect(() => {
if (!mapEl.current || mapRef.current) return;
const map = new maplibregl.Map({
container: mapEl.current,
style: OSM_STYLE,
center: [104.0, 35.5], // 中国中心
zoom: 3.2,
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
mapRef.current = map;
return () => {
map.remove();
mapRef.current = null;
};
}, []);
// 数据变化时绘制标记
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const markers: maplibregl.Marker[] = [];
for (const s of sightings) {
if (typeof s.lat !== 'number' || typeof s.lng !== 'number') continue;
const popup = new maplibregl.Popup({ offset: 18 }).setHTML(
`<strong>${s.model_code}</strong><br/>${s.station || '未知地点'}<br/>` +
`<span style="color:#888">${s.user_name} · ${s.car_number || ''}</span>`,
);
const m = new maplibregl.Marker({ color: '#4ea1ff' })
.setLngLat([s.lng, s.lat])
.setPopup(popup)
.addTo(map);
markers.push(m);
}
return () => markers.forEach((m) => m.remove());
}, [sightings]);
return (
<div className="page map-page">
<PageHeader title="打卡地图" subtitle="爱好者目击打卡的聚合地图 · 看看哪里能拍到什么车。" />
<div className="map-layout">
<div ref={mapEl} className="map-canvas" data-testid="map" />
<aside className="map-feed">
<h2></h2>
{sightings.length === 0 ? (
<p className="muted"></p>
) : (
<ul className="feed-list">
{sightings.map((s) => (
<li key={s.id}>
<Link to={`/models/${s.model_id}`} className="feed-item">
<b>{s.model_code}</b>
<span className="muted"> @ {s.station || '未知'}</span>
<div className="muted feed-sub">
{s.user_name} · {s.car_number || '—'}
</div>
</Link>
</li>
))}
</ul>
)}
</aside>
</div>
<section className="d-section spots-section">
<h2> · </h2>
{spots.length === 0 ? (
<p className="muted">"在哪能拍到什么车"</p>
) : (
<div className="spots">
{spots.slice(0, 12).map((s) => (
<div className="spot" key={s.station}>
<div className="spot-head">
<b>{s.station}</b>
<span className="muted">{s.total} </span>
</div>
<div className="spot-models">
{s.models.map((m) => (
<Link key={m.model_id} to={`/models/${m.model_id}`} className="chip">
{m.model_code}
{m.count > 1 ? ` ×${m.count}` : ''}
</Link>
))}
</div>
</div>
))}
</div>
)}
</section>
</div>
);
}
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { ROLE_LABEL, useAuth } from '../lib/auth';
import { useCollection } from '../lib/useCollection';
import { useFollow } from '../lib/useFollow';
import { Button } from '../components/ui';
import { IconTrophy, IconBack, IconForward } from '../components/icons';
import type { Sighting, UserStats } from '../types';
export function ProfilePage() {
const { user, logout } = useAuth();
const { count } = useCollection();
const { followIds } = useFollow();
const [stats, setStats] = useState<UserStats | null>(null);
const [alerts, setAlerts] = useState<Sighting[]>([]);
useEffect(() => {
if (user) api.auth.me().then(setStats).catch(() => {});
}, [user]);
useEffect(() => {
if (followIds.size === 0) {
setAlerts([]);
return;
}
api
.recentSightings(100)
.then((all) => setAlerts(all.filter((s) => followIds.has(s.model_id)).slice(0, 10)))
.catch(() => {});
}, [followIds]);
if (!user) {
return (
<div className="page">
<p className="muted"></p>
<Link to="/" className="back"><IconBack size={14} /> </Link>
</div>
);
}
const s = stats;
const pts = s?.contributionPoints ?? user.contributionPoints;
const next = s?.level.nextThreshold ?? null;
const min = s?.level.min ?? 0;
const pct = next ? Math.min(100, Math.round(((pts - min) / (next - min)) * 100)) : 100;
return (
<div className="page profile">
<div className="profile-head">
<div className="avatar">{user.displayName.slice(0, 1)}</div>
<div>
<h1>{user.displayName}</h1>
<span className="chip">{s?.level.title ?? ROLE_LABEL[user.role]}</span>
<span className="chip">{ROLE_LABEL[user.role] ?? user.role}</span>
<p className="muted">{user.email}</p>
</div>
<Button variant="secondary" onClick={logout}>退</Button>
</div>
<div className="level-bar">
<div className="level-bar-head">
<span> L{s?.level.level ?? 1} · {s?.level.title ?? '见习巡道员'}</span>
<span className="muted">
{next ? `${pts} / ${next} 升级` : `${pts} · 已满级`}
</span>
</div>
<div className="progress-track">
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
</div>
<div className="profile-stats">
<div><b>{pts}</b><span></span></div>
<div><b>{s?.approvedCount ?? 0}</b><span></span></div>
<div><b>{count}</b><span></span></div>
<div>
<b>{new Date(user.createdAt + 'Z').toLocaleDateString('zh-CN')}</b>
<span></span>
</div>
</div>
<h2></h2>
{s && s.badges.length > 0 ? (
<div className="badges">
{s.badges.map((b) => (
<span className="badge" key={b.key}><IconTrophy size={14} /> {b.label}</span>
))}
</div>
) : (
<p className="muted"></p>
)}
<p className="muted profile-foot">
<Link to="/leaderboard"> <IconForward size={13} /></Link>
</p>
<h2> · </h2>
{followIds.size === 0 ? (
<p className="muted">
</p>
) : alerts.length === 0 ? (
<p className="muted"> {followIds.size} </p>
) : (
<ul className="feed-list">
{alerts.map((s) => (
<li key={s.id}>
<Link to={`/models/${s.model_id}`} className="feed-item">
<b>{s.model_code}</b>
<span className="muted"> @ {s.station || '未知'} · {s.user_name}</span>
</Link>
</li>
))}
</ul>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../lib/auth';
import { PageHeader, EmptyState } from '../components/ui';
import { IconBack } from '../components/icons';
import type { Revision } from '../types';
import { RevisionList } from '../components/RevisionList';
const RANK: Record<string, number> = {
guest: 0, user: 1, trusted: 2, moderator: 3, admin: 4,
};
export function ReviewPage() {
const { user } = useAuth();
const [revs, setRevs] = useState<Revision[]>([]);
const [error, setError] = useState('');
const [msg, setMsg] = useState('');
const canReview = !!user && RANK[user.role] >= RANK.moderator;
const load = () => {
api.pendingRevisions().then(setRevs).catch((e) => setError(String(e)));
};
useEffect(() => {
if (canReview) load();
}, [canReview]);
if (!user)
return (
<div className="page">
<p className="muted"></p>
<Link to="/" className="back"><IconBack size={14} /> </Link>
</div>
);
if (!canReview)
return (
<div className="page">
<p className="muted"></p>
<Link to="/" className="back"><IconBack size={14} /> </Link>
</div>
);
const act = async (id: number, kind: 'approve' | 'reject') => {
try {
if (kind === 'approve') await api.approveRevision(id);
else await api.rejectRevision(id);
setMsg(kind === 'approve' ? '已通过' : '已驳回');
setRevs((rs) => rs.filter((r) => r.id !== id));
} catch (e) {
setError(String(e));
}
};
return (
<div className="page">
<PageHeader title="审核队列" subtitle="待审核的众包修改。通过后立即生效并为作者计分。" />
{error && <p className="error">{error}</p>}
{msg && <p className="muted">{msg}</p>}
<RevisionList
revisions={revs}
onApprove={(id) => act(id, 'approve')}
onReject={(id) => act(id, 'reject')}
/>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../lib/auth';
import { Button } from '../components/ui';
import { IconBack } from '../components/icons';
import type { Thread } from '../types';
export function ThreadPage() {
const { id } = useParams();
const { user } = useAuth();
const [thread, setThread] = useState<Thread | null>(null);
const [body, setBody] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (id) api.thread(Number(id)).then(setThread).catch((e) => setError(String(e)));
}, [id]);
const reply = async (e: React.FormEvent) => {
e.preventDefault();
if (!id) return;
setError('');
try {
const updated = await api.addReply(Number(id), body);
setThread(updated);
setBody('');
} catch (err) {
setError(err instanceof Error ? err.message : '回复失败');
}
};
if (error) return <p className="error">{error}</p>;
if (!thread) return <p className="muted"></p>;
const fmt = (s: string) => new Date(s + 'Z').toLocaleString('zh-CN');
return (
<div className="page thread-page">
<Link to={thread.model_id ? `/models/${thread.model_id}` : '/community'} className="back">
<IconBack size={14} />
</Link>
<h1>{thread.title}</h1>
<div className="post op">
<div className="post-head">
<b>{thread.author_name}</b>
<span className="muted">{fmt(thread.created_at)}</span>
</div>
<p className="post-body">{thread.body}</p>
</div>
<h2>{thread.reply_count} </h2>
<div className="replies">
{thread.replies?.map((r) => (
<div className="post" key={r.id}>
<div className="post-head">
<b>{r.author_name}</b>
<span className="muted">{fmt(r.created_at)}</span>
</div>
<p className="post-body">{r.body}</p>
</div>
))}
</div>
{user ? (
<form className="reply-form" onSubmit={reply}>
<textarea
placeholder="写下你的回复…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
required
/>
{error && <p className="error">{error}</p>}
<Button variant="primary" type="submit"></Button>
</form>
) : (
<p className="muted"></p>
)}
</div>
);
}
+836
View File
@@ -0,0 +1,836 @@
:root {
--bg: #0d0f14;
--panel: #161922;
--panel2: #1e222c;
--line: #2a2f3a;
--line-strong: #3a4150;
--text: #e8eaee;
--muted: #8b93a1;
--accent: #4ea1ff;
--accent-press: #3b8ae6;
--green: #4ade80;
--amber: #fbbf24;
--danger: #f87171;
/* 间距刻度 */
--s1: 4px; --s2: 8px; --s3: 12px; --s4: 16px; --s5: 24px; --s6: 32px; --s7: 48px;
/* 圆角 */
--r1: 8px; --r2: 12px; --r3: 16px; --rpill: 999px;
/* 阴影 */
--shadow1: 0 1px 3px rgba(0,0,0,.3);
--shadow2: 0 8px 24px -8px rgba(0,0,0,.5);
--ring: 0 0 0 3px rgba(78,161,255,.25);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
:focus-visible { outline: none; box-shadow: var(--ring); border-radius: var(--r1); }
a { color: inherit; text-decoration: none; }
.app { max-width: 1200px; margin: 0 auto; padding: 0 16px 48px; }
.topbar {
display: flex; align-items: center; gap: 16px;
padding: 12px 0; position: sticky; top: 0; background: rgba(13,15,20,.85);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--line); z-index: 50;
}
.brand { font-size: 19px; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; }
.brand svg { color: var(--accent); }
.mainnav { display: flex; align-items: center; gap: 2px; margin-left: 8px; }
.nav-top {
display: inline-flex; align-items: center; gap: 4px;
padding: 7px 14px; border-radius: var(--rpill); font-size: 14px; color: var(--muted);
background: none; border: none; cursor: pointer; font-family: inherit;
}
.nav-top:hover { color: var(--text); background: var(--panel2); }
.nav-top.active { color: var(--text); }
a.nav-top.active { background: var(--panel2); }
.nav-trigger .caret { font-size: 10px; opacity: .7; }
.nav-group { position: relative; }
.nav-dropdown {
position: absolute; top: calc(100% + 6px); left: 0; min-width: 160px; z-index: 60;
background: var(--panel); border: 1px solid var(--line); border-radius: var(--r2);
box-shadow: var(--shadow2); padding: 6px; display: flex; flex-direction: column;
animation: pop .12s ease-out;
}
@keyframes pop { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.nav-item { padding: 9px 12px; border-radius: var(--r1); font-size: 14px; color: var(--text); white-space: nowrap; }
.nav-item:hover { background: var(--panel2); }
.nav-item.active { color: var(--accent); }
.searchbox { position: relative; margin-left: auto; width: 300px; max-width: 38%; }
.searchbox input {
width: 100%; padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel); color: var(--text);
}
.search-dropdown {
position: absolute; top: 110%; left: 0; right: 0; margin: 0; padding: 6px;
list-style: none; background: var(--panel2); border: 1px solid var(--line);
border-radius: 8px; max-height: 320px; overflow: auto;
}
.search-dropdown li { padding: 8px 10px; border-radius: 6px; cursor: pointer; }
.search-dropdown li:hover { background: var(--panel); }
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin: 16px 0; }
.filterbar { display: flex; flex-wrap: wrap; gap: 8px; }
.filterbar select, .filterbar input {
padding: 6px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text);
}
.filterbar input { width: 92px; }
.viewswitch { margin-left: auto; display: flex; gap: 4px; }
.viewswitch button, .pagination button {
padding: 6px 12px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text); cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
}
.viewswitch button.active { background: var(--accent); border-color: var(--accent); color: #06121f; }
.grid {
display: grid; gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
}
.gallery {
display: grid; gap: 14px;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.card {
display: block; background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 14px; transition: border-color .15s, transform .15s;
}
.card:hover { border-color: var(--accent); transform: translateY(-2px); }
.card-head { display: flex; justify-content: space-between; align-items: center; }
.card-code { font-size: 18px; font-weight: 700; }
.card-cat { color: var(--muted); font-size: 12px; margin: 6px 0 10px; }
.card-spec { display: grid; grid-template-columns: 1fr 1fr; gap: 6px 12px; margin: 0; }
.card-spec dt { color: var(--muted); font-size: 11px; }
.card-spec dd { margin: 0; font-size: 14px; }
.card-maker { margin-top: 10px; font-size: 12px; color: var(--muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); }
.tag-green { color: #4ade80; border-color: #2e6b46; }
.tag-amber { color: #fbbf24; border-color: #6b552e; }
.tag-gray { color: #9aa3b2; }
.tag-blue { color: var(--accent); border-color: #2e4d6b; }
.tag-purple { color: #c084fc; border-color: #4d2e6b; }
.pagination { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 24px 0; }
.muted { color: var(--muted); }
.error { color: #f87171; }
/* 时间轴 */
.timeline { margin-top: 12px; }
.timeline-axis { position: relative; height: 24px; margin-left: 96px; border-bottom: 1px solid var(--line); }
.timeline-axis .tick { position: absolute; transform: translateX(-50%); font-size: 11px; color: var(--muted); }
.lane { display: flex; align-items: center; margin: 10px 0; }
.lane-label { width: 96px; flex: none; font-size: 13px; color: var(--muted); }
.lane-track { position: relative; height: 34px; flex: 1; background: var(--panel); border-radius: 8px; }
.node { position: absolute; transform: translateX(-50%);
display: flex; flex-direction: column; align-items: center; gap: 2px;
background: none; border: none; cursor: pointer; color: var(--text); z-index: 1; }
.node:hover { z-index: 10; }
.node-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
.node-label { font-size: 10px; white-space: nowrap; opacity: 0; transition: opacity .1s; pointer-events: none; }
.node:hover .node-label { opacity: 1; }
/* 图鉴 */
.collect-progress { display: flex; align-items: center; gap: 12px; margin: 4px 0 16px; font-size: 13px; color: var(--muted); }
.collect-progress strong { color: #4ade80; }
.progress-track { flex: 1; height: 8px; background: var(--panel2); border-radius: 999px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #2e7d52, #4ade80); transition: width .25s; }
/* 收藏卡:trading-card 比例 + 满幅图 + 浮层标题,与列表参数卡形态区分 */
.gcard {
position: relative; border-radius: 14px; overflow: hidden;
aspect-ratio: 3 / 4; background: #0c0e12;
border: 1px solid var(--line);
transition: transform .15s, box-shadow .2s, border-color .2s;
}
.gcard:hover { transform: translateY(-4px) scale(1.015); border-color: var(--accent); }
.gcard.collected {
border-color: #3fae6e;
box-shadow: 0 0 0 1px #3fae6e66, 0 0 18px -4px #3fae6e99;
}
.gcard-art { position: absolute; inset: 0; display: block; }
.gcard-art img {
width: 100%; height: 100%; object-fit: cover; display: block;
filter: grayscale(0.85) brightness(0.45) contrast(0.95); transition: filter .25s, transform .25s;
}
.gcard:hover .gcard-art img { transform: scale(1.06); }
.gcard.collected .gcard-art img { filter: none; }
.gcard.bright .gcard-art img { filter: none; }
/* 缩略图(渐变 + 专业图标) */
.thumb { width: 100%; height: 100%; display: grid; place-items: center; }
.thumb-glyph { color: #fff; opacity: .9; }
.gcard .thumb-glyph { opacity: .42; transition: opacity .25s, transform .25s; }
.gcard:hover .thumb-glyph { transform: scale(1.08); }
.gcard.collected .thumb-glyph { opacity: .95; }
/* 编号徽章(图鉴序号) */
.gcard-no {
position: absolute; top: 8px; left: 8px; z-index: 3;
font-size: 11px; font-weight: 700; letter-spacing: .5px;
padding: 2px 7px; border-radius: 999px;
background: rgba(0,0,0,.55); color: #cdd5e0; backdrop-filter: blur(2px);
}
/* 收集星标按钮(角标式) */
.gcard-collect {
position: absolute; top: 6px; right: 6px; z-index: 3;
width: 30px; height: 30px; display: grid; place-items: center;
border-radius: 50%; cursor: pointer;
border: 1px solid rgba(255,255,255,.18); background: rgba(0,0,0,.5);
color: #e6e8ec; backdrop-filter: blur(2px); transition: background .15s, color .15s;
}
.gcard-collect:hover { background: rgba(0,0,0,.75); }
.gcard.collected .gcard-collect { background: #1d3a2a; border-color: #3fae6e; color: #ffd55a; }
/* 底部名称浮层 */
.gcard-caption {
position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
padding: 22px 10px 10px; display: flex; flex-direction: column; gap: 2px;
background: linear-gradient(to top, rgba(0,0,0,.82), transparent);
}
.gcard .gcard-code { font-size: 15px; font-weight: 700; color: #fff; }
.gcard .gcard-meta { font-size: 11px; color: #c2c9d4; }
/* 详情 */
.detail { padding-top: 16px; max-width: 920px; margin: 0 auto; }
.back { color: var(--accent); font-size: 13px; background: none; border: none; padding: 0; cursor: pointer; font-family: inherit; display: inline-flex; align-items: center; gap: 4px; }
.back:hover { text-decoration: underline; }
.detail-hero {
display: flex; gap: 20px; align-items: stretch; margin: 14px 0 28px;
background: var(--panel); border: 1px solid var(--line); border-radius: 16px;
overflow: hidden;
}
.detail-thumb { width: 200px; flex: none; }
.detail-thumb .thumb { height: 100%; min-height: 150px; }
.detail-thumb .thumb-glyph { opacity: .9; }
.detail-hero-img { width: 100%; height: 100%; min-height: 150px; object-fit: cover; display: block; }
.detail-head { padding: 18px 18px 18px 0; display: flex; flex-direction: column; gap: 8px; }
.detail-title { display: flex; align-items: center; gap: 10px; }
.detail-title svg { color: var(--accent); }
.detail-title h1 { margin: 0; font-size: 28px; }
.detail-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.detail-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
.chip {
font-size: 12px; padding: 3px 10px; border-radius: 999px;
background: var(--panel2); border: 1px solid var(--line); color: #c2c9d4;
display: inline-flex; align-items: center; gap: 4px;
}
.d-section { margin: 0 0 26px; }
.d-section h2 { font-size: 18px; margin: 0 0 12px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
.d-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 18px; margin: 0; }
.d-row { display: flex; justify-content: space-between; gap: 12px; padding: 8px 0; border-bottom: 1px dashed var(--line); }
.d-row dt { color: var(--muted); font-size: 13px; }
.d-row dd { margin: 0; font-size: 14px; text-align: right; }
.d-row.empty dd { color: var(--muted); }
.foot { margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 12px; text-align: center; }
@media (max-width: 640px) {
.detail-hero { flex-direction: column; }
.detail-thumb { width: 100%; }
.detail-head { padding: 0 16px 16px; }
}
/* ===== 叙事首页 ===== */
.home { padding-bottom: 40px; }
/* 英雄区 + 时速演进 并排一行 */
.hero-row {
display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
gap: 32px; align-items: center; padding: 40px 0 32px;
background:
radial-gradient(900px 400px at 25% -10%, #1b3654aa, transparent),
radial-gradient(700px 360px at 85% 120%, #14352f88, transparent);
}
.hero-row .hero { padding: 0; background: none; text-align: left; }
.hero-row .hero-sub { margin-left: 0; margin-right: 0; }
.hero-row .hero-stats { justify-content: flex-start; }
.hero-evo { min-width: 0; }
.hero-evo h2 { font-size: 22px; margin: 0 0 6px; }
.hero-evo .eras-intro { text-align: left; max-width: none; margin: 0 0 10px; }
@media (max-width: 860px) {
.hero-row { grid-template-columns: 1fr; gap: 20px; }
.hero-row .hero, .hero-row .hero-evo, .hero-row .hero-evo .eras-intro { text-align: center; }
.hero-row .hero-stats { justify-content: center; }
}
.hero {
text-align: center; padding: 48px 16px 36px;
background:
radial-gradient(900px 400px at 50% -10%, #1b3654aa, transparent),
radial-gradient(700px 360px at 50% 120%, #14352f88, transparent);
}
.hero h1 { font-size: 36px; line-height: 1.2; margin: 0 0 12px; letter-spacing: 1px; }
.hero-sub { max-width: 640px; margin: 0 auto 20px; color: #c2c9d4; font-size: 16px; line-height: 1.6; }
.hero-sub strong { color: var(--accent); }
.hero-stats { display: flex; flex-wrap: wrap; justify-content: center; gap: 28px; margin-bottom: 24px; }
.hero-stats div { display: flex; flex-direction: column; }
.hero-stats b { font-size: 26px; color: #fff; }
.hero-stats span { font-size: 12px; color: var(--muted); margin-top: 2px; }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px; padding: 12px 26px; border-radius: 999px;
background: var(--accent); color: #06121f; font-weight: 700; font-size: 15px;
transition: transform .15s, box-shadow .2s;
}
.hero-cta:hover { transform: translateY(-2px); box-shadow: 0 8px 24px -8px var(--accent); }
.evo-section { max-width: 820px; margin: 36px auto; text-align: center; padding: 0 16px; }
.evo-section h2, .eras h2 { font-size: 24px; margin: 0 0 8px; }
.evo { width: 100%; height: auto; margin-top: 14px;
background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 8px; }
.eras-intro { text-align: center; max-width: 680px; margin: 0 auto 6px; line-height: 1.6; font-size: 14px; }
.eras-evo { max-width: 820px; margin: 0 auto 8px; }
.eras { max-width: none; margin: 32px 0 0; padding: 0; }
.eras > h2 { text-align: center; }
.era-rail { display: flex; flex-direction: column; gap: 18px; margin-top: 20px; }
.era {
position: relative; padding: 18px 18px 18px 22px; border-radius: 16px;
background: var(--panel); border: 1px solid var(--line);
border-left: 4px solid var(--accent);
}
.era-head { display: flex; align-items: center; gap: 14px; }
.era-icon {
font-size: 24px; width: 46px; height: 46px; flex: none;
display: grid; place-items: center; border-radius: 12px;
background: color-mix(in srgb, var(--accent) 22%, transparent);
}
.era-head h3 { margin: 0; font-size: 19px; display: flex; align-items: center; gap: 10px; }
.era-step {
display: inline-grid; place-items: center; width: 24px; height: 24px;
border-radius: 50%; font-size: 13px; background: var(--accent); color: #06121f;
}
.era-period { font-size: 12px; color: var(--muted); }
.era-blurb { color: #c2c9d4; line-height: 1.6; margin: 10px 0 12px; max-width: 760px; font-size: 14px; }
.era-reps {
display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
margin-bottom: 12px;
}
.mini {
display: flex; flex-direction: column; border-radius: 10px; overflow: hidden;
background: #0c0e12; border: 1px solid var(--line); transition: transform .15s, border-color .15s;
}
.mini:hover { transform: translateY(-3px); border-color: var(--accent); }
.mini img { width: 100%; aspect-ratio: 5/3; object-fit: cover; }
.mini .thumb { width: 100%; aspect-ratio: 5/3; }
.mini-code { font-weight: 700; font-size: 14px; padding: 8px 10px 0; }
.mini-meta { font-size: 11px; color: var(--muted); padding: 2px 10px 10px; }
.era-more { display: inline-flex; align-items: center; gap: 4px; color: var(--accent); font-size: 14px; font-weight: 600; }
/* ===== 探索页工具条 ===== */
.explore-bar { display: flex; align-items: center; gap: 12px; margin: 18px 0; flex-wrap: wrap; }
.filter-toggle {
padding: 6px 14px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text); cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
}
.result-count { margin-left: auto; font-size: 13px; }
.kw-input {
min-width: 200px; flex: 1 1 200px; max-width: 320px;
padding: 6px 12px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel); color: var(--text); font-size: 13px;
}
.kw-input:focus { outline: none; border-color: var(--accent); }
.filter-panel {
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
padding: 14px; margin-bottom: 18px;
}
@media (max-width: 640px) {
.hero h1 { font-size: 30px; }
.hero-stats { gap: 22px; }
.mainnav { display: none; }
}
/* ===== 详情图册 ===== */
.gallery-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px;
padding-bottom: 8px; border-bottom: 1px solid var(--line); }
.gallery-head h2 { margin: 0; border: none; padding: 0; }
.add-img { margin-left: auto; padding: 6px 14px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--accent); background: transparent; color: var(--accent); font-size: 13px; }
.add-img:hover { background: var(--accent); color: #06121f; }
.img-empty { font-size: 13px; line-height: 1.7; }
.img-grid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.img-cell { position: relative; aspect-ratio: 4/3; border-radius: 10px; overflow: hidden;
border: 1px solid var(--line); background: var(--panel2); }
.img-cell img { width: 100%; height: 100%; object-fit: cover; cursor: zoom-in; display: block;
transition: transform .2s; }
.img-cell img:hover { transform: scale(1.05); }
.img-del { position: absolute; top: 6px; right: 6px; width: 24px; height: 24px;
border-radius: 50%; border: none; cursor: pointer; background: rgba(0,0,0,.6);
color: #fff; font-size: 12px; line-height: 1; opacity: 0; transition: opacity .15s; }
.img-cell:hover .img-del { opacity: 1; }
.img-badge { position: absolute; bottom: 6px; left: 6px; font-size: 10px;
padding: 2px 7px; border-radius: 999px; color: #fff; background: rgba(0,0,0,.55);
backdrop-filter: blur(2px); }
.badge-commons { background: rgba(46,109,174,.8); }
.badge-local { background: rgba(63,174,110,.8); }
.badge-photo { background: rgba(63,174,110,.85); }
.badge-sample { background: rgba(0,0,0,.5); }
.img-cell:has(.img-badge:where(.badge-photo)) {}
.img-admin { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; opacity: 0; transition: opacity .15s; }
.img-cell:hover .img-admin { opacity: 1; }
.img-admin button { width: 24px; height: 24px; border-radius: 50%; border: none; cursor: pointer;
color: #fff; font-size: 12px; line-height: 1; }
.img-confirm { background: rgba(46,140,80,.9); }
.img-confirm:hover { background: #2e8c50; }
.img-feature { background: rgba(0,0,0,.6); color: #cfd6e0; }
.img-feature:hover { background: rgba(0,0,0,.78); color: #ffd55a; }
.img-feature.on { background: #5a4410; color: #ffd55a; box-shadow: 0 0 0 1px #ffd55a99; }
.img-admin .img-del { position: static; opacity: 1; width: 24px; height: 24px; }
.img-hint { font-size: 12px; margin-top: 10px; line-height: 1.6; }
/* ===== 灯箱 ===== */
.lb { position: fixed; inset: 0; z-index: 100; background: rgba(0,0,0,.86);
display: grid; place-items: center; }
.lb-stage { position: relative; width: 92vw; height: 88vh; display: grid; place-items: center; overflow: hidden; }
.lb-img { max-width: 92vw; max-height: 80vh; object-fit: contain; user-select: none;
transition: transform .05s linear; }
.lb-close { position: absolute; top: 8px; right: 8px; width: 40px; height: 40px;
border-radius: 50%; border: none; cursor: pointer; background: rgba(255,255,255,.12);
color: #fff; font-size: 18px; }
.lb-close:hover { background: rgba(255,255,255,.25); }
.lb-nav { position: absolute; top: 50%; transform: translateY(-50%); width: 48px; height: 64px;
border: none; cursor: pointer; background: rgba(255,255,255,.1); color: #fff; font-size: 32px;
border-radius: 8px; }
.lb-nav:hover { background: rgba(255,255,255,.22); }
.lb-prev { left: 4px; } .lb-next { right: 4px; }
.lb-toolbar { position: absolute; bottom: 44px; left: 50%; transform: translateX(-50%);
display: flex; align-items: center; gap: 10px; padding: 6px 12px; border-radius: 999px;
background: rgba(0,0,0,.6); color: #fff; font-size: 13px; }
.lb-toolbar button { width: 30px; height: 30px; border-radius: 50%; border: 1px solid rgba(255,255,255,.25);
background: transparent; color: #fff; cursor: pointer; }
.lb-toolbar button:last-child { width: auto; padding: 0 12px; border-radius: 999px; }
.lb-caption { position: absolute; bottom: 8px; left: 50%; transform: translateX(-50%);
color: #cdd5e0; font-size: 12px; display: flex; flex-direction: column; align-items: center; gap: 2px; max-width: 90vw; text-align: center; }
.lb-attr { color: #9aa3b2; font-size: 11px; }
.lb-attr a { color: var(--accent); }
/* ===== 账户:登录按钮 / 用户菜单 ===== */
.login-btn { padding: 7px 14px; border-radius: 999px; cursor: pointer; font-size: 14px;
border: 1px solid var(--accent); background: transparent; color: var(--accent); white-space: nowrap; }
.login-btn:hover { background: var(--accent); color: #06121f; }
.user-chip { display: inline-flex; align-items: center; gap: 8px; padding: 4px 12px 4px 4px;
border-radius: 999px; background: var(--panel2); border: 1px solid var(--line); }
.user-chip:hover { border-color: var(--accent); }
.user-avatar { width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
background: var(--accent); color: #06121f; font-weight: 700; }
.user-name { font-size: 14px; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ===== 模态框 ===== */
.modal-backdrop { position: fixed; inset: 0; z-index: 90; background: rgba(0,0,0,.6);
display: grid; place-items: center; }
.modal { position: relative; width: 360px; max-width: 92vw; background: var(--panel);
border: 1px solid var(--line); border-radius: 16px; padding: 22px; }
.modal-tabs { display: flex; gap: 6px; margin-bottom: 16px; }
.modal-tabs button { flex: 1; padding: 8px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--panel2); color: var(--muted); }
.modal-tabs button.active { background: var(--accent); border-color: var(--accent); color: #06121f; font-weight: 700; }
.auth-form { display: flex; flex-direction: column; gap: 10px; }
.auth-form input { padding: 10px 12px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel2); color: var(--text); }
.auth-submit { padding: 10px; border-radius: 8px; border: none; cursor: pointer;
background: var(--accent); color: #06121f; font-weight: 700; }
.auth-submit:disabled { opacity: .6; cursor: default; }
.modal-close { position: absolute; top: 10px; right: 10px; width: 30px; height: 30px;
border-radius: 50%; border: none; cursor: pointer; background: var(--panel2); color: var(--text); }
.test-accounts { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line); }
.test-accounts > .muted { font-size: 12px; }
.test-account-btns { display: flex; gap: 8px; margin-top: 10px; }
.test-account { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px;
padding: 8px 6px; border-radius: 8px; cursor: pointer; border: 1px solid var(--line);
background: var(--panel2); color: var(--text); }
.test-account:hover { border-color: var(--accent); }
.test-account b { font-size: 13px; }
.test-account small { font-size: 10px; color: var(--muted); }
/* ===== 个人主页 ===== */
.profile-head { display: flex; align-items: center; gap: 16px; margin: 16px 0 24px; }
.profile-head .avatar { width: 64px; height: 64px; border-radius: 50%; flex: none;
display: grid; place-items: center; font-size: 28px; font-weight: 700;
background: var(--accent); color: #06121f; }
.profile-head h1 { margin: 0 0 6px; font-size: 24px; }
.profile-head > button { margin-left: auto; }
.profile-stats { display: flex; gap: 32px; padding: 18px 0; border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line); margin-bottom: 18px; }
.profile-stats div { display: flex; flex-direction: column; }
.profile-stats b { font-size: 24px; }
.profile-stats span { font-size: 12px; color: var(--muted); }
/* ===== 编辑 / 修订 ===== */
.edit-btn { margin-top: 10px; align-self: flex-start; padding: 7px 14px; border-radius: 8px;
cursor: pointer; border: 1px solid var(--accent); background: transparent; color: var(--accent); font-size: 13px; }
.edit-btn:hover { background: var(--accent); color: #06121f; }
.notice { background: #14352f; border: 1px solid #2e7d52; color: #8af0b4;
padding: 10px 14px; border-radius: 10px; margin: 0 0 18px; }
.modal-wide { width: 560px; max-width: 94vw; }
.edit-hint { font-size: 12px; margin: 4px 0 14px; }
.edit-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; }
.edit-field { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.edit-field span { color: var(--muted); }
.edit-field input, .edit-field select { padding: 8px 10px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel2); color: var(--text); }
.edit-note { margin-top: 12px; width: 100%; padding: 9px 12px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel2); color: var(--text); }
.edit-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
.edit-actions button { padding: 9px 16px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--panel2); color: var(--text); }
.edit-actions .auth-submit { border: none; }
.rev-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 12px; }
.rev-item { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
.rev-head { display: flex; align-items: center; gap: 10px; }
.rev-author { font-weight: 600; }
.rev-date { font-size: 12px; margin-left: auto; }
.rev-note { font-size: 13px; color: #c2c9d4; margin: 8px 0 6px; }
.rev-changes { list-style: none; padding: 0; margin: 6px 0 0; font-size: 13px; display: flex; flex-direction: column; gap: 4px; }
.rev-changes .old { color: #f0a3a3; text-decoration: line-through; }
.rev-changes .new { color: #8af0b4; }
.rev-actions { display: flex; gap: 10px; margin-top: 12px; }
.rev-actions button { padding: 7px 16px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--panel2); color: var(--text); }
.rev-actions .auth-submit { border: none; }
@media (max-width: 640px) { .edit-grid { grid-template-columns: 1fr; } }
/* ===== 荣誉:等级条 / 徽章 / 贡献榜 / 维护者 ===== */
.level-bar { margin: 4px 0 20px; }
.level-bar-head { display: flex; justify-content: space-between; font-size: 14px; margin-bottom: 6px; }
.badges { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 16px; }
.badge { font-size: 13px; padding: 6px 12px; border-radius: 999px;
background: linear-gradient(135deg, #2a3550, #1c2233); border: 1px solid var(--line);
display: inline-flex; align-items: center; gap: 5px; }
.profile-foot { margin-top: 18px; }
.profile-foot a { color: var(--accent); display: inline-flex; align-items: center; gap: 4px; }
.maintainers { display: flex; flex-wrap: wrap; gap: 8px; }
.leaderboard { list-style: none; padding: 0; margin: 16px 0 0; display: flex; flex-direction: column; gap: 8px; }
.lb-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; }
.lb-rank { width: 28px; height: 28px; flex: none; display: grid; place-items: center;
border-radius: 50%; background: var(--panel2); font-weight: 700; font-size: 13px; }
.rank-1 .lb-rank { background: #caa23a; color: #1b1500; }
.rank-2 .lb-rank { background: #aab4c2; color: #14181f; }
.rank-3 .lb-rank { background: #b07a48; color: #160d04; }
.lb-name { font-weight: 600; }
.lb-role { margin-left: auto; font-size: 12px; }
.lb-points { font-size: 18px; font-weight: 700; color: var(--accent); min-width: 48px; text-align: right; }
/* ===== 登录测试账号一键填入 ===== */
.test-accounts { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--line); }
.test-accounts > .muted { font-size: 12px; }
.test-account-btns { display: flex; gap: 8px; margin-top: 8px; }
.test-account { flex: 1; display: flex; flex-direction: column; align-items: flex-start;
gap: 2px; padding: 8px 10px; border-radius: 8px; cursor: pointer;
border: 1px solid var(--line); background: var(--panel2); color: var(--text); font-size: 13px; }
.test-account:hover { border-color: var(--accent); }
.test-account small { color: var(--muted); font-size: 11px; }
/* ===== 社区 / 论坛 ===== */
.board-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0; }
.board-tabs button { padding: 7px 14px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--line); background: var(--panel); color: var(--muted); }
.board-tabs button.active { background: var(--accent); border-color: var(--accent); color: #06121f; }
.new-thread-btn { margin-left: auto; color: var(--accent) !important; border-color: var(--accent) !important; }
.new-thread { display: flex; flex-direction: column; gap: 10px; margin: 0 0 18px;
background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; }
.new-thread input, .new-thread textarea, .reply-form textarea {
padding: 9px 12px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel2); color: var(--text); font: inherit; resize: vertical; }
.new-thread .auth-submit, .reply-form .auth-submit { align-self: flex-start; padding: 8px 18px; border: none; }
.thread-list { list-style: none; padding: 0; margin: 12px 0 0; display: flex; flex-direction: column; gap: 8px; }
.thread-row { display: flex; align-items: center; gap: 12px; padding: 12px 16px;
background: var(--panel); border: 1px solid var(--line); border-radius: 10px; }
.thread-row:hover { border-color: var(--accent); }
.thread-title { font-weight: 600; }
.thread-meta { margin-left: auto; font-size: 12px; }
.thread-page .post { background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 14px; margin-bottom: 10px; }
.thread-page .post.op { border-left: 3px solid var(--accent); }
.post-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.post-head .muted { font-size: 12px; }
.post-body { margin: 0; white-space: pre-wrap; line-height: 1.6; }
.replies { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.reply-form { display: flex; flex-direction: column; gap: 10px; }
/* ===== 打卡地图 / 动态流 ===== */
.map-layout { display: grid; grid-template-columns: 1fr 300px; gap: 14px; margin-top: 14px; }
.map-canvas { height: 540px; border-radius: 12px; overflow: hidden; border: 1px solid var(--line); }
.map-feed { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px; max-height: 540px; overflow: auto; }
.map-feed h2 { margin-top: 0; font-size: 16px; }
.feed-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; }
.feed-item { display: block; padding: 8px 10px; border-radius: 8px; background: var(--panel2); }
.feed-item:hover { outline: 1px solid var(--accent); }
.feed-sub { font-size: 12px; margin-top: 2px; }
.sighting-row { padding: 8px 0; border-bottom: 1px dashed var(--line); font-size: 14px; }
.checkin-form { margin-top: 14px; }
.checkin-row { display: flex; gap: 8px; flex-wrap: wrap; }
.checkin-row input { flex: 1; min-width: 120px; padding: 9px 12px; border-radius: 8px;
border: 1px solid var(--line); background: var(--panel2); color: var(--text); }
.loc-btn { padding: 9px 14px; border-radius: 8px; cursor: pointer; white-space: nowrap;
border: 1px solid var(--line); background: var(--panel2); color: var(--text);
display: inline-flex; align-items: center; gap: 4px; }
.map-link { align-self: flex-start; display: inline-flex; align-items: center; gap: 4px; }
/* MapLibre 弹窗在深色背景下的文字色 */
.maplibregl-popup-content { color: #14181f; }
@media (max-width: 760px) { .map-layout { grid-template-columns: 1fr; } .map-canvas { height: 360px; } }
/* ===== 参数对比 ===== */
.cmp-add-btn { display: inline-block; margin-top: 10px; }
.cmp-add { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-start; margin: 16px 0; }
.cmp-search { width: 280px; margin: 0; }
.cmp-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.cmp-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px;
border-radius: 999px; border: 1px solid var(--line); background: var(--panel); font-size: 13px; }
.cmp-chip i { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.cmp-chip button { border: none; background: none; color: var(--muted); cursor: pointer; }
.cmp-body { display: grid; grid-template-columns: 360px 1fr; gap: 20px; align-items: start; }
.radar { width: 360px; height: 360px; }
.cmp-table-wrap { overflow-x: auto; }
.cmp-table { width: 100%; border-collapse: collapse; }
.cmp-table th, .cmp-table td { padding: 10px 12px; border-bottom: 1px solid var(--line);
text-align: left; font-size: 14px; }
.cmp-table thead th { font-size: 15px; }
.cmp-table tbody th { color: var(--muted); font-weight: 500; }
@media (max-width: 760px) { .cmp-body { grid-template-columns: 1fr; } .radar { margin: 0 auto; } }
/* ===== 技术族谱 ===== */
.family { display: flex; flex-direction: column; gap: 14px; margin-top: 14px; }
.family-series { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.family-series-name { font-weight: 700; margin-bottom: 10px; }
.family-track { display: flex; flex-wrap: wrap; gap: 12px; }
.family-node { position: relative; display: flex; flex-direction: column;
border-radius: var(--r2); background: var(--panel2); border: 1px solid var(--line);
width: 150px; overflow: hidden; }
.family-node:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow2); }
.fn-thumb { display: block; aspect-ratio: 5/3; }
.fn-thumb .thumb { width: 100%; height: 100%; }
.fn-code { font-weight: 700; font-size: 14px; padding: 8px 10px 0; }
.fn-meta { font-size: 11px; color: var(--muted); padding: 2px 10px 10px; }
.fn-flag { position: absolute; top: 6px; right: 6px; font-size: 10px; padding: 1px 6px;
border-radius: var(--rpill); background: #4a2e6b; color: #d6b8ff; }
/* ===== 拍车攻略 ===== */
.spots-section { margin-top: 24px; }
.spots { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.spot { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.spot-head { display: flex; justify-content: space-between; margin-bottom: 8px; }
.spot-models { display: flex; flex-wrap: wrap; gap: 6px; }
/* ===== 数据大屏 ===== */
.dashboard { padding: 8px 0 40px; }
.dash-topbar {
display: flex; align-items: center; justify-content: space-between; gap: 16px;
padding: 16px 22px; margin: 8px 0 20px; border-radius: 16px;
background:
radial-gradient(700px 200px at 0% 0%, rgba(78,161,255,.18), transparent),
radial-gradient(700px 200px at 100% 100%, rgba(43,179,154,.16), transparent),
var(--panel);
border: 1px solid var(--line-strong); position: relative; overflow: hidden;
}
.dash-topbar::after {
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
background: linear-gradient(90deg, #4ea1ff, #2bb39a, #fbbf24, transparent);
}
.dash-title { display: flex; align-items: center; gap: 14px; }
.dash-title svg { color: var(--accent); }
.dash-title h1 { margin: 0; font-size: 24px; letter-spacing: 1px; }
.dash-sub { font-size: 11px; color: var(--muted); letter-spacing: 3px; }
.dash-clock { text-align: right; font-variant-numeric: tabular-nums; }
.dash-clock b { font-size: 26px; color: #fff; letter-spacing: 1px; display: block; }
.dash-clock span { font-size: 12px; color: var(--muted); }
.kpi-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 14px; margin: 0 0 20px; }
.kpi {
position: relative; background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
padding: 16px 18px; display: flex; align-items: center; gap: 14px; overflow: hidden;
}
.kpi::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px;
background: var(--kpi, var(--accent));
}
.kpi-icon {
width: 44px; height: 44px; flex: none; display: grid; place-items: center; border-radius: 12px;
color: var(--kpi, var(--accent));
background: color-mix(in srgb, var(--kpi, var(--accent)) 16%, transparent);
}
.kpi-body { display: flex; flex-direction: column; min-width: 0; }
.kpi-body b { font-size: 30px; line-height: 1.1; color: #fff; font-variant-numeric: tabular-nums; }
.kpi-body span { font-size: 12px; color: var(--muted); white-space: nowrap; }
.dash-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.dash-card {
background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px;
}
.dash-card.span-2 { grid-column: 1 / -1; }
.dash-card-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.dash-card-head h2 { margin: 0; font-size: 16px; }
.dash-tag {
font-size: 11px; color: var(--muted); padding: 3px 10px; border-radius: 999px;
background: var(--panel2); border: 1px solid var(--line);
}
/* 环形图 */
.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.donut { width: 180px; height: 180px; flex: none; filter: drop-shadow(0 4px 12px rgba(0,0,0,.4)); }
.donut-total { fill: #fff; font-size: 30px; font-weight: 700; }
.donut-total-label { fill: var(--muted); font-size: 12px; letter-spacing: 2px; }
.donut-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 160px;
display: flex; flex-direction: column; gap: 7px; }
.donut-legend li { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.donut-legend i { width: 11px; height: 11px; border-radius: 3px; flex: none; }
.donut-legend .dl-label { flex: 1; color: #c2c9d4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.donut-legend b { color: #fff; font-variant-numeric: tabular-nums; }
.donut-legend .dl-pct { width: 40px; text-align: right; }
/* 纵向柱状图 */
.vbars { display: flex; align-items: flex-end; gap: 10px; height: 200px; padding-top: 8px; }
.vbar-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; gap: 6px; }
.vbar-val { font-size: 12px; color: #c2c9d4; font-variant-numeric: tabular-nums; }
.vbar-track { flex: 1; width: 100%; max-width: 46px; display: flex; align-items: flex-end; }
.vbar-fill { width: 100%; border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, #6cc1ff, #2e5d8f); min-height: 3px;
transition: height .4s ease; }
.vbar-x { font-size: 11px; color: var(--muted); }
.dash-foot { margin-top: 18px; font-size: 12px; text-align: center; }
/* ===== 开放 API ===== */
.export-btns { display: flex; gap: 12px; margin: 16px 0 24px; flex-wrap: wrap; }
.hero-cta.alt { background: transparent; color: var(--accent); border: 1px solid var(--accent); }
.api-table code { background: var(--panel2); padding: 2px 6px; border-radius: 6px; font-size: 13px; }
/* ===== AI 识车 ===== */
.identify-layout { display: grid; grid-template-columns: 360px 1fr; gap: 22px; margin-top: 8px; align-items: start; }
.identify-drop { display: grid; place-items: center; min-height: 240px; cursor: pointer;
border: 2px dashed var(--line); border-radius: 14px; overflow: hidden; background: var(--panel); }
.identify-drop:hover { border-color: var(--accent); }
.identify-drop img { max-width: 100%; max-height: 360px; display: block; }
.identify-drop-hint { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--muted); padding: 24px; text-align: center; }
.identify-drop-hint svg { color: var(--accent); opacity: .8; }
.identify-actions { display: flex; gap: 10px; margin-top: 14px; }
.identify-right { min-width: 0; }
.identify-loading { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 40px 0; }
.spinner { width: 34px; height: 34px; border-radius: 50%; border: 3px solid var(--line);
border-top-color: var(--accent); animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.identify-summary { font-size: 16px; line-height: 1.6; padding: 12px 14px; border-radius: 10px;
background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); margin: 0 0 18px; }
.notice-warn { border-color: #6b552e; background: #2b2410; color: #f3d18a; }
.guess-list { list-style: none; padding: 0; margin: 0 0 18px; display: flex; flex-direction: column; gap: 12px; }
.guess-row { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.guess-head { display: flex; align-items: baseline; gap: 10px; }
.guess-code { font-size: 17px; font-weight: 700; color: #fff; }
.guess-name { font-size: 13px; }
.guess-conf { margin-left: auto; font-size: 14px; font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
.guess-bar { height: 7px; border-radius: 999px; background: var(--panel2); margin: 8px 0; overflow: hidden; }
.guess-bar span { display: block; height: 100%; background: linear-gradient(90deg, #2e5d8f, #4ea1ff); border-radius: 999px; }
.guess-reason { font-size: 13px; line-height: 1.6; margin: 4px 0 0; }
.match-list { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; }
.match-card { display: inline-flex; align-items: center; gap: 8px; padding: 9px 14px; border-radius: 999px;
background: var(--panel2); border: 1px solid var(--line); font-size: 14px; }
.match-card:hover { border-color: var(--accent); }
.match-card svg { color: var(--accent); }
.match-code { font-weight: 700; }
.match-cat { font-size: 12px; }
.identify-foot { font-size: 12px; line-height: 1.6; }
.identify-placeholder { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 50px 0; text-align: center; }
.identify-placeholder svg { opacity: .5; }
/* AI 识车历史 */
.identify-history { margin-top: 28px; }
.hist-badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; margin-left: 8px;
background: #1d3a2a; border: 1px solid #3fae6e; color: #8af0b4; }
.hist-list { display: flex; flex-direction: column; gap: 12px; }
.hist-item { display: flex; gap: 14px; background: var(--panel); border: 1px solid var(--line);
border-radius: 12px; padding: 12px; }
.hist-thumb { width: 130px; height: 96px; flex: none; object-fit: cover; border-radius: 8px;
background: var(--panel2); }
.hist-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
.hist-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.hist-code { font-size: 16px; font-weight: 700; color: #fff; }
.hist-conf { font-size: 13px; font-weight: 700; color: var(--accent); }
.hist-time { margin-left: auto; font-size: 12px; }
.hist-del { background: none; border: none; color: var(--muted); cursor: pointer; padding: 2px;
display: inline-flex; }
.hist-del:hover { color: var(--danger); }
.hist-summary { margin: 0; font-size: 13px; line-height: 1.5; color: #c2c9d4; }
.hist-err { margin: 0; font-size: 13px; }
.hist-matches { display: flex; flex-wrap: wrap; gap: 6px; }
.hist-match { display: inline-flex; align-items: center; gap: 4px; font-size: 12px;
padding: 3px 9px; border-radius: 999px; background: var(--panel2); border: 1px solid var(--line); }
.hist-match:hover { border-color: var(--accent); }
.hist-match svg { color: var(--accent); }
.hist-note { display: flex; align-items: center; gap: 8px; margin-top: 2px; }
.hist-note input { flex: 1; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--line);
background: var(--panel2); color: var(--text); font-size: 13px; }
.hist-note input:focus { outline: none; border-color: var(--accent); }
@media (max-width: 560px) {
.hist-item { flex-direction: column; }
.hist-thumb { width: 100%; height: 160px; }
}
.foot { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; }
.foot-nav { display: flex; gap: 16px; }
.foot-nav a { color: var(--accent); }
@media (max-width: 980px) {
.kpi-row { grid-template-columns: repeat(2, 1fr); }
.identify-layout { grid-template-columns: 1fr; }
}
@media (max-width: 760px) { .dash-grid { grid-template-columns: 1fr; } .dash-card.span-2 { grid-column: auto; } }
/* ===== 统一组件:按钮 ===== */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
font-family: inherit; cursor: pointer; border-radius: var(--r1); border: 1px solid transparent;
transition: background .15s, border-color .15s, transform .1s, color .15s; white-space: nowrap; }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .55; cursor: default; }
.btn-md { padding: 9px 16px; font-size: 14px; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.btn-primary { background: var(--accent); color: #06121f; font-weight: 700; }
.btn-primary:hover { background: var(--accent-press); }
.btn-secondary { background: var(--panel2); color: var(--text); border-color: var(--line); }
.btn-secondary:hover { border-color: var(--line-strong); }
.btn-ghost { background: transparent; color: var(--accent); border-color: var(--accent); }
.btn-ghost:hover { background: var(--accent); color: #06121f; }
.btn-danger { background: transparent; color: var(--danger); border-color: #6b2e2e; }
.btn-danger:hover { background: #f87171; color: #1a0606; }
/* ===== 统一页头 ===== */
.page-header { display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; margin: 8px 0 20px; flex-wrap: wrap; }
.page-header h1 { margin: 0; font-size: 26px; letter-spacing: .5px; }
.page-sub { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
.page-actions { display: flex; gap: 8px; }
.page h1 { letter-spacing: .5px; }
/* ===== 统一空状态 ===== */
.empty-state { text-align: center; padding: 56px 16px; color: var(--muted); }
.empty-icon { font-size: 40px; display: block; margin-bottom: 10px; opacity: .8; }
/* 统一卡片悬浮过渡 */
.card, .gcard, .mini, .family-node, .feed-item, .spot, .dash-card, .rev-item, .thread-row {
transition: border-color .15s, transform .15s, box-shadow .2s;
}
/* ===== 候选审图 ===== */
.review-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.review-card { background: var(--panel); border: 1px solid var(--line); border-radius: var(--r2); overflow: hidden; display: flex; flex-direction: column; }
.review-img { display: block; aspect-ratio: 4/3; background: var(--panel2); }
.review-img img { width: 100%; height: 100%; object-fit: cover; }
.review-meta { padding: 10px 12px; display: flex; flex-direction: column; gap: 2px; }
.review-code { font-weight: 700; }
.review-attr { font-size: 11px; }
.review-attr a { color: var(--accent); }
.review-actions { display: flex; gap: 8px; padding: 0 12px 12px; }
.review-actions .btn { flex: 1; }
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+277
View File
@@ -0,0 +1,277 @@
export interface Category {
id: number;
name: string;
subcat: string;
modelCount: number;
unitCount: number;
}
export interface ModelListItem {
id: number;
model_code: string;
full_name: string;
series: string;
manufacturer: string;
country: string;
country_type: string;
first_year: number | null;
last_year: number | null;
status: string;
usage: string;
max_speed_value: number | null;
max_speed_unit: string;
weight_value: number | null;
weight_unit: string;
axle_arrangement: string;
category: string;
subcat: string;
cover_url?: string | null;
}
export interface ModelDetail extends ModelListItem {
raw?: Record<string, string>;
raw_json?: string;
[key: string]: unknown;
}
export interface Paged<T> {
total: number;
page: number;
pageSize: number;
items: T[];
}
export interface ModelQuery {
page?: number;
pageSize?: number;
category?: string;
yearFrom?: number;
yearTo?: number;
speedMin?: number;
speedMax?: number;
manufacturer?: string;
country?: string;
status?: string;
q?: string;
sort?: string;
order?: 'asc' | 'desc';
}
export interface SearchResult {
type: string;
id: number;
model_code: string;
full_name: string;
manufacturer: string;
series: string;
category: string;
rank: number;
}
/** 详情图册统一图片模型(示意图 / 本地上传 / Commons 真实照片)。*/
export interface ImageAttribution {
author?: string;
license?: string;
url?: string;
}
export interface GalleryImage {
id: string;
src: string;
caption: string;
removable: boolean;
source: 'sample' | 'local' | 'commons' | 'photo';
attribution?: ImageAttribution;
photoId?: number;
pending?: boolean;
featured?: boolean;
}
export type Role = 'guest' | 'user' | 'trusted' | 'moderator' | 'admin';
export interface PublicUser {
id: number;
email: string;
displayName: string;
role: Role;
contributionPoints: number;
createdAt: string;
}
export interface LevelInfo {
level: number;
title: string;
min: number;
nextThreshold: number | null;
}
export interface Badge {
key: string;
label: string;
}
export interface UserStats extends PublicUser {
level: LevelInfo;
approvedCount: number;
badges: Badge[];
}
export interface LeaderboardEntry {
rank: number;
id: number;
displayName: string;
role: Role;
points: number;
title: string;
}
export interface Maintainer {
id: number;
displayName: string;
role: Role;
since: string;
}
export interface Board {
key: string;
label: string;
}
export interface ThreadReply {
id: number;
body: string;
author_id: number;
author_name: string;
created_at: string;
}
export interface Thread {
id: number;
board: string;
model_id: number | null;
title: string;
body: string;
author_id: number;
author_name: string;
created_at: string;
last_activity_at: string;
reply_count: number;
replies?: ThreadReply[];
}
export interface Sighting {
id: number;
model_id: number;
model_code: string;
category: string;
user_name: string;
car_number: string;
lat: number;
lng: number;
station: string;
spotted_at: string;
description: string;
created_at: string;
}
export interface FamilyModel {
id: number;
model_code: string;
series: string;
first_year: number | null;
last_year: number | null;
country_type: string;
max_speed_value: number | null;
max_speed_unit: string;
category: string;
}
export interface Family {
category: string;
series: { name: string; models: FamilyModel[] }[];
}
export interface Spot {
station: string;
total: number;
models: { model_id: number; model_code: string; count: number }[];
}
export interface Photo {
id: number;
modelId: number;
url: string;
caption: string;
status: 'candidate' | 'confirmed';
featured: boolean;
sourceUrl: string;
author: string;
license: string;
uploaderId: number;
uploaderName: string;
createdAt: string;
}
export interface Stats {
totals: { models: number; units: number; categories: number };
byCategory: { label: string; count: number }[];
byDecade: { decade: number; count: number }[];
speedByDecade: { decade: number; maxSpeed: number; avgSpeed: number }[];
byCountryType: { label: string; count: number }[];
}
export interface AuthResult {
token: string;
user: PublicUser;
}
export interface EditableField {
field: string;
label: string;
type: 'text' | 'int';
}
export interface RevisionChange {
field: string;
old_value: string | null;
new_value: string | null;
}
export interface Revision {
id: number;
model_id: number;
author_id: number;
author_name: string;
status: 'pending' | 'approved' | 'rejected';
note: string;
created_at: string;
reviewed_at: string | null;
changes: RevisionChange[];
}
/** AI 识车(通义千问视觉模型)结果。*/
export interface IdentifyGuess {
model_code: string;
name: string;
confidence: number;
reason: string;
}
export interface IdentifyMatch {
id: number;
model_code: string;
category: string;
}
export interface Identification {
id: number;
url: string;
summary: string;
guesses: IdentifyGuess[];
matches: IdentifyMatch[];
topCode: string;
topName: string;
note: string;
error: string;
cached: boolean;
configured: boolean;
createdAt: string;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2021",
"useDefineForClassFields": true,
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/vite-env.d.ts","./src/api/client.test.ts","./src/api/client.ts","./src/components/authmodal.tsx","./src/components/editmodal.tsx","./src/components/evolutioncurve.tsx","./src/components/filterbar.tsx","./src/components/gallerycard.test.tsx","./src/components/gallerycard.tsx","./src/components/galleryview.tsx","./src/components/lightbox.tsx","./src/components/modelcard.test.tsx","./src/components/modelcard.tsx","./src/components/navmenu.tsx","./src/components/pagination.tsx","./src/components/radarchart.tsx","./src/components/revisionlist.tsx","./src/components/searchbox.tsx","./src/components/thumb.tsx","./src/components/timelineview.tsx","./src/components/icons.tsx","./src/components/ui.tsx","./src/lib/auth.tsx","./src/lib/compare.test.ts","./src/lib/compare.ts","./src/lib/eras.test.ts","./src/lib/eras.ts","./src/lib/fieldlabels.test.ts","./src/lib/fieldlabels.ts","./src/lib/format.test.ts","./src/lib/format.ts","./src/lib/sampleimages.ts","./src/lib/thumb.test.ts","./src/lib/thumb.ts","./src/lib/timeline.test.ts","./src/lib/timeline.ts","./src/lib/usecollection.ts","./src/lib/usefollow.ts","./src/lib/useimages.ts","./src/pages/adminphotospage.tsx","./src/pages/apidocspage.tsx","./src/pages/comparepage.tsx","./src/pages/dashboardpage.tsx","./src/pages/detailpage.tsx","./src/pages/familypage.tsx","./src/pages/forumpage.tsx","./src/pages/homepage.tsx","./src/pages/identifypage.tsx","./src/pages/leaderboardpage.tsx","./src/pages/listpage.tsx","./src/pages/mappage.tsx","./src/pages/profilepage.tsx","./src/pages/reviewpage.tsx","./src/pages/threadpage.tsx","./src/test/setup.ts"],"version":"5.9.3"}
+22
View File
@@ -0,0 +1,22 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
// 仅代理真正的 API 调用(/api/...);避免误伤前端路由如 /api-docs
'^/api/': { target: 'http://127.0.0.1:3001', changeOrigin: true },
// 共享图库静态图片
'^/uploads/': { target: 'http://127.0.0.1:3001', changeOrigin: true },
},
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
});