init: AI培训与智能巡检系统
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
*.log
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,29 @@
|
||||
# 中国机车图鉴 · 前端 (apps/web)
|
||||
|
||||
Vite + React + TypeScript,消费 `apps/api`(NestJS)的接口。对应 Phase 1C(T-1.5/1.6/1.7)。
|
||||
|
||||
## 功能
|
||||
- 列表页:分类/状态/国别/年代区间/时速筛选 + 排序 + 分页
|
||||
- 三视图切换:**列表卡片 / 时间轴(分类泳道)/ 图鉴卡牌(含收集占位)**
|
||||
- 详情页:技术参数表 + 原始数据(raw_json)保真表
|
||||
- 全局搜索框:防抖下拉,命中跳转详情
|
||||
|
||||
## 开发运行(需先启动后端)
|
||||
```bash
|
||||
# 1) 终端 A:启动后端 API(在 apps/api)
|
||||
cd apps/api && npm run build && node dist/main.js # http://localhost:3001
|
||||
|
||||
# 2) 终端 B:启动前端(在 apps/web)
|
||||
npm run dev # http://localhost:5173
|
||||
```
|
||||
Vite 已将 `/api` 代理到 `http://127.0.0.1:3001`,无需额外配置。
|
||||
|
||||
## 测试与构建
|
||||
```bash
|
||||
npm test # Vitest 单元/组件测试(23 项)
|
||||
npm run build # tsc 类型检查 + vite 生产构建
|
||||
```
|
||||
|
||||
## 说明
|
||||
- 时间轴布局、格式化、查询拼装等纯逻辑抽到 `src/lib`、`src/api`,便于单测。
|
||||
- 图鉴"已收集/未收集"为占位,收集逻辑待 Phase 3 打卡功能接入。
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
// 回归:贡献榜列表曾因 class 名 `.lb` 与灯箱冲突变成全屏遮罩,导致无法离开页面
|
||||
test('从贡献榜可正常导航离开', async ({ page }) => {
|
||||
await page.goto('/leaderboard');
|
||||
await expect(page.getByRole('heading', { name: '贡献榜' })).toBeVisible();
|
||||
await page.getByRole('button', { name: /图鉴/ }).click();
|
||||
await page.getByRole('menuitem', { name: '探索' }).click();
|
||||
await expect(page).toHaveURL(/\/explore/);
|
||||
});
|
||||
|
||||
// 回归:/api-docs 曾被 vite 的 /api 代理误吞(前缀匹配),导致路由 404
|
||||
test('开放API文档路由不被代理吞掉', async ({ page }) => {
|
||||
await page.goto('/api-docs');
|
||||
await expect(page.getByRole('heading', { name: /开放 API/ })).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,374 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
/** MVP 全链路冒烟 — 对应 T-1.8(已适配叙事首页 + 探索页 IA)。*/
|
||||
|
||||
test('叙事首页:英雄区 + 时代章节 + 代表车', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('heading', { name: /中国工业史/ })).toBeVisible();
|
||||
await expect(page.getByText('蒸汽时代')).toBeVisible();
|
||||
await expect(page.getByText('高铁时代')).toBeVisible();
|
||||
// 代表车 mini 卡存在
|
||||
await expect(page.locator('.mini').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('首页 → 探索(CTA)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: /进入图鉴探索/ }).click();
|
||||
await expect(page).toHaveURL(/\/explore/);
|
||||
await expect(page.getByTestId('gallery')).toBeVisible();
|
||||
});
|
||||
|
||||
test('探索页默认图鉴:分页(21/页) + 点击收集', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery-card').first()).toBeVisible();
|
||||
// 每页至多 21 个
|
||||
expect(await page.getByTestId('gallery-card').count()).toBeLessThanOrEqual(21);
|
||||
// 分页器存在(在图鉴上方)
|
||||
await expect(page.getByRole('button', { name: '下一页' })).toBeVisible();
|
||||
await expect(page.locator('.gcard-art .thumb').first()).toBeVisible();
|
||||
const firstCollect = page.locator('.gcard-collect').first();
|
||||
await expect(firstCollect).toHaveAttribute('aria-pressed', 'false');
|
||||
await firstCollect.click();
|
||||
await expect(firstCollect).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
|
||||
test('筛选(折叠面板)→ 图鉴卡片 → 详情(全部详情, 全中文)', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await page.getByRole('button', { name: /筛选/ }).click();
|
||||
await page.locator('.filterbar select').first().selectOption('电力机车');
|
||||
const firstCard = page.locator('.gcard-art').first();
|
||||
await expect(firstCard).toBeVisible();
|
||||
await firstCard.click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
await expect(page.getByRole('heading', { name: '基本信息' })).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '动力与性能' })).toBeVisible();
|
||||
// 原始数据英文键已映射为中文
|
||||
await expect(page.getByText('model_code')).toHaveCount(0);
|
||||
await expect(page.getByText('max_speed_value')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('详情图册:管理员上传图片并灯箱缩放', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('heading', { name: '图册' }).scrollIntoViewIfNeeded();
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await page.setInputFiles('input[type=file]', {
|
||||
name: 'demo.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
});
|
||||
await expect(page.locator('.img-cell img').first()).toBeVisible();
|
||||
await page.locator('.img-cell img').first().click();
|
||||
await expect(page.getByTestId('lightbox')).toBeVisible();
|
||||
await page.getByRole('button', { name: '放大' }).click();
|
||||
await expect(page.getByTestId('lightbox')).toContainText('%');
|
||||
});
|
||||
|
||||
test('时间轴视图渲染并可点击节点', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await page.getByRole('button', { name: '时间轴' }).click();
|
||||
await expect(page.getByTestId('timeline')).toBeVisible();
|
||||
await expect(page.locator('.error')).toHaveCount(0);
|
||||
expect(await page.locator('.node').count()).toBeGreaterThan(30);
|
||||
await page.locator('.node').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
});
|
||||
|
||||
test('全局搜索命中并跳转详情', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByPlaceholder('搜索型号 / 生产商 / 系列…').fill('HXD');
|
||||
await expect(page.locator('.search-dropdown li').first()).toBeVisible();
|
||||
await page.locator('.search-dropdown li').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
});
|
||||
|
||||
test('首页章节链接 → 探索并带分类筛选', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('link', { name: /探索全部.*车型/ }).first().click();
|
||||
await expect(page).toHaveURL(/\/explore\?category=/);
|
||||
await expect(page.getByTestId('gallery')).toBeVisible();
|
||||
});
|
||||
|
||||
test('账户:注册 → 用户菜单 → 个人主页', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await expect(page.getByTestId('auth-modal')).toBeVisible();
|
||||
await page.getByRole('button', { name: '注册', exact: true }).click();
|
||||
await page.getByPlaceholder('昵称').fill('E2E用户');
|
||||
await page.getByPlaceholder('邮箱').fill(`e2e_${Date.now()}@test.com`);
|
||||
await page.getByPlaceholder('密码(至少 6 位)').fill('secret123');
|
||||
await page.getByRole('button', { name: '注册并登录' }).click();
|
||||
await expect(page.getByTestId('auth-modal')).toHaveCount(0);
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await page.locator('.user-chip').click();
|
||||
await expect(page).toHaveURL(/\/me/);
|
||||
await expect(page.getByText('贡献积分', { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('管理员编辑词条 → 立即生效', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('button', { name: /编辑词条/ }).click();
|
||||
await expect(page.getByTestId('edit-modal')).toBeVisible();
|
||||
await page
|
||||
.locator('.edit-field', { hasText: '用途' })
|
||||
.locator('input')
|
||||
.fill(`用途修订 ${Date.now()}`);
|
||||
await page.getByRole('button', { name: '提交修改' }).click();
|
||||
await expect(page.locator('.notice')).toContainText('生效');
|
||||
});
|
||||
|
||||
test('普通用户看不到编辑/上传入口(图鉴只读)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await expect(page.getByRole('button', { name: /编辑词条/ })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '+ 添加图片' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
async function registerUser(page: import('@playwright/test').Page, name: string) {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: '注册', exact: true }).click();
|
||||
await page.getByPlaceholder('昵称').fill(name);
|
||||
await page.getByPlaceholder('邮箱').fill(`u_${Date.now()}@test.com`);
|
||||
await page.getByPlaceholder('密码(至少 6 位)').fill('secret123');
|
||||
await page.getByRole('button', { name: '注册并登录' }).click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
}
|
||||
|
||||
test('认领词条维护 → 维护者署名出现', async ({ page }) => {
|
||||
const name = `维护${Date.now() % 100000}`;
|
||||
await registerUser(page, name);
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('button', { name: '认领维护' }).click();
|
||||
await expect(page.locator('.maintainers')).toContainText(name);
|
||||
});
|
||||
|
||||
test('个人主页含等级与徽章区', async ({ page }) => {
|
||||
await registerUser(page, `等级${Date.now() % 100000}`);
|
||||
await page.locator('.user-chip').click();
|
||||
await expect(page).toHaveURL(/\/me/);
|
||||
await expect(page.locator('.level-bar')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: '徽章' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('贡献榜页可访问', async ({ page }) => {
|
||||
await page.goto('/leaderboard');
|
||||
await expect(page.getByRole('heading', { name: '贡献榜' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('测试账号一键填入并登录', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toContainText('演示用户');
|
||||
});
|
||||
|
||||
test('管理员一键登录后可见审核入口', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '审核' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('社区:发帖 → 帖子页回复', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /社区/ }).click();
|
||||
await page.getByRole('menuitem', { name: '论坛' }).click();
|
||||
await expect(page).toHaveURL(/\/community/);
|
||||
await page.getByRole('button', { name: '+ 发帖' }).click();
|
||||
const title = `测试帖 ${Date.now() % 100000}`;
|
||||
await page.getByPlaceholder('标题').fill(title);
|
||||
await page.getByPlaceholder('正文…').fill('这是一条 e2e 测试帖');
|
||||
await page.getByRole('button', { name: '发布' }).click();
|
||||
|
||||
await page.getByRole('link', { name: new RegExp(title) }).click();
|
||||
await expect(page).toHaveURL(/\/thread\/\d+/);
|
||||
await page.getByPlaceholder('写下你的回复…').fill('e2e 回复');
|
||||
await page.getByRole('button', { name: '回复' }).click();
|
||||
await expect(page.locator('.replies')).toContainText('e2e 回复');
|
||||
});
|
||||
|
||||
test('打卡:详情页登记目击 → 出现在列表', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('heading', { name: '目击打卡' }).scrollIntoViewIfNeeded();
|
||||
await page.getByPlaceholder('纬度 lat').fill('39.90');
|
||||
await page.getByPlaceholder('经度 lng').fill('116.40');
|
||||
await page.getByPlaceholder('车站/地点').fill('北京南站');
|
||||
await page.getByRole('button', { name: '打卡', exact: true }).click();
|
||||
await expect(page.locator('.sighting-row').first()).toContainText('北京南站');
|
||||
});
|
||||
|
||||
test('打卡地图页加载', async ({ page }) => {
|
||||
await page.goto('/map');
|
||||
await expect(page.getByRole('heading', { name: '打卡地图' })).toBeVisible();
|
||||
await expect(page.getByTestId('map')).toBeVisible();
|
||||
});
|
||||
|
||||
test('参数对比:加入车型 → 雷达图 + 表格', async ({ page }) => {
|
||||
await page.goto('/compare');
|
||||
await expect(page.getByRole('heading', { name: '参数对比' })).toBeVisible();
|
||||
await page.getByPlaceholder('搜索型号加入对比…').fill('CR400');
|
||||
await page.locator('.cmp-search .search-dropdown li').first().click();
|
||||
await page.getByPlaceholder('搜索型号加入对比…').fill('HXD1');
|
||||
await page.locator('.cmp-search .search-dropdown li').first().click();
|
||||
await expect(page.getByTestId('radar')).toBeVisible();
|
||||
await expect(page.locator('.cmp-table')).toContainText('最高时速');
|
||||
await expect(page.locator('.cmp-chip')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('技术族谱:按系列展示并可进详情', async ({ page }) => {
|
||||
await page.goto('/family');
|
||||
await expect(page.getByRole('heading', { name: '技术族谱' })).toBeVisible();
|
||||
await expect(page.locator('.family-series').first()).toBeVisible();
|
||||
await page.locator('.family-node').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
});
|
||||
|
||||
test('关注车型 → 个人主页"最新目击"区出现', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/1');
|
||||
await page.getByRole('button', { name: /关注/ }).click();
|
||||
await expect(page.getByRole('button', { name: '★ 已关注' })).toBeVisible();
|
||||
|
||||
await page.locator('.user-chip').click();
|
||||
await expect(page.getByRole('heading', { name: /关注的车型/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test('数据大屏渲染 KPI 与图表', async ({ page }) => {
|
||||
await page.goto('/stats');
|
||||
await expect(page.getByRole('heading', { name: /数据大屏/ })).toBeVisible();
|
||||
await expect(page.locator('.kpi').first()).toBeVisible();
|
||||
await expect(page.locator('.donut').first()).toBeVisible();
|
||||
await expect(page.locator('.vbars')).toBeVisible();
|
||||
await expect(page.getByTestId('evolution-curve')).toBeVisible();
|
||||
});
|
||||
|
||||
test('开放 API 文档页与导出链接', async ({ page }) => {
|
||||
await page.goto('/api-docs');
|
||||
await expect(page.getByRole('heading', { name: /开放 API/ })).toBeVisible();
|
||||
await expect(page.locator('a[href="/api/export/models.csv"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('AI 识车页:未登录提示 + 上传区', async ({ page }) => {
|
||||
await page.goto('/identify');
|
||||
await expect(page.getByRole('heading', { name: 'AI 识车' })).toBeVisible();
|
||||
await expect(page.locator('.identify-drop')).toBeVisible();
|
||||
await expect(page.locator('.notice')).toContainText('登录');
|
||||
});
|
||||
|
||||
test('管理员候选审图页可访问', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await page.getByRole('link', { name: '候选审图' }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/photos/);
|
||||
await expect(page.getByRole('heading', { name: '候选审图' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('普通用户无候选审图入口', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /普通用户/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: '候选审图' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('图鉴:输入型号关键字直接筛选', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery-card').first()).toBeVisible();
|
||||
await page.locator('.kw-input').fill('HXD');
|
||||
// 防抖后结果收敛,首张卡片型号含 HXD
|
||||
await expect(page.locator('.gcard-code').first()).toContainText('HXD');
|
||||
// 计数随筛选下降(全量 500+ → 少量)
|
||||
await expect(page.locator('.result-count')).toBeVisible();
|
||||
});
|
||||
|
||||
test('管理员:上传后可将照片设为封面', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: '登录 / 注册' }).click();
|
||||
await page.getByRole('button', { name: /管理员/ }).click();
|
||||
await page.locator('.auth-submit').click();
|
||||
await expect(page.locator('.user-chip')).toBeVisible();
|
||||
|
||||
await page.goto('/models/2');
|
||||
await page.getByRole('heading', { name: '图册' }).scrollIntoViewIfNeeded();
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
);
|
||||
await page.setInputFiles('input[type=file]', {
|
||||
name: 'cover.png',
|
||||
mimeType: 'image/png',
|
||||
buffer: png,
|
||||
});
|
||||
// 已确认的图库照片出现"设为封面"★ 按钮
|
||||
const featureBtn = page.getByRole('button', { name: '设为封面' }).first();
|
||||
await expect(featureBtn).toBeVisible();
|
||||
await featureBtn.click();
|
||||
// 设为封面后按钮进入选中态(金色 on)
|
||||
await expect(page.locator('.img-feature.on')).toBeVisible();
|
||||
});
|
||||
|
||||
test('图鉴:翻到第 2 页进详情,返回仍停在第 2 页', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery-card').first()).toBeVisible();
|
||||
await page.getByRole('button', { name: '下一页' }).click();
|
||||
await expect(page).toHaveURL(/[?&]gp=2/);
|
||||
await expect(page.locator('.pagination')).toContainText('第 2');
|
||||
const firstCode = await page.locator('.gcard-code').first().textContent();
|
||||
|
||||
await page.locator('.gcard-art').first().click();
|
||||
await expect(page).toHaveURL(/\/models\/\d+/);
|
||||
|
||||
await page.getByRole('button', { name: /返回图鉴/ }).click();
|
||||
await expect(page).toHaveURL(/[?&]gp=2/);
|
||||
await expect(page.locator('.pagination')).toContainText('第 2');
|
||||
await expect(page.locator('.gcard-code').first()).toHaveText(firstCode || '');
|
||||
});
|
||||
|
||||
test('图鉴页不再显示"收集示例"按钮', async ({ page }) => {
|
||||
await page.goto('/explore');
|
||||
await expect(page.getByTestId('gallery')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '收集示例' })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>中国机车图鉴</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3548
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@train/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.6.0",
|
||||
"react-router-dom": "^6.26.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.47.2",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@types/react": "^18.3.10",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.2",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.1.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Playwright e2e 配置 — 对应 T-1.5/1.6/1.7/1.8 E2E。
|
||||
* 自动拉起后端 API(apps/api)与前端 dev server,再跑用例。
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30_000,
|
||||
fullyParallel: false,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:5173',
|
||||
trace: 'on-first-retry',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
webServer: [
|
||||
{
|
||||
command: 'node dist/main.js',
|
||||
cwd: '../api',
|
||||
url: 'http://127.0.0.1:3001/api/health',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev -- --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:5173',
|
||||
reuseExistingServer: true,
|
||||
timeout: 30_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, Route, Routes } from 'react-router-dom';
|
||||
import { SearchBox } from './components/SearchBox';
|
||||
import { AuthModal } from './components/AuthModal';
|
||||
import { NavMenu } from './components/NavMenu';
|
||||
import { IconBrand } from './components/icons';
|
||||
import { useAuth, ROLE_LABEL } from './lib/auth';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { ListPage } from './pages/ListPage';
|
||||
import { DetailPage } from './pages/DetailPage';
|
||||
import { ProfilePage } from './pages/ProfilePage';
|
||||
import { ReviewPage } from './pages/ReviewPage';
|
||||
import { LeaderboardPage } from './pages/LeaderboardPage';
|
||||
import { ForumPage } from './pages/ForumPage';
|
||||
import { ThreadPage } from './pages/ThreadPage';
|
||||
import { MapPage } from './pages/MapPage';
|
||||
import { ComparePage } from './pages/ComparePage';
|
||||
import { FamilyPage } from './pages/FamilyPage';
|
||||
import { DashboardPage } from './pages/DashboardPage';
|
||||
import { ApiDocsPage } from './pages/ApiDocsPage';
|
||||
import { IdentifyPage } from './pages/IdentifyPage';
|
||||
import { AdminPhotosPage } from './pages/AdminPhotosPage';
|
||||
|
||||
const RANK: Record<string, number> = {
|
||||
guest: 0, user: 1, trusted: 2, moderator: 3, admin: 4,
|
||||
};
|
||||
|
||||
function UserMenu({ onLogin }: { onLogin: () => void }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return null;
|
||||
if (!user)
|
||||
return (
|
||||
<button className="login-btn" onClick={onLogin}>
|
||||
登录 / 注册
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<Link to="/me" className="user-chip" title={ROLE_LABEL[user.role] ?? user.role}>
|
||||
<span className="user-avatar">{user.displayName.slice(0, 1)}</span>
|
||||
<span className="user-name">{user.displayName}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [showAuth, setShowAuth] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const canReview = !!user && RANK[user.role] >= RANK.moderator;
|
||||
const isAdmin = user?.role === 'admin';
|
||||
return (
|
||||
<div className="app">
|
||||
<header className="topbar">
|
||||
<Link to="/" className="brand">
|
||||
<IconBrand size={24} /> 中国机车图鉴
|
||||
</Link>
|
||||
<NavMenu canReview={canReview} isAdmin={isAdmin} />
|
||||
<SearchBox />
|
||||
<UserMenu onLogin={() => setShowAuth(true)} />
|
||||
</header>
|
||||
<main>
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/explore" element={<ListPage />} />
|
||||
<Route path="/models/:id" element={<DetailPage />} />
|
||||
<Route path="/me" element={<ProfilePage />} />
|
||||
<Route path="/review" element={<ReviewPage />} />
|
||||
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
||||
<Route path="/community" element={<ForumPage />} />
|
||||
<Route path="/thread/:id" element={<ThreadPage />} />
|
||||
<Route path="/map" element={<MapPage />} />
|
||||
<Route path="/compare" element={<ComparePage />} />
|
||||
<Route path="/family" element={<FamilyPage />} />
|
||||
<Route path="/stats" element={<DashboardPage />} />
|
||||
<Route path="/api-docs" element={<ApiDocsPage />} />
|
||||
<Route path="/identify" element={<IdentifyPage />} />
|
||||
<Route path="/admin/photos" element={<AdminPhotosPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<footer className="foot muted">
|
||||
<span>数据底座 · 沿技术演进主线呈现 · 众包共建</span>
|
||||
<nav className="foot-nav">
|
||||
<Link to="/api-docs">开放 API & 数据</Link>
|
||||
</nav>
|
||||
</footer>
|
||||
{showAuth && <AuthModal onClose={() => setShowAuth(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildQuery } from './client';
|
||||
|
||||
describe('buildQuery', () => {
|
||||
it('剔除空值', () => {
|
||||
expect(buildQuery({ a: 1, b: undefined, c: null, d: '' })).toBe('?a=1');
|
||||
});
|
||||
|
||||
it('多参数', () => {
|
||||
const s = buildQuery({ page: 2, category: '电力机车' });
|
||||
expect(s).toContain('page=2');
|
||||
expect(s).toContain('category=');
|
||||
});
|
||||
|
||||
it('空对象返回空串', () => {
|
||||
expect(buildQuery({})).toBe('');
|
||||
});
|
||||
|
||||
it('0 与 false 保留', () => {
|
||||
expect(buildQuery({ a: 0, b: false })).toBe('?a=0&b=false');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,226 @@
|
||||
import type {
|
||||
AuthResult,
|
||||
Board,
|
||||
Category,
|
||||
EditableField,
|
||||
Family,
|
||||
Identification,
|
||||
LeaderboardEntry,
|
||||
Maintainer,
|
||||
ModelDetail,
|
||||
ModelListItem,
|
||||
ModelQuery,
|
||||
Paged,
|
||||
Photo,
|
||||
PublicUser,
|
||||
Revision,
|
||||
SearchResult,
|
||||
Sighting,
|
||||
Spot,
|
||||
Stats,
|
||||
Thread,
|
||||
UserStats,
|
||||
} from '../types';
|
||||
|
||||
const BASE = import.meta.env?.VITE_API_BASE ?? '';
|
||||
const TOKEN_KEY = 'train.token';
|
||||
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function setToken(t: string) {
|
||||
try {
|
||||
localStorage.setItem(TOKEN_KEY, t);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
export function clearToken() {
|
||||
try {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/** 把查询对象转为 URLSearchParams 字符串(剔除 null/undefined/空串)。*/
|
||||
export function buildQuery(params: Record<string, unknown>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue;
|
||||
sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : '';
|
||||
}
|
||||
|
||||
function authHeaders(): Record<string, string> {
|
||||
const t = getToken();
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, { headers: { ...authHeaders() } });
|
||||
if (!res.ok) throw new Error(`请求失败 ${res.status}: ${path}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function postJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = Array.isArray((data as any)?.message)
|
||||
? (data as any).message.join(';')
|
||||
: (data as any)?.message || `请求失败 ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function deleteJson<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { ...authHeaders() },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as any)?.message || `请求失败 ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function patchJson<T>(path: string, body: unknown): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as any)?.message || `请求失败 ${res.status}`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function postForm<T>(path: string, form: FormData): Promise<T> {
|
||||
// 不手动设置 Content-Type,让浏览器带上 multipart boundary
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...authHeaders() },
|
||||
body: form,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = Array.isArray((data as any)?.message)
|
||||
? (data as any).message.join(';')
|
||||
: (data as any)?.message || `上传失败 ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
categories: () => getJson<Category[]>('/api/categories'),
|
||||
|
||||
models: (q: ModelQuery = {}) =>
|
||||
getJson<Paged<ModelListItem>>(
|
||||
`/api/models${buildQuery(q as Record<string, unknown>)}`,
|
||||
),
|
||||
|
||||
model: (id: number) => getJson<ModelDetail>(`/api/models/${id}`),
|
||||
|
||||
search: (q: string, limit = 10) =>
|
||||
getJson<{ query: string; results: SearchResult[] }>(
|
||||
`/api/search${buildQuery({ q, limit })}`,
|
||||
),
|
||||
|
||||
auth: {
|
||||
register: (email: string, password: string, displayName: string) =>
|
||||
postJson<AuthResult>('/api/auth/register', { email, password, displayName }),
|
||||
login: (email: string, password: string) =>
|
||||
postJson<AuthResult>('/api/auth/login', { email, password }),
|
||||
me: () => getJson<UserStats>('/api/auth/me'),
|
||||
},
|
||||
|
||||
editableFields: () => getJson<EditableField[]>('/api/editable-fields'),
|
||||
modelRevisions: (id: number) => getJson<Revision[]>(`/api/models/${id}/revisions`),
|
||||
submitRevision: (id: number, changes: Record<string, string>, note: string) =>
|
||||
postJson<Revision>(`/api/models/${id}/revisions`, { changes, note }),
|
||||
pendingRevisions: () => getJson<Revision[]>('/api/revisions/pending'),
|
||||
approveRevision: (rid: number) =>
|
||||
postJson<Revision>(`/api/revisions/${rid}/approve`, {}),
|
||||
rejectRevision: (rid: number) =>
|
||||
postJson<Revision>(`/api/revisions/${rid}/reject`, {}),
|
||||
|
||||
leaderboard: (limit = 20) =>
|
||||
getJson<LeaderboardEntry[]>(`/api/leaderboard${buildQuery({ limit })}`),
|
||||
maintainers: (id: number) => getJson<Maintainer[]>(`/api/models/${id}/maintainers`),
|
||||
claimMaintainer: (id: number) =>
|
||||
postJson<Maintainer[]>(`/api/models/${id}/maintainers`, {}),
|
||||
unclaimMaintainer: (id: number) =>
|
||||
deleteJson<Maintainer[]>(`/api/models/${id}/maintainers`),
|
||||
|
||||
boards: () => getJson<Board[]>('/api/boards'),
|
||||
threads: (params: { board?: string; modelId?: number } = {}) =>
|
||||
getJson<Thread[]>(`/api/threads${buildQuery(params)}`),
|
||||
thread: (id: number) => getJson<Thread>(`/api/threads/${id}`),
|
||||
createThread: (data: {
|
||||
board: string;
|
||||
modelId?: number;
|
||||
title: string;
|
||||
body: string;
|
||||
}) => postJson<Thread>('/api/threads', data),
|
||||
addReply: (id: number, body: string) =>
|
||||
postJson<Thread>(`/api/threads/${id}/replies`, { body }),
|
||||
|
||||
modelSightings: (id: number) =>
|
||||
getJson<Sighting[]>(`/api/models/${id}/sightings`),
|
||||
createSighting: (
|
||||
id: number,
|
||||
data: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
station?: string;
|
||||
carNumber?: string;
|
||||
spottedAt?: string;
|
||||
description?: string;
|
||||
},
|
||||
) => postJson<Sighting>(`/api/models/${id}/sightings`, data),
|
||||
recentSightings: (limit = 30) =>
|
||||
getJson<Sighting[]>(`/api/sightings/recent${buildQuery({ limit })}`),
|
||||
sightingsMap: () => getJson<Sighting[]>('/api/sightings/map'),
|
||||
spots: () => getJson<Spot[]>('/api/sightings/spots'),
|
||||
families: (category?: string) =>
|
||||
getJson<Family[]>(`/api/models/families${buildQuery({ category: category ?? '' })}`),
|
||||
stats: () => getJson<Stats>('/api/stats'),
|
||||
|
||||
modelPhotos: (id: number) => getJson<Photo[]>(`/api/models/${id}/photos`),
|
||||
uploadPhoto: (id: number, file: File, caption = '') => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
if (caption) fd.append('caption', caption);
|
||||
return postForm<Photo>(`/api/models/${id}/photos`, fd);
|
||||
},
|
||||
deletePhoto: (pid: number) => deleteJson<{ ok: boolean }>(`/api/photos/${pid}`),
|
||||
confirmPhoto: (pid: number) => postJson<Photo>(`/api/photos/${pid}/confirm`, {}),
|
||||
featurePhoto: (pid: number) => postJson<Photo>(`/api/photos/${pid}/feature`, {}),
|
||||
candidatePhotos: () =>
|
||||
getJson<(Photo & { modelCode: string; category: string })[]>(
|
||||
'/api/photos/candidates',
|
||||
),
|
||||
|
||||
identify: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return postForm<Identification>('/api/identify', fd);
|
||||
},
|
||||
identifications: () => getJson<Identification[]>('/api/identifications'),
|
||||
updateIdentification: (id: number, note: string) =>
|
||||
patchJson<Identification>(`/api/identifications/${id}`, { note }),
|
||||
deleteIdentification: (id: number) =>
|
||||
deleteJson<{ ok: boolean }>(`/api/identifications/${id}`),
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { IconClose } from './icons';
|
||||
|
||||
const TEST_ACCOUNTS = [
|
||||
{ label: '管理员', email: 'admin@demo.com', password: 'demo1234' },
|
||||
{ label: '版主', email: 'mod@demo.com', password: 'demo1234' },
|
||||
{ label: '普通用户', email: 'user@demo.com', password: 'demo1234' },
|
||||
];
|
||||
|
||||
export function AuthModal({ onClose }: { onClose: () => void }) {
|
||||
const { login, register } = useAuth();
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const fill = (acc: { email: string; password: string }) => {
|
||||
setMode('login');
|
||||
setEmail(acc.email);
|
||||
setPassword(acc.password);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setBusy(true);
|
||||
try {
|
||||
if (mode === 'login') await login(email, password);
|
||||
else await register(email, password, displayName);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '操作失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" onClick={(e) => e.stopPropagation()} data-testid="auth-modal">
|
||||
<div className="modal-tabs">
|
||||
<button
|
||||
className={mode === 'login' ? 'active' : ''}
|
||||
onClick={() => setMode('login')}
|
||||
>
|
||||
登录
|
||||
</button>
|
||||
<button
|
||||
className={mode === 'register' ? 'active' : ''}
|
||||
onClick={() => setMode('register')}
|
||||
>
|
||||
注册
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="auth-form">
|
||||
{mode === 'register' && (
|
||||
<input
|
||||
placeholder="昵称"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="密码(至少 6 位)"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||
required
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<button type="submit" className="auth-submit" disabled={busy}>
|
||||
{busy ? '处理中…' : mode === 'login' ? '登录' : '注册并登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{mode === 'login' && (
|
||||
<div className="test-accounts">
|
||||
<span className="muted">测试账号(点击填入,密码 demo1234):</span>
|
||||
<div className="test-account-btns">
|
||||
{TEST_ACCOUNTS.map((a) => (
|
||||
<button
|
||||
key={a.email}
|
||||
type="button"
|
||||
className="test-account"
|
||||
onClick={() => fill(a)}
|
||||
>
|
||||
<b>{a.label}</b>
|
||||
<small>{a.email}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭">
|
||||
<IconClose size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { Button } from './ui';
|
||||
import { IconClose } from './icons';
|
||||
import type { EditableField, ModelDetail } from '../types';
|
||||
|
||||
const STATUS_ENUM = ['现役', '封存', '半封存', '退役', '报废', '保存', '试验', '未知'];
|
||||
const COUNTRY_TYPE_ENUM = ['国产', '进口', '引进仿制', '中外合资', '未知'];
|
||||
|
||||
export function EditModal({
|
||||
model,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
model: ModelDetail;
|
||||
onClose: () => void;
|
||||
onSaved: (msg: string) => void;
|
||||
}) {
|
||||
const [fields, setFields] = useState<EditableField[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [note, setNote] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.editableFields().then((fs) => {
|
||||
setFields(fs);
|
||||
const init: Record<string, string> = {};
|
||||
for (const f of fs) {
|
||||
const v = (model as any)[f.field];
|
||||
init[f.field] = v == null ? '' : String(v);
|
||||
}
|
||||
setValues(init);
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
const changes: Record<string, string> = {};
|
||||
for (const f of fields) {
|
||||
const cur = (model as any)[f.field];
|
||||
const curStr = cur == null ? '' : String(cur);
|
||||
if (values[f.field] !== curStr) changes[f.field] = values[f.field];
|
||||
}
|
||||
if (Object.keys(changes).length === 0) {
|
||||
setError('没有任何改动');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const rev = await api.submitRevision(model.id, changes, note);
|
||||
onSaved(
|
||||
rev.status === 'approved'
|
||||
? '修改已通过并生效(你的权限可直接生效)'
|
||||
: '修改已提交,等待版主审核',
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '提交失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="modal modal-wide"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
data-testid="edit-modal"
|
||||
>
|
||||
<h2>编辑「{model.model_code}」</h2>
|
||||
<p className="muted edit-hint">
|
||||
提交后进入审核(信任用户及以上可直接生效)。修改有完整历史,可回溯。
|
||||
</p>
|
||||
<form onSubmit={submit} className="edit-form">
|
||||
<div className="edit-grid">
|
||||
{fields.map((f) => (
|
||||
<label key={f.field} className="edit-field">
|
||||
<span>{f.label}</span>
|
||||
{f.field === 'status' ? (
|
||||
<select
|
||||
value={values[f.field] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{STATUS_ENUM.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.field === 'country_type' ? (
|
||||
<select
|
||||
value={values[f.field] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))}
|
||||
>
|
||||
<option value="">—</option>
|
||||
{COUNTRY_TYPE_ENUM.map((s) => (
|
||||
<option key={s} value={s}>{s}</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === 'int' ? 'number' : 'text'}
|
||||
value={values[f.field] ?? ''}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [f.field]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
className="edit-note"
|
||||
placeholder="修改说明 / 来源(可选)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<div className="edit-actions">
|
||||
<Button type="button" variant="secondary" onClick={onClose}>取消</Button>
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{busy ? '提交中…' : '提交修改'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<button className="modal-close" onClick={onClose} aria-label="关闭"><IconClose size={18} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { EvolutionPoint } from '../lib/eras';
|
||||
|
||||
/** 时速演进曲线(SVG):横轴年代、纵轴最高时速,体现"科学感"主线。*/
|
||||
export function EvolutionCurve({ points }: { points: EvolutionPoint[] }) {
|
||||
if (points.length < 2) return null;
|
||||
const W = 720;
|
||||
const H = 180;
|
||||
const padX = 36;
|
||||
const padY = 24;
|
||||
const decades = points.map((p) => p.decade);
|
||||
const speeds = points.map((p) => p.maxSpeed);
|
||||
const minD = Math.min(...decades);
|
||||
const maxD = Math.max(...decades);
|
||||
const maxS = Math.max(...speeds);
|
||||
const spanD = Math.max(1, maxD - minD);
|
||||
|
||||
const x = (d: number) => padX + ((d - minD) / spanD) * (W - padX * 2);
|
||||
const y = (s: number) => H - padY - (s / maxS) * (H - padY * 2);
|
||||
|
||||
const line = points.map((p) => `${x(p.decade)},${y(p.maxSpeed)}`).join(' ');
|
||||
const area = `${padX},${H - padY} ${line} ${W - padX},${H - padY}`;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="evo"
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label="中国机车最高时速演进曲线"
|
||||
data-testid="evolution-curve"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="evoFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stopColor="#4ea1ff" stopOpacity="0.35" />
|
||||
<stop offset="1" stopColor="#4ea1ff" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points={area} fill="url(#evoFill)" />
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="#4ea1ff"
|
||||
strokeWidth="2.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{points.map((p) => (
|
||||
<g key={p.decade}>
|
||||
<circle cx={x(p.decade)} cy={y(p.maxSpeed)} r="3.5" fill="#9ed0ff" />
|
||||
{(p.decade === minD ||
|
||||
p.decade === maxD ||
|
||||
p.maxSpeed === maxS) && (
|
||||
<text
|
||||
x={x(p.decade)}
|
||||
y={y(p.maxSpeed) - 10}
|
||||
fontSize="11"
|
||||
fill="#cdd5e0"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{p.maxSpeed}km/h
|
||||
</text>
|
||||
)}
|
||||
<text
|
||||
x={x(p.decade)}
|
||||
y={H - 6}
|
||||
fontSize="10"
|
||||
fill="#8b93a1"
|
||||
textAnchor="middle"
|
||||
>
|
||||
{p.decade}s
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Category, ModelQuery } from '../types';
|
||||
|
||||
const STATUSES = ['现役', '封存', '半封存', '退役', '报废', '试验', '保存', '未知'];
|
||||
const COUNTRIES = ['国产', '进口', '引进仿制', '中外合资', '未知'];
|
||||
|
||||
export function FilterBar({
|
||||
categories,
|
||||
query,
|
||||
onChange,
|
||||
}: {
|
||||
categories: Category[];
|
||||
query: ModelQuery;
|
||||
onChange: (patch: Partial<ModelQuery>) => void;
|
||||
}) {
|
||||
const catNames = Array.from(new Set(categories.map((c) => c.name)));
|
||||
return (
|
||||
<div className="filterbar">
|
||||
<select
|
||||
value={query.category ?? ''}
|
||||
onChange={(e) => onChange({ category: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">全部分类</option>
|
||||
{catNames.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={query.status ?? ''}
|
||||
onChange={(e) => onChange({ status: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={query.country ?? ''}
|
||||
onChange={(e) => onChange({ country: e.target.value || undefined })}
|
||||
>
|
||||
<option value="">全部国别</option>
|
||||
{COUNTRIES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
placeholder="起始年"
|
||||
value={query.yearFrom ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ yearFrom: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="截止年"
|
||||
value={query.yearTo ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ yearTo: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="时速≥"
|
||||
value={query.speedMin ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ speedMin: e.target.value ? Number(e.target.value) : undefined })
|
||||
}
|
||||
/>
|
||||
|
||||
<select
|
||||
value={`${query.sort}:${query.order}`}
|
||||
onChange={(e) => {
|
||||
const [sort, order] = e.target.value.split(':');
|
||||
onChange({ sort, order: order as 'asc' | 'desc' });
|
||||
}}
|
||||
>
|
||||
<option value="first_year:asc">年代 ↑</option>
|
||||
<option value="first_year:desc">年代 ↓</option>
|
||||
<option value="max_speed_value:desc">时速 ↓</option>
|
||||
<option value="max_speed_value:asc">时速 ↑</option>
|
||||
<option value="model_code:asc">型号 A→Z</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { GalleryCard } from './GalleryCard';
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
const m = {
|
||||
id: 7,
|
||||
model_code: 'CR400AF',
|
||||
full_name: '复兴号',
|
||||
series: '',
|
||||
manufacturer: '中车四方',
|
||||
country: '中国',
|
||||
country_type: '国产',
|
||||
first_year: 2017,
|
||||
last_year: null,
|
||||
status: '现役',
|
||||
usage: '高速客运',
|
||||
max_speed_value: 350,
|
||||
max_speed_unit: 'km/h',
|
||||
weight_value: null,
|
||||
weight_unit: '',
|
||||
axle_arrangement: '',
|
||||
category: '动车组',
|
||||
subcat: '复兴号',
|
||||
} as ModelListItem;
|
||||
|
||||
const renderCard = (collected: boolean, onToggle = vi.fn()) =>
|
||||
render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<GalleryCard m={m} index={1} collected={collected} onToggle={onToggle} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('GalleryCard', () => {
|
||||
it('展示缩略图、编号、收藏按钮', () => {
|
||||
renderCard(false);
|
||||
expect(screen.getByRole('img', { name: /CR400AF/ })).toBeInTheDocument();
|
||||
expect(screen.getByText('#001')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: '收藏' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('已收藏:按钮变为已收藏', () => {
|
||||
renderCard(true);
|
||||
expect(screen.getByRole('button', { name: '已收藏' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('点击收藏触发 onToggle(id)', () => {
|
||||
const onToggle = vi.fn();
|
||||
renderCard(false, onToggle);
|
||||
fireEvent.click(screen.getByRole('button', { name: '收藏' }));
|
||||
expect(onToggle).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it('缩略图链接指向详情', () => {
|
||||
renderCard(false);
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.some((l) => l.getAttribute('href') === '/models/7')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ModelListItem } from '../types';
|
||||
import { fmtEra } from '../lib/format';
|
||||
import { Thumb } from './Thumb';
|
||||
import { IconStarFilled, IconStarOutline } from './icons';
|
||||
|
||||
/**
|
||||
* 图鉴卡:封面优先级 共享图库封面(cover_url,本地图库) > 分类示意图。
|
||||
* 真实照片已批量下载到本地共享图库,不再实时访问外部图源;★ 为个人收藏书签。
|
||||
*/
|
||||
export function GalleryCard({
|
||||
m,
|
||||
index,
|
||||
collected,
|
||||
onToggle,
|
||||
}: {
|
||||
m: ModelListItem;
|
||||
index: number;
|
||||
collected: boolean;
|
||||
onToggle: (id: number) => void;
|
||||
}) {
|
||||
const no = `#${String(index).padStart(3, '0')}`;
|
||||
const cover = m.cover_url ?? null;
|
||||
|
||||
return (
|
||||
<div className={`gcard${collected ? ' collected' : ''}${cover ? ' bright' : ''}`} data-testid="gallery-card">
|
||||
<span className="gcard-no">{no}</span>
|
||||
<button
|
||||
className="gcard-collect"
|
||||
aria-pressed={collected}
|
||||
aria-label={collected ? '已收藏' : '收藏'}
|
||||
title={collected ? '已收藏' : '收藏'}
|
||||
onClick={() => onToggle(m.id)}
|
||||
>
|
||||
{collected ? <IconStarFilled size={16} /> : <IconStarOutline size={16} />}
|
||||
</button>
|
||||
<Link to={`/models/${m.id}`} className="gcard-art">
|
||||
{cover ? (
|
||||
<img src={cover} alt={`${m.model_code} 照片`} loading="lazy" />
|
||||
) : (
|
||||
<Thumb category={m.category} code={m.model_code} />
|
||||
)}
|
||||
<span className="gcard-caption">
|
||||
<span className="gcard-code">{m.model_code}</span>
|
||||
<span className="gcard-meta">
|
||||
{m.category} · {fmtEra(m.first_year, m.last_year)}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ModelListItem } from '../types';
|
||||
import { GalleryCard } from './GalleryCard';
|
||||
import { Pagination } from './Pagination';
|
||||
|
||||
export const GALLERY_PAGE_SIZE = 21;
|
||||
|
||||
/**
|
||||
* 图鉴卡牌视图(T-1.7)。图片优先的卡牌墙 + 收集进度 + 分页(21/页,分页器在上)。
|
||||
* 分页受控(page/onPage 由上层管理,落在 URL,便于详情返回后回到原页)。
|
||||
*/
|
||||
export function GalleryView({
|
||||
models,
|
||||
collectedIds,
|
||||
onToggle,
|
||||
page,
|
||||
onPage,
|
||||
}: {
|
||||
models: ModelListItem[];
|
||||
collectedIds: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
page: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const collectedHere = models.filter((m) => collectedIds.has(m.id)).length;
|
||||
const pct = models.length ? Math.round((collectedHere / models.length) * 100) : 0;
|
||||
const pageCount = Math.max(1, Math.ceil(models.length / GALLERY_PAGE_SIZE));
|
||||
const safePage = Math.min(Math.max(1, page), pageCount);
|
||||
const start = (safePage - 1) * GALLERY_PAGE_SIZE;
|
||||
const pageItems = models.slice(start, start + GALLERY_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div data-testid="gallery">
|
||||
<div className="collect-progress">
|
||||
<span>
|
||||
已收集 <strong>{collectedHere}</strong> / {models.length}(当前筛选)
|
||||
</span>
|
||||
<div className="progress-track">
|
||||
<div className="progress-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分页器在上 */}
|
||||
<Pagination
|
||||
total={models.length}
|
||||
page={safePage}
|
||||
pageSize={GALLERY_PAGE_SIZE}
|
||||
onPage={onPage}
|
||||
/>
|
||||
|
||||
<div className="gallery">
|
||||
{pageItems.map((m, i) => (
|
||||
<GalleryCard
|
||||
key={m.id}
|
||||
m={m}
|
||||
index={start + i + 1}
|
||||
collected={collectedIds.has(m.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type { GalleryImage } from '../types';
|
||||
import { IconClose, IconPrev, IconNext, IconZoomIn, IconZoomOut, IconReset } from './icons';
|
||||
|
||||
/** 图片灯箱:缩放(按钮/滚轮)、平移(拖拽)、上一张/下一张、Esc 关闭,含署名。*/
|
||||
export function Lightbox({
|
||||
images,
|
||||
index,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
}: {
|
||||
images: GalleryImage[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onIndexChange: (i: number) => void;
|
||||
}) {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [drag, setDrag] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setScale(1);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
const prev = useCallback(() => {
|
||||
reset();
|
||||
onIndexChange((index - 1 + images.length) % images.length);
|
||||
}, [index, images.length, onIndexChange, reset]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
reset();
|
||||
onIndexChange((index + 1) % images.length);
|
||||
}, [index, images.length, onIndexChange, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
else if (e.key === 'ArrowLeft') prev();
|
||||
else if (e.key === 'ArrowRight') next();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
const img = images[index];
|
||||
if (!img) return null;
|
||||
|
||||
const zoom = (delta: number) =>
|
||||
setScale((s) => Math.min(5, Math.max(1, +(s + delta).toFixed(2))));
|
||||
|
||||
return (
|
||||
<div className="lb" onClick={onClose} data-testid="lightbox">
|
||||
<div className="lb-stage" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.caption}
|
||||
className="lb-img"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`,
|
||||
cursor: scale > 1 ? (drag ? 'grabbing' : 'grab') : 'auto',
|
||||
}}
|
||||
draggable={false}
|
||||
onWheel={(e) => zoom(e.deltaY < 0 ? 0.2 : -0.2)}
|
||||
onMouseDown={(e) =>
|
||||
scale > 1 && setDrag({ x: e.clientX - offset.x, y: e.clientY - offset.y })
|
||||
}
|
||||
onMouseMove={(e) =>
|
||||
drag && setOffset({ x: e.clientX - drag.x, y: e.clientY - drag.y })
|
||||
}
|
||||
onMouseUp={() => setDrag(null)}
|
||||
onMouseLeave={() => setDrag(null)}
|
||||
/>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button className="lb-nav lb-prev" onClick={prev} aria-label="上一张">
|
||||
<IconPrev size={32} />
|
||||
</button>
|
||||
<button className="lb-nav lb-next" onClick={next} aria-label="下一张">
|
||||
<IconNext size={32} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="lb-toolbar">
|
||||
<button onClick={() => zoom(-0.3)} aria-label="缩小">
|
||||
<IconZoomOut size={18} />
|
||||
</button>
|
||||
<span>{Math.round(scale * 100)}%</span>
|
||||
<button onClick={() => zoom(0.3)} aria-label="放大">
|
||||
<IconZoomIn size={18} />
|
||||
</button>
|
||||
<button onClick={reset} aria-label="重置">
|
||||
<IconReset size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="lb-caption">
|
||||
<span>
|
||||
{img.caption}
|
||||
{images.length > 1 ? ` · ${index + 1}/${images.length}` : ''}
|
||||
</span>
|
||||
{img.attribution && (
|
||||
<span className="lb-attr">
|
||||
© {img.attribution.author || '未署名'}
|
||||
{img.attribution.license ? ` · ${img.attribution.license}` : ''}
|
||||
{img.attribution.url ? (
|
||||
<>
|
||||
{' · '}
|
||||
<a href={img.attribution.url} target="_blank" rel="noreferrer noopener">
|
||||
来源
|
||||
</a>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button className="lb-close" onClick={onClose} aria-label="关闭">
|
||||
<IconClose size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ModelCard } from './ModelCard';
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
const m: ModelListItem = {
|
||||
id: 42,
|
||||
model_code: 'HXD1型',
|
||||
full_name: '',
|
||||
series: '和谐',
|
||||
manufacturer: '中车株洲',
|
||||
country: '中国',
|
||||
country_type: '国产',
|
||||
first_year: 2006,
|
||||
last_year: null,
|
||||
status: '现役',
|
||||
usage: '干线货运',
|
||||
max_speed_value: 120,
|
||||
max_speed_unit: 'km/h',
|
||||
weight_value: 150,
|
||||
weight_unit: 't',
|
||||
axle_arrangement: 'Co-Co',
|
||||
category: '电力机车',
|
||||
subcat: '',
|
||||
};
|
||||
|
||||
describe('ModelCard', () => {
|
||||
const renderCard = (item: ModelListItem) =>
|
||||
render(
|
||||
<MemoryRouter
|
||||
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
|
||||
>
|
||||
<ModelCard m={item} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
it('展示型号/分类/参数', () => {
|
||||
renderCard(m);
|
||||
expect(screen.getByText('HXD1型')).toBeInTheDocument();
|
||||
expect(screen.getByText('120km/h')).toBeInTheDocument();
|
||||
expect(screen.getByText('现役')).toBeInTheDocument();
|
||||
expect(screen.getByText(/中车株洲/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('链接指向详情页', () => {
|
||||
renderCard(m);
|
||||
const link = screen.getByTestId('model-card');
|
||||
expect(link).toHaveAttribute('href', '/models/42');
|
||||
});
|
||||
|
||||
it('缺失数值优雅留白', () => {
|
||||
renderCard({ ...m, max_speed_value: null });
|
||||
expect(screen.getByText('—')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ModelListItem } from '../types';
|
||||
import { fmtEra, fmtValueUnit, statusClass } from '../lib/format';
|
||||
|
||||
export function ModelCard({ m }: { m: ModelListItem }) {
|
||||
return (
|
||||
<Link to={`/models/${m.id}`} className="card" data-testid="model-card">
|
||||
<div className="card-head">
|
||||
<span className="card-code">{m.model_code}</span>
|
||||
<span className={statusClass(m.status)}>{m.status}</span>
|
||||
</div>
|
||||
<div className="card-cat">
|
||||
{m.category}
|
||||
{m.subcat ? ` · ${m.subcat}` : ''}
|
||||
{m.series ? ` · ${m.series}` : ''}
|
||||
</div>
|
||||
<dl className="card-spec">
|
||||
<div>
|
||||
<dt>年代</dt>
|
||||
<dd>{fmtEra(m.first_year, m.last_year)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>最高时速</dt>
|
||||
<dd>{fmtValueUnit(m.max_speed_value, m.max_speed_unit)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>整备重量</dt>
|
||||
<dd>{fmtValueUnit(m.weight_value, m.weight_unit)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>国别</dt>
|
||||
<dd>{m.country_type || '—'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="card-maker">{m.manufacturer || '生产商未知'}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { IconCaret } from './icons';
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
}
|
||||
interface NavGroup {
|
||||
label: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
const GROUPS: NavGroup[] = [
|
||||
{
|
||||
label: '图鉴',
|
||||
items: [
|
||||
{ to: '/explore', label: '探索图鉴' },
|
||||
{ to: '/stats', label: '数据大屏' },
|
||||
{ to: '/family', label: '技术族谱' },
|
||||
{ to: '/compare', label: '参数对比' },
|
||||
{ to: '/identify', label: 'AI 识车' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '社区',
|
||||
items: [
|
||||
{ to: '/community', label: '论坛' },
|
||||
{ to: '/map', label: '打卡地图' },
|
||||
{ to: '/leaderboard', label: '贡献榜' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function NavMenu({ canReview, isAdmin }: { canReview: boolean; isAdmin: boolean }) {
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
const loc = useLocation();
|
||||
|
||||
// 路由变化时关闭
|
||||
useEffect(() => setOpen(null), [loc.pathname]);
|
||||
|
||||
// 点击外部 / Esc 关闭
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(null);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(null);
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<nav className="mainnav" ref={ref}>
|
||||
<NavLink to="/" end className="nav-top">
|
||||
故事
|
||||
</NavLink>
|
||||
|
||||
{GROUPS.map((g) => {
|
||||
const active = g.items.some((it) => loc.pathname.startsWith(it.to));
|
||||
return (
|
||||
<div className="nav-group" key={g.label}>
|
||||
<button
|
||||
className={`nav-top nav-trigger${active ? ' active' : ''}`}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open === g.label}
|
||||
onClick={() => setOpen((o) => (o === g.label ? null : g.label))}
|
||||
>
|
||||
{g.label}
|
||||
<span className="caret"><IconCaret size={16} /></span>
|
||||
</button>
|
||||
{open === g.label && (
|
||||
<div className="nav-dropdown" role="menu">
|
||||
{g.items.map((it) => (
|
||||
<NavLink key={it.to} to={it.to} className="nav-item" role="menuitem">
|
||||
{it.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{canReview && (
|
||||
<NavLink to="/review" className="nav-top">
|
||||
审核
|
||||
</NavLink>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<NavLink to="/admin/photos" className="nav-top">
|
||||
候选审图
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function Pagination({
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
onPage,
|
||||
}: {
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPage: (p: number) => void;
|
||||
}) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<div className="pagination">
|
||||
<button disabled={page <= 1} onClick={() => onPage(page - 1)}>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
第 {page} / {pages} 页 · 共 {total} 条
|
||||
</span>
|
||||
<button disabled={page >= pages} onClick={() => onPage(page + 1)}>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CompareAxis, NormalizedSeries } from '../lib/compare';
|
||||
|
||||
const COLORS = ['#4ea1ff', '#4ade80', '#fbbf24', '#c084fc'];
|
||||
|
||||
export function RadarChart({
|
||||
axes,
|
||||
series,
|
||||
}: {
|
||||
axes: CompareAxis[];
|
||||
series: NormalizedSeries[];
|
||||
}) {
|
||||
const size = 360;
|
||||
const c = size / 2;
|
||||
const r = size / 2 - 60;
|
||||
const n = axes.length;
|
||||
const angle = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
|
||||
const pt = (i: number, radius: number) => ({
|
||||
x: c + radius * Math.cos(angle(i)),
|
||||
y: c + radius * Math.sin(angle(i)),
|
||||
});
|
||||
|
||||
const rings = [0.25, 0.5, 0.75, 1];
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="radar"
|
||||
role="img"
|
||||
aria-label="参数对比雷达图"
|
||||
data-testid="radar"
|
||||
>
|
||||
{/* 网格环 */}
|
||||
{rings.map((ring) => (
|
||||
<polygon
|
||||
key={ring}
|
||||
points={axes
|
||||
.map((_, i) => {
|
||||
const p = pt(i, r * ring);
|
||||
return `${p.x},${p.y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke="#2a2f3a"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
{/* 轴线 + 标签 */}
|
||||
{axes.map((a, i) => {
|
||||
const edge = pt(i, r);
|
||||
const label = pt(i, r + 26);
|
||||
return (
|
||||
<g key={a.key}>
|
||||
<line x1={c} y1={c} x2={edge.x} y2={edge.y} stroke="#2a2f3a" />
|
||||
<text
|
||||
x={label.x}
|
||||
y={label.y}
|
||||
fontSize="11"
|
||||
fill="#8b93a1"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{a.label}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{/* 数据多边形 */}
|
||||
{series.map((s, si) => {
|
||||
const color = COLORS[si % COLORS.length];
|
||||
const pts = s.points
|
||||
.map((p, i) => {
|
||||
const pp = pt(i, r * p.norm);
|
||||
return `${pp.x},${pp.y}`;
|
||||
})
|
||||
.join(' ');
|
||||
return (
|
||||
<polygon
|
||||
key={s.id}
|
||||
points={pts}
|
||||
fill={color}
|
||||
fillOpacity="0.15"
|
||||
stroke={color}
|
||||
strokeWidth="2"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export { COLORS as RADAR_COLORS };
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Revision } from '../types';
|
||||
import { labelOf } from '../lib/fieldLabels';
|
||||
import { Button } from './ui';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待审核',
|
||||
approved: '已通过',
|
||||
rejected: '已驳回',
|
||||
};
|
||||
const STATUS_CLS: Record<string, string> = {
|
||||
pending: 'tag tag-amber',
|
||||
approved: 'tag tag-green',
|
||||
rejected: 'tag tag-gray',
|
||||
};
|
||||
|
||||
export function RevisionList({
|
||||
revisions,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
revisions: Revision[];
|
||||
onApprove?: (id: number) => void;
|
||||
onReject?: (id: number) => void;
|
||||
}) {
|
||||
if (revisions.length === 0)
|
||||
return <p className="muted">暂无修订记录。</p>;
|
||||
return (
|
||||
<ul className="rev-list">
|
||||
{revisions.map((r) => (
|
||||
<li key={r.id} className="rev-item">
|
||||
<div className="rev-head">
|
||||
<span className={STATUS_CLS[r.status]}>{STATUS_LABEL[r.status]}</span>
|
||||
<span className="rev-author">{r.author_name}</span>
|
||||
<span className="muted rev-date">
|
||||
{new Date(r.created_at + 'Z').toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
{r.note && <p className="rev-note">{r.note}</p>}
|
||||
<ul className="rev-changes">
|
||||
{r.changes.map((c) => (
|
||||
<li key={c.field}>
|
||||
<b>{labelOf(c.field)}</b>:
|
||||
<span className="old">{c.old_value || '—'}</span>
|
||||
{' → '}
|
||||
<span className="new">{c.new_value || '—'}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{r.status === 'pending' && (onApprove || onReject) && (
|
||||
<div className="rev-actions">
|
||||
{onApprove && (
|
||||
<Button variant="primary" size="sm" onClick={() => onApprove(r.id)}>
|
||||
通过
|
||||
</Button>
|
||||
)}
|
||||
{onReject && (
|
||||
<Button variant="danger" size="sm" onClick={() => onReject(r.id)}>
|
||||
驳回
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import type { SearchResult } from '../types';
|
||||
|
||||
export function SearchBox() {
|
||||
const [q, setQ] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const timer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
clearTimeout(timer.current);
|
||||
if (!q.trim()) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
timer.current = setTimeout(async () => {
|
||||
try {
|
||||
const res = await api.search(q.trim(), 8);
|
||||
setResults(res.results);
|
||||
setOpen(true);
|
||||
} catch {
|
||||
setResults([]);
|
||||
}
|
||||
}, 250);
|
||||
return () => clearTimeout(timer.current);
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="searchbox">
|
||||
<input
|
||||
type="search"
|
||||
placeholder="搜索型号 / 生产商 / 系列…"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onFocus={() => results.length && setOpen(true)}
|
||||
/>
|
||||
{open && results.length > 0 && (
|
||||
<ul className="search-dropdown">
|
||||
{results.map((r) => (
|
||||
<li
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setQ('');
|
||||
navigate(`/models/${r.id}`);
|
||||
}}
|
||||
>
|
||||
<strong>{r.model_code}</strong>
|
||||
<span className="muted"> · {r.category}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { gradientCss } from '../lib/thumb';
|
||||
import { CategoryIcon } from './icons';
|
||||
|
||||
/**
|
||||
* 缩略图:分类配色渐变 + 专业图标。替代原 emoji/data-URI 方案。
|
||||
* role=img + aria-label 保证可访问性与可测试性。
|
||||
*/
|
||||
export function Thumb({
|
||||
category,
|
||||
code,
|
||||
size = 'md',
|
||||
}: {
|
||||
category: string;
|
||||
code: string;
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`thumb thumb-${size}`}
|
||||
style={{ background: gradientCss(category) }}
|
||||
role="img"
|
||||
aria-label={`${code} 缩略图`}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={category}
|
||||
size={size === 'sm' ? 30 : 46}
|
||||
className="thumb-glyph"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { ModelListItem } from '../types';
|
||||
import { axisTicks, buildTimeline } from '../lib/timeline';
|
||||
|
||||
export function TimelineView({ models }: { models: ModelListItem[] }) {
|
||||
const navigate = useNavigate();
|
||||
const layout = buildTimeline(models);
|
||||
|
||||
if (layout.lanes.length === 0) {
|
||||
return <p className="muted">当前结果无可定位年代的数据。</p>;
|
||||
}
|
||||
const ticks = axisTicks(layout.minYear, layout.maxYear);
|
||||
const span = Math.max(1, layout.maxYear - layout.minYear);
|
||||
|
||||
return (
|
||||
<div className="timeline" data-testid="timeline">
|
||||
<div className="timeline-axis">
|
||||
{ticks.map((y) => (
|
||||
<span
|
||||
key={y}
|
||||
className="tick"
|
||||
style={{ left: `${((y - layout.minYear) / span) * 100}%` }}
|
||||
>
|
||||
{y}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{layout.lanes.map((lane) => (
|
||||
<div className="lane" key={lane.category}>
|
||||
<div className="lane-label">{lane.category}</div>
|
||||
<div
|
||||
className="lane-track"
|
||||
style={{ height: `${Math.max(34, lane.rows * 16 + 10)}px` }}
|
||||
>
|
||||
{lane.nodes.map((n) => (
|
||||
<button
|
||||
key={n.m.id}
|
||||
className="node"
|
||||
style={{
|
||||
left: `${n.xPct}%`,
|
||||
top: `${10 + n.row * 16}px`,
|
||||
}}
|
||||
title={`${n.m.model_code}(${n.year})`}
|
||||
onClick={() => navigate(`/models/${n.m.id}`)}
|
||||
>
|
||||
<span className="node-dot" />
|
||||
<span className="node-label">{n.m.model_code}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{layout.undated.length > 0 && (
|
||||
<p className="muted">另有 {layout.undated.length} 个车型缺年代信息,未在轴上显示。</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { IconType } from 'react-icons';
|
||||
import { FaTrain } from 'react-icons/fa6';
|
||||
import { GiSteamLocomotive, GiCoalWagon } from 'react-icons/gi';
|
||||
import {
|
||||
MdTrain,
|
||||
MdDirectionsRailway,
|
||||
MdEventSeat,
|
||||
MdTroubleshoot,
|
||||
MdTour,
|
||||
MdLock,
|
||||
MdStar,
|
||||
MdStarOutline,
|
||||
MdGridView,
|
||||
MdTimeline,
|
||||
MdFilterList,
|
||||
MdEdit,
|
||||
MdBalance,
|
||||
MdCheck,
|
||||
MdClose,
|
||||
MdDelete,
|
||||
MdHandyman,
|
||||
MdMyLocation,
|
||||
MdArrowBack,
|
||||
MdArrowForward,
|
||||
MdWarningAmber,
|
||||
MdArrowDropDown,
|
||||
MdForum,
|
||||
MdImage,
|
||||
MdEmojiEvents,
|
||||
MdPlace,
|
||||
MdChevronLeft,
|
||||
MdChevronRight,
|
||||
MdZoomIn,
|
||||
MdZoomOut,
|
||||
MdRefresh,
|
||||
} from 'react-icons/md';
|
||||
|
||||
/** 分类 → 专业图标(react-icons),统一替代 emoji。*/
|
||||
const CATEGORY_ICON: Record<string, IconType> = {
|
||||
蒸汽机车: GiSteamLocomotive,
|
||||
内燃机车: FaTrain,
|
||||
电力机车: MdDirectionsRailway,
|
||||
动车组: MdTrain,
|
||||
客车: MdEventSeat,
|
||||
货车: GiCoalWagon,
|
||||
检测车: MdTroubleshoot,
|
||||
旅游列车: MdTour,
|
||||
};
|
||||
|
||||
export function categoryIconType(category: string): IconType {
|
||||
return CATEGORY_ICON[category] ?? FaTrain;
|
||||
}
|
||||
|
||||
export function CategoryIcon({
|
||||
category,
|
||||
size = 20,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
category: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const Icon = categoryIconType(category);
|
||||
return <Icon size={size} className={className} title={title ?? category} />;
|
||||
}
|
||||
|
||||
// UI 图标(统一出口,便于替换/复用)
|
||||
export const IconBrand = MdTrain;
|
||||
export const IconLock = MdLock;
|
||||
export const IconStarFilled = MdStar;
|
||||
export const IconStarOutline = MdStarOutline;
|
||||
export const IconGallery = MdGridView;
|
||||
export const IconTimeline = MdTimeline;
|
||||
export const IconFilter = MdFilterList;
|
||||
export const IconEdit = MdEdit;
|
||||
export const IconCompare = MdBalance;
|
||||
export const IconConfirm = MdCheck;
|
||||
export const IconClose = MdClose;
|
||||
export const IconDelete = MdDelete;
|
||||
export const IconMaintain = MdHandyman;
|
||||
export const IconLocation = MdMyLocation;
|
||||
export const IconBack = MdArrowBack;
|
||||
export const IconForward = MdArrowForward;
|
||||
export const IconWarning = MdWarningAmber;
|
||||
export const IconCaret = MdArrowDropDown;
|
||||
export const IconForum = MdForum;
|
||||
export const IconImage = MdImage;
|
||||
export const IconTrophy = MdEmojiEvents;
|
||||
export const IconStation = MdPlace;
|
||||
export const IconPrev = MdChevronLeft;
|
||||
export const IconNext = MdChevronRight;
|
||||
export const IconZoomIn = MdZoomIn;
|
||||
export const IconZoomOut = MdZoomOut;
|
||||
export const IconReset = MdRefresh;
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { IconStation } from './icons';
|
||||
|
||||
/** 统一按钮:variant = primary / secondary / ghost / danger,size = sm/md。*/
|
||||
export function Button({
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
className = '',
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md';
|
||||
}) {
|
||||
return <button className={`btn btn-${variant} btn-${size} ${className}`} {...rest} />;
|
||||
}
|
||||
|
||||
/** 统一页头:标题 + 副标题 + 右侧操作区。*/
|
||||
export function PageHeader({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="page-sub">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="page-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一空状态。*/
|
||||
export function EmptyState({
|
||||
icon,
|
||||
text,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
text: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="empty-icon">{icon ?? <IconStation size={30} />}</span>
|
||||
<p>{text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 统一标签。*/
|
||||
export function Tag({
|
||||
children,
|
||||
tone = 'default',
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: 'default' | 'green' | 'amber' | 'blue' | 'purple';
|
||||
}) {
|
||||
return <span className={`tag tag-${tone}`}>{children}</span>;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { api, clearToken, getToken, setToken } from '../api/client';
|
||||
import type { PublicUser } from '../types';
|
||||
|
||||
interface AuthState {
|
||||
user: PublicUser | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (email: string, password: string, displayName: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
const AuthCtx = createContext<AuthState | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<PublicUser | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
api.auth
|
||||
.me()
|
||||
.then(setUser)
|
||||
.catch(() => clearToken())
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const res = await api.auth.login(email, password);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
}, []);
|
||||
|
||||
const register = useCallback(
|
||||
async (email: string, password: string, displayName: string) => {
|
||||
const res = await api.auth.register(email, password, displayName);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthCtx.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth(): AuthState {
|
||||
const ctx = useContext(AuthCtx);
|
||||
if (!ctx) throw new Error('useAuth 必须在 AuthProvider 内使用');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export const ROLE_LABEL: Record<string, string> = {
|
||||
user: '注册用户',
|
||||
trusted: '信任用户',
|
||||
moderator: '版主',
|
||||
admin: '管理员',
|
||||
guest: '游客',
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { COMPARE_AXES, normalizeForRadar } from './compare';
|
||||
import type { ModelDetail } from '../types';
|
||||
|
||||
const mk = (id: number, over: Record<string, unknown>): ModelDetail =>
|
||||
({ id, model_code: `M${id}`, ...over }) as unknown as ModelDetail;
|
||||
|
||||
describe('normalizeForRadar', () => {
|
||||
it('按每轴最大值归一', () => {
|
||||
const res = normalizeForRadar([
|
||||
mk(1, { max_speed_value: 200, weight_value: 100 }),
|
||||
mk(2, { max_speed_value: 400, weight_value: 50 }),
|
||||
]);
|
||||
// 第一轴 max_speed:200/400=0.5,400/400=1
|
||||
expect(res[0].points[0].norm).toBeCloseTo(0.5);
|
||||
expect(res[1].points[0].norm).toBe(1);
|
||||
// 第二轴 weight:100/100=1,50/100=0.5
|
||||
expect(res[0].points[1].norm).toBe(1);
|
||||
expect(res[1].points[1].norm).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it('缺失值 norm=0 且 raw=null', () => {
|
||||
const res = normalizeForRadar([mk(1, { max_speed_value: 300 })]);
|
||||
const weightIdx = COMPARE_AXES.findIndex((a) => a.key === 'weight_value');
|
||||
expect(res[0].points[weightIdx].raw).toBeNull();
|
||||
expect(res[0].points[weightIdx].norm).toBe(0);
|
||||
});
|
||||
|
||||
it('某轴全缺失不除零', () => {
|
||||
const res = normalizeForRadar([mk(1, {}), mk(2, {})]);
|
||||
expect(res[0].points.every((p) => p.norm === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { ModelDetail } from '../types';
|
||||
|
||||
export interface CompareAxis {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
/** 参与雷达对比的数值轴(取 model 的 *_value 字段)。*/
|
||||
export const COMPARE_AXES: CompareAxis[] = [
|
||||
{ key: 'max_speed_value', label: '最高时速', unit: 'km/h' },
|
||||
{ key: 'weight_value', label: '整备重量', unit: 't' },
|
||||
{ key: 'axle_load_value', label: '轴重', unit: 't' },
|
||||
{ key: 'tractive_start_value', label: '起动牵引力', unit: 'kN' },
|
||||
{ key: 'tractive_cont_value', label: '持续牵引力', unit: 'kN' },
|
||||
];
|
||||
|
||||
export interface NormalizedPoint {
|
||||
raw: number | null;
|
||||
norm: number; // 0..1(按所选车型每轴最大值归一;缺失=0)
|
||||
}
|
||||
|
||||
export interface NormalizedSeries {
|
||||
id: number;
|
||||
label: string;
|
||||
points: NormalizedPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所选车型在各轴上归一化(每轴除以该轴最大值)。纯函数,便于单测。
|
||||
*/
|
||||
export function normalizeForRadar(
|
||||
models: ModelDetail[],
|
||||
axes: CompareAxis[] = COMPARE_AXES,
|
||||
): NormalizedSeries[] {
|
||||
const max = axes.map((a) =>
|
||||
Math.max(
|
||||
0,
|
||||
...models.map((m) => {
|
||||
const v = (m as any)[a.key];
|
||||
return typeof v === 'number' && Number.isFinite(v) ? v : 0;
|
||||
}),
|
||||
),
|
||||
);
|
||||
return models.map((m) => ({
|
||||
id: m.id,
|
||||
label: m.model_code,
|
||||
points: axes.map((a, i) => {
|
||||
const v = (m as any)[a.key];
|
||||
const raw = typeof v === 'number' && Number.isFinite(v) ? v : null;
|
||||
const norm = raw != null && max[i] > 0 ? raw / max[i] : 0;
|
||||
return { raw, norm };
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeSpeedEvolution, ERAS, pickRepresentatives } from './eras';
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
const mk = (over: Partial<ModelListItem>): ModelListItem =>
|
||||
({
|
||||
id: Math.random(),
|
||||
model_code: 'X',
|
||||
full_name: '',
|
||||
series: '',
|
||||
manufacturer: '',
|
||||
country: '中国',
|
||||
country_type: '国产',
|
||||
first_year: null,
|
||||
last_year: null,
|
||||
status: '未知',
|
||||
usage: '',
|
||||
max_speed_value: null,
|
||||
max_speed_unit: '',
|
||||
weight_value: null,
|
||||
weight_unit: '',
|
||||
axle_arrangement: '',
|
||||
category: '电力机车',
|
||||
subcat: '',
|
||||
...over,
|
||||
}) as ModelListItem;
|
||||
|
||||
describe('ERAS', () => {
|
||||
it('四个时代覆盖主线分类', () => {
|
||||
expect(ERAS.map((e) => e.key)).toEqual(['steam', 'diesel', 'electric', 'hsr']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickRepresentatives', () => {
|
||||
const data = [
|
||||
mk({ id: 1, category: '动车组', model_code: 'A', max_speed_value: 350 }),
|
||||
mk({ id: 2, category: '动车组', model_code: 'B', max_speed_value: 400 }),
|
||||
mk({ id: 3, category: '电力机车', model_code: 'C', max_speed_value: 120 }),
|
||||
];
|
||||
const hsr = ERAS.find((e) => e.key === 'hsr')!;
|
||||
|
||||
it('只取对应分类并按时速降序', () => {
|
||||
const reps = pickRepresentatives(data, hsr, 4);
|
||||
expect(reps.map((m) => m.model_code)).toEqual(['B', 'A']);
|
||||
});
|
||||
|
||||
it('限制数量', () => {
|
||||
expect(pickRepresentatives(data, hsr, 1)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeSpeedEvolution', () => {
|
||||
it('按十年取最高时速并升序', () => {
|
||||
const pts = computeSpeedEvolution([
|
||||
mk({ first_year: 1958, max_speed_value: 100 }),
|
||||
mk({ first_year: 1965, max_speed_value: 120 }),
|
||||
mk({ first_year: 2017, max_speed_value: 350 }),
|
||||
]);
|
||||
expect(pts).toEqual([
|
||||
{ decade: 1950, maxSpeed: 100 },
|
||||
{ decade: 1960, maxSpeed: 120 },
|
||||
{ decade: 2010, maxSpeed: 350 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('忽略缺年代/缺时速', () => {
|
||||
const pts = computeSpeedEvolution([
|
||||
mk({ first_year: null, max_speed_value: 999 }),
|
||||
mk({ first_year: 2000, max_speed_value: null }),
|
||||
]);
|
||||
expect(pts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ModelListItem } from '../types';
|
||||
|
||||
/** 主线:中国机车技术演进的四个时代。*/
|
||||
export interface Era {
|
||||
key: string;
|
||||
title: string;
|
||||
period: string;
|
||||
categories: string[];
|
||||
blurb: string;
|
||||
accent: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export const ERAS: Era[] = [
|
||||
{
|
||||
key: 'steam',
|
||||
title: '蒸汽时代',
|
||||
period: '1881 – 1990s',
|
||||
categories: ['蒸汽机车'],
|
||||
blurb: '钢铁与煤火的咆哮。从进口机车到自主制造的前进型、建设型,蒸汽机车牵引了中国铁路的第一个世纪。',
|
||||
accent: '#a8743f',
|
||||
icon: '🚂',
|
||||
},
|
||||
{
|
||||
key: 'diesel',
|
||||
title: '内燃时代',
|
||||
period: '1958 – 至今',
|
||||
categories: ['内燃机车'],
|
||||
blurb: '东风奔驰。内燃机车以更高效率取代蒸汽,从仿制到东风系列的自主谱系,撑起干线与调车的主力。',
|
||||
accent: '#c8862f',
|
||||
icon: '🛤️',
|
||||
},
|
||||
{
|
||||
key: 'electric',
|
||||
title: '电力时代',
|
||||
period: '1958 – 至今',
|
||||
categories: ['电力机车'],
|
||||
blurb: '从引进法国 8K 到韶山系列国产化,再到和谐号 HXD,电气化让重载与提速成为可能。',
|
||||
accent: '#3a86c8',
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
key: 'hsr',
|
||||
title: '高铁时代',
|
||||
period: '2007 – 至今',
|
||||
categories: ['动车组'],
|
||||
blurb: '和谐号到复兴号,从引进消化到全面自主,CR400 与 CR450 把中国带入全球最快的运营速度。',
|
||||
accent: '#2bb39a',
|
||||
icon: '🚄',
|
||||
},
|
||||
];
|
||||
|
||||
/** 从数据集中挑选某时代的代表车型:优先有真实封面图者,其次按最高时速降序。*/
|
||||
export function pickRepresentatives(
|
||||
models: ModelListItem[],
|
||||
era: Era,
|
||||
n = 4,
|
||||
): ModelListItem[] {
|
||||
const hasCover = (m: ModelListItem) => (m.cover_url ? 1 : 0);
|
||||
return models
|
||||
.filter((m) => era.categories.includes(m.category))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
hasCover(b) - hasCover(a) ||
|
||||
(b.max_speed_value ?? -1) - (a.max_speed_value ?? -1),
|
||||
)
|
||||
.slice(0, n);
|
||||
}
|
||||
|
||||
export interface EvolutionPoint {
|
||||
decade: number;
|
||||
maxSpeed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算"时速演进":按十年取该年代出现车型的最高时速。
|
||||
* 用于首页主线的科学感曲线。纯函数,便于单测。
|
||||
*/
|
||||
export function computeSpeedEvolution(models: ModelListItem[]): EvolutionPoint[] {
|
||||
const byDecade = new Map<number, number>();
|
||||
for (const m of models) {
|
||||
if (typeof m.first_year !== 'number' || m.max_speed_value == null) continue;
|
||||
const decade = Math.floor(m.first_year / 10) * 10;
|
||||
byDecade.set(decade, Math.max(byDecade.get(decade) ?? 0, m.max_speed_value));
|
||||
}
|
||||
return [...byDecade.entries()]
|
||||
.map(([decade, maxSpeed]) => ({ decade, maxSpeed }))
|
||||
.sort((a, b) => a.decade - b.decade);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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('2006–2014'));
|
||||
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'));
|
||||
});
|
||||
@@ -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 ?? ''}`;
|
||||
}
|
||||
|
||||
/** 年代区间展示:2006–2014 / 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;
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
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-');
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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})`;
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 <token></code>。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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}'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user