Files
i/docs/DEV-PLAN.md
T
wsh5485 65b0bb04f8 feat: import CarLog v2.8 code + dev plan
把 CarLog v2.8 全套源码 + 配置导入到 i 仓库作为 baseline:
- server/src/ (13 个路由 + middleware + services + config)
- server/migrations/ (0001~0018 共 18 个迁移 + mysql)
- server/test/ (12 文件 101 测试)
- client/src/ (20 个 view + components + stores + api + composables)
- client/public/ + client/scripts/
- 全部配置文件 (.editorconfig, .eslintrc.json, .prettierrc.json, vitest.config.js, lighthouserc.json, .pa11yci.json, package.json, carlog-init.sql)
- .husky/pre-commit (git hooks)
- docs/install/ (宝塔部署文档)

不含:
- node_modules/ (本地 npm install)
- .env (敏感, 走 .env.example)
- *.zip / *.log / *.sqlite / .DS_Store

新增文档 docs/DEV-PLAN.md:
- Phase 1: 平台基座 (019 migration + 3 个 platform 路由 + 3 个 view)
- Phase 2: CarLog 子系统化 (后端 routes/ → subsystems/carlog/ + 前端 views/ → views/subsystems/carlog/ + 元数据驱动菜单)
- Phase 3: 验证 (测试 + E2E + DB 完整性)
- 交付清单 + commit 模板 + 给 Mavis review 的材料

后续 Trae 实施, 提交后我 code review + 跑测试。
2026-06-20 22:30:19 +08:00

1134 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 开发计划:i 平台基座 + CarLog 子系统化
> **目标读者**Trae(另一个 AI IDE 工具),按本文档逐步实施,最终提交代码由 Mavis(我)做 code review + 跑测试。
>
> **完成定义**:所有 Phase 1 + Phase 2 任务通过验收测试,端到端流程跑通。
---
## 0. 背景
i 是一个生活操作系统平台,**单 Vue SPA + 单 Express 进程 + 单 MySQL**。CarLogv2.8)作为第一个子系统。
完整架构见 `docs/ARCHITECTURE.md`。本计划是它的实施分解。
### 0.1 当前仓库状态
- **仓库**: https://gitea.img2img.com/wsh5485/i.git
- **本地**: `/Users/yabozi/wzpstudio/i`
- **当前 commit**: `77adc8e`README + ARCHITECTURE.md + .gitignore
- **CarLog v2.8 代码已经拷到 i 仓库**:
- `server/src/` (13 个 route 文件 + middleware + services)
- `server/migrations/` (0001~0018 共 18 个迁移)
- `client/src/` (20 个 view + components + stores + api)
- 配置文件 (.editorconfig, .eslintrc.json, .prettierrc.json, vitest.config.js, package.json, etc.)
- **MySQL**: `162.14.110.130:33306 / carlog`(密码 `ZeMRBwXP8JC6B3rF``.env` 里读)
- **node_modules**: 没装,需要 `npm install` 装 server 和 client
### 0.2 关键决策
| 决策 | 选定 | 理由 |
|---|---|---|
| 子系统隔离 | 物理目录 + 表前缀 | 单用户场景,分进程/分库是过度工程 |
| 平台层和子系统共享一个进程 | 是 | 一个 Express server |
| 路由前缀 | **保持现状** `/api/*` | 改 URL 路径影响前端所有 link/api call,工作量大且无收益 |
| CarLog 代码目录 | 移到 `server/src/subsystems/carlog/` | 物理隔离,加子系统不会乱碰 |
| CarLog 前端目录 | 移到 `client/src/views/subsystems/carlog/` | 同上 |
| CarLog 路由 path | 不变(`/washes``/vehicles` | 用户体验一致 |
| CarLog 表前缀 | **Phase 2 不做**(留给 Phase 3) | 当前阶段数据库里只有 CarLog 的表,没必要急着加前缀;加第二个子系统前再做 |
| 平台层路由前缀 | `/api/platform/*` | 平台层独立路径空间 |
| 平台层 UI 路径 | `/settings/global`, `/settings/:subsystem`, `/admin/subsystems` | 用户能直接 URL 进入 |
| 元数据驱动 | `subsystems` 表的 `settings_schema` + `nav_items` JSON 字段 | 加新子系统不用改平台前端代码 |
---
## Phase 1: 平台基座
### Task 1.1: 数据库迁移(subsystems + platform_settings 表)
**新增文件**: `server/migrations/019_platform.sql`
**内容**:
```sql
-- ============================================================
-- 019_platform.sql — i 平台基座 (subsystems + platform_settings)
-- ============================================================
-- 可重复执行(先 DROP 再 CREATE
DROP TABLE IF EXISTS subsystems;
CREATE TABLE subsystems (
id VARCHAR(50) PRIMARY KEY, -- 'carlog' / 'fitness' / 'reading'
name VARCHAR(100) NOT NULL, -- 显示名: '洗车管理系统'
description TEXT,
icon VARCHAR(20), -- emoji: '🚗'
color VARCHAR(20), -- '#1B6EF3'
category VARCHAR(50) NOT NULL, -- 'vehicle' / 'fitness' / 'reading'
version VARCHAR(20),
enabled TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
settings_schema JSON, -- JSON Schema 描述设置项
nav_items JSON, -- [{label, icon, path, sort}]
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_category (category, enabled, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
DROP TABLE IF EXISTS platform_settings;
CREATE TABLE platform_settings (
`key` VARCHAR(200) PRIMARY KEY, -- 总设置无前缀 'ui.theme' / 子系统设置 'carlog.ai.provider'
value JSON NOT NULL,
type VARCHAR(20) NOT NULL, -- 'string' / 'number' / 'boolean' / 'json'
description TEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Seed: CarLog 注册到 subsystems 表
INSERT INTO subsystems (id, name, description, icon, color, category, version, enabled, sort_order, settings_schema, nav_items) VALUES
('carlog', '洗车管理系统', '个人 detailer 洗车管理系统', '🚗', '#1B6EF3', 'vehicle', '2.8.0', 1, 10,
'{
"fields": [
{"key": "weather.default_city", "label": "默认城市", "type": "select", "options": ["Beijing", "Shanghai", "Korla", "Shenzhen"], "default": "Korla"},
{"key": "ai.provider", "label": "AI 识别 provider", "type": "select", "options": ["minimax_vl", "openai_compat"], "default": "minimax_vl"},
{"key": "grocy.url", "label": "Grocy URL", "type": "string", "default": ""},
{"key": "grocy.api_key", "label": "Grocy API Key", "type": "password", "default": ""},
{"key": "ui.compact_mode", "label": "紧凑模式", "type": "boolean", "default": false}
]
}',
'[
{"label": "概览", "path": "/", "icon": "🏠", "sort": 0},
{"label": "车辆", "path": "/vehicles", "icon": "🚙", "sort": 10},
{"label": "洗车记录", "path": "/washes", "icon": "🧽", "sort": 20},
{"label": "加油", "path": "/refuels", "icon": "⛽", "sort": 30},
{"label": "充电", "path": "/chargings", "icon": "🔌", "sort": 40},
{"label": "保养", "path": "/maintenances", "icon": "🔧", "sort": 50},
{"label": "保险", "path": "/insurances", "icon": "🛡️", "sort": 60},
{"label": "药剂", "path": "/chemicals", "icon": "🧴", "sort": 70},
{"label": "统计", "path": "/stats", "icon": "📊", "sort": 80},
{"label": "设置", "path": "/settings", "icon": "⚙️", "sort": 90}
]
');
-- Seed: 几个总设置默认值
INSERT INTO platform_settings (`key`, value, type, description) VALUES
('ui.theme', '"auto"', 'string', 'UI 主题: auto/light/dark'),
('ui.language', '"zh-CN"', 'string', '界面语言: zh-CN/en'),
('dashboard.layout', '"default"', 'string', 'Dashboard 布局: default/compact'),
('backup.enabled', 'false', 'boolean', '自动备份开关'),
('backup.path', '""', 'string', '备份路径');
```
**验证**:
```bash
mysql -h 162.14.110.130 -P 33306 -u carlog -p carlog < server/migrations/019_platform.sql
mysql -h 162.14.110.130 -P 33306 -u carlog -p carlog -e "SHOW TABLES;" | grep -E "subsystems|platform_settings"
mysql -h 162.14.110.130 -P 33306 -u carlog -p carlog -e "SELECT id, name, category FROM subsystems;"
# 期望输出: carlog | 洗车管理系统 | vehicle
mysql -h 162.14.110.130 -P 33306 -u carlog -p carlog -e "SELECT \`key\`, type FROM platform_settings;"
# 期望输出 5 行: ui.theme / ui.language / dashboard.layout / backup.enabled / backup.path
```
**幂等性**: 文件用 `DROP TABLE IF EXISTS` + `CREATE TABLE`,可重复执行。
---
### Task 1.2: 平台路由 — subsystems
**新增文件**: `server/src/routes/platform/subsystems.js`
**内容**:
```js
import express from 'express';
import { db } from '../../db.js';
import { requireAuth } from '../../middleware/auth.js';
const router = express.Router();
// GET /api/platform/subsystems — 列出所有子系统(按 category 分组,按 sort_order 排序)
router.get('/', requireAuth, async (req, res, next) => {
try {
const [rows] = await db().execute(
`SELECT id, name, description, icon, color, category, version, enabled, sort_order, settings_schema, nav_items, created_at, updated_at
FROM subsystems
WHERE enabled = 1
ORDER BY category, sort_order`
);
// JSON 字段需要手动 parse
const subs = rows.map(r => ({
...r,
settings_schema: typeof r.settings_schema === 'string' ? JSON.parse(r.settings_schema) : r.settings_schema,
nav_items: typeof r.nav_items === 'string' ? JSON.parse(r.nav_items) : r.nav_items,
enabled: !!r.enabled,
}));
res.json({ ok: true, data: subs });
} catch (err) {
next(err);
}
});
// GET /api/platform/subsystems/:id — 单个详情
router.get('/:id', requireAuth, async (req, res, next) => {
try {
const [rows] = await db().execute(
`SELECT * FROM subsystems WHERE id = ? LIMIT 1`,
[req.params.id]
);
if (!rows.length) return res.status(404).json({ ok: false, error: 'subsystem not found' });
const sub = rows[0];
sub.settings_schema = typeof sub.settings_schema === 'string' ? JSON.parse(sub.settings_schema) : sub.settings_schema;
sub.nav_items = typeof sub.nav_items === 'string' ? JSON.parse(sub.nav_items) : sub.nav_items;
sub.enabled = !!sub.enabled;
res.json({ ok: true, data: sub });
} catch (err) {
next(err);
}
});
// PATCH /api/platform/subsystems/:id — 启停
router.patch('/:id', requireAuth, async (req, res, next) => {
try {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
return res.status(400).json({ ok: false, error: 'enabled must be boolean' });
}
await db().execute(
`UPDATE subsystems SET enabled = ? WHERE id = ?`,
[enabled ? 1 : 0, req.params.id]
);
res.json({ ok: true, data: { id: req.params.id, enabled } });
} catch (err) {
next(err);
}
});
export default router;
```
**注意**:
- 路由前缀 `/api/platform/subsystems`,在 `index.js` mount
- 响应统一用 `{ok: true, data: ...}` 包装(与其他平台路由一致)
- 已有 `requireAuth` middleware 直接复用(`server/src/middleware/auth.js`
- mysql2 返回的 JSON 字段可能是字符串,需要手动 parse
- enabled 是 TINYINT(1),要转 boolean
**验证**:
```bash
# 启动 server (如果还没起)
cd server && npm install && npm run dev &
# 拿 token
TOKEN=$(curl -s -X POST http://localhost:8787/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"carwash2026"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
# 列表
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/platform/subsystems | python3 -m json.tool | head -20
# 期望: data 数组里有 carlog 那条
# 单个
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/platform/subsystems/carlog | python3 -m json.tool
# 期望: data.settings_schema 是对象, data.nav_items 是数组
# 启停
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"enabled": false}' http://localhost:8787/api/platform/subsystems/carlog
# 期望: {ok: true, data: {id: 'carlog', enabled: false}}
# 然后立即恢复
curl -s -X PATCH -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"enabled": true}' http://localhost:8787/api/platform/subsystems/carlog
```
---
### Task 1.3: 平台路由 — settings
**新增文件**: `server/src/routes/platform/settings.js`
**内容**:
```js
import express from 'express';
import { db } from '../../db.js';
import { requireAuth } from '../../middleware/auth.js';
const router = express.Router();
// GET /api/platform/settings?prefix=carlog. — 获取设置(支持 prefix 过滤)
router.get('/', requireAuth, async (req, res, next) => {
try {
const { prefix } = req.query;
let rows;
if (prefix) {
[rows] = await db().execute(
`SELECT \`key\`, value, type, description, updated_at
FROM platform_settings
WHERE \`key\` LIKE ?
ORDER BY \`key\``,
[prefix + '%']
);
} else {
[rows] = await db().execute(
`SELECT \`key\`, value, type, description, updated_at
FROM platform_settings
ORDER BY \`key\``
);
}
const settings = rows.map(r => ({
...r,
value: typeof r.value === 'string' ? JSON.parse(r.value) : r.value,
}));
res.json({ ok: true, data: settings });
} catch (err) {
next(err);
}
});
// GET /api/platform/settings/:key — 单个
router.get('/:key(*)', requireAuth, async (req, res, next) => {
try {
const key = req.params.key;
const [rows] = await db().execute(
`SELECT \`key\`, value, type, description, updated_at
FROM platform_settings
WHERE \`key\` = ? LIMIT 1`,
[key]
);
if (!rows.length) return res.status(404).json({ ok: false, error: 'setting not found' });
const setting = rows[0];
setting.value = typeof setting.value === 'string' ? JSON.parse(setting.value) : setting.value;
res.json({ ok: true, data: setting });
} catch (err) {
next(err);
}
});
// PUT /api/platform/settings/:key — 设置单个
router.put('/:key(*)', requireAuth, async (req, res, next) => {
try {
const key = req.params.key;
const { value, type, description } = req.body;
if (value === undefined) {
return res.status(400).json({ ok: false, error: 'value required' });
}
const inferredType = type || (typeof value === 'boolean' ? 'boolean' : typeof value === 'number' ? 'number' : typeof value === 'object' ? 'json' : 'string');
await db().execute(
`INSERT INTO platform_settings (\`key\`, value, type, description)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value), type = VALUES(type), description = VALUES(description)`,
[key, JSON.stringify(value), inferredType, description || null]
);
res.json({ ok: true, data: { key, value, type: inferredType } });
} catch (err) {
next(err);
}
});
// POST /api/platform/settings/batch — 批量设置
router.post('/batch', requireAuth, async (req, res, next) => {
try {
const { settings } = req.body;
if (!Array.isArray(settings)) {
return res.status(400).json({ ok: false, error: 'settings must be array' });
}
for (const s of settings) {
if (!s.key || s.value === undefined) continue;
const type = s.type || (typeof s.value === 'boolean' ? 'boolean' : typeof s.value === 'number' ? 'number' : typeof s.value === 'object' ? 'json' : 'string');
await db().execute(
`INSERT INTO platform_settings (\`key\`, value, type, description)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE value = VALUES(value), type = VALUES(type), description = VALUES(description)`,
[s.key, JSON.stringify(s.value), type, s.description || null]
);
}
res.json({ ok: true, data: { updated: settings.length } });
} catch (err) {
next(err);
}
});
export default router;
```
**注意**:
- Express 路由参数 `:key(*)` 让 key 可以包含 `.`(如 `carlog.weather.default_city`
- key 是 MySQL 保留字,必须用反引号转义
- `INSERT ... ON DUPLICATE KEY UPDATE` 做 upsert
- 批量接口 settings 数组每个元素 `{key, value, type?, description?}`
**验证**:
```bash
# 列表(无 prefix
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/platform/settings | python3 -m json.tool | head -30
# 期望: 5 行总设置
# 列表(带 prefix
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8787/api/platform/settings?prefix=carlog.' | python3 -m json.tool
# 期望: 空数组(carlog 子系统设置还没有)
# 单个
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/platform/settings/ui.theme | python3 -m json.tool
# 期望: value='auto'
# 设置单个
curl -s -X PUT -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"value": "dark"}' http://localhost:8787/api/platform/settings/ui.theme
# 期望: {ok: true, data: {key: 'ui.theme', value: 'dark', type: 'string'}}
# 批量
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"settings":[{"key":"carlog.ai.provider","value":"openai_compat","description":"AI provider"},{"key":"carlog.ui.compact_mode","value":true,"description":"紧凑模式"}]}' \
http://localhost:8787/api/platform/settings/batch
# 期望: {ok: true, data: {updated: 2}}
# 再查 prefix
curl -s -H "Authorization: Bearer $TOKEN" 'http://localhost:8787/api/platform/settings?prefix=carlog.' | python3 -m json.tool
# 期望: 2 条 (ai.provider 和 ui.compact_mode)
```
---
### Task 1.4: 平台路由 — dashboard
**新增文件**: `server/src/routes/platform/dashboard.js`
**内容**:
```js
import express from 'express';
import { db } from '../../db.js';
import { requireAuth } from '../../middleware/auth.js';
const router = express.Router();
// GET /api/platform/dashboard — 跨子系统聚合数据
router.get('/', requireAuth, async (req, res, next) => {
try {
// Phase 2 只聚合 CarLog 的关键 stats
const [vehicles] = await db().execute(`SELECT COUNT(*) AS n FROM vehicles WHERE deleted_at IS NULL`);
const [washesThisMonth] = await db().execute(
`SELECT COUNT(*) AS n, COALESCE(SUM(total_cost), 0) AS total
FROM wash_records
WHERE deleted_at IS NULL
AND DATE_FORMAT(wash_date, '%Y-%m') = DATE_FORMAT(UTC_DATE(), '%Y-%m')`
);
const [refuelsThisMonth] = await db().execute(
`SELECT COUNT(*) AS n, COALESCE(SUM(total_cost), 0) AS total, COALESCE(SUM(liters), 0) AS liters
FROM refuel_records
WHERE deleted_at IS NULL
AND DATE_FORMAT(refuel_date, '%Y-%m') = DATE_FORMAT(UTC_DATE(), '%Y-%m')`
);
res.json({
ok: true,
data: {
carlog: {
vehicles_count: vehicles[0].n,
this_month: {
washes: washesThisMonth[0].n,
wash_cost: Number(washesThisMonth[0].total),
refuels: refuelsThisMonth[0].n,
refuel_cost: Number(refuelsThisMonth[0].total),
refuel_liters: Number(refuelsThisMonth[0].liters),
},
},
// fitness: {...}, // Phase 3 加
// reading: {...}, // Phase 3 加
},
});
} catch (err) {
next(err);
}
});
export default router;
```
**注意**:
- 现在只聚 CarLogFitness / Reading 子系统加进来后再扩
- 用 UTC_DATE()mysql2 timezone='Z' 配置已经设为 UTC
- 总数 / 总额用 `Number()` 转 JS numbermysql2 返回 DECIMAL 是字符串)
- 后续扩字段时按需加
**验证**:
```bash
curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8787/api/platform/dashboard | python3 -m json.tool
# 期望: {ok: true, data: {carlog: {vehicles_count: N, this_month: {washes: N, wash_cost: X, ...}}}}
```
---
### Task 1.5: 路由挂载到 index.js
**修改文件**: `server/src/index.js`
**改动**:
1. 加 3 个 import
2. mount 3 个 router
**找到现有的 import 和 mount 位置(参考 CarLog 仓库的 `server/src/index.js` 已有模式),在合适位置加**:
```js
// 在 routes 引入区加
import platformSubsystemsRouter from './routes/platform/subsystems.js';
import platformSettingsRouter from './routes/platform/settings.js';
import platformDashboardRouter from './routes/platform/dashboard.js';
// 在 app.use mount 区加(参考现有 app.use('/api/vehicles', ...) 的位置)
app.use('/api/platform/subsystems', platformSubsystemsRouter);
app.use('/api/platform/settings', platformSettingsRouter);
app.use('/api/platform/dashboard', platformDashboardRouter);
```
**注意**:
- 保持现有所有 `app.use('/api/*', ...)` 不变
- 平台路由 mount 在最后(不冲突就行)
- mount 顺序不重要(路径都不重叠)
**验证**:
```bash
cd server && npm run dev
# 等 server 起来后跑上面 1.2/1.3/1.4 的所有 curl
```
---
### Task 1.6: 单元测试(平台路由)
**新增文件**:
- `server/test/routes/platform.subsystems.test.js`
- `server/test/routes/platform.settings.test.js`
- `server/test/routes/platform.dashboard.test.js`
**测试范围(每个文件 4-8 个 case)**:
- auth 拦截(不带 token 返 401
- list / get / patch / put / post 各种 HTTP 方法
- JSON 字段 parse 正确
- 不存在的 key 返 404
- prefix 过滤正确
- 批量 upsert 正确
**参考现有 `server/test/routes/*.test.js`** 的写法(vitest + supertest + mock db)。
**验证**:
```bash
cd server && npm test
# 期望: 所有测试通过, 旧测试 101 个不破 + 新增 12-20 个平台测试
```
---
## Phase 2: CarLog 子系统化
### Task 2.1: 后端代码目录迁移
**目标**: 把 `server/src/routes/*.js`(13 个 CarLog 路由文件)移到 `server/src/subsystems/carlog/routes/*.js`
**操作**mv 不是 cp:
```bash
mkdir -p server/src/subsystems/carlog/routes
mv server/src/routes/*.js server/src/subsystems/carlog/routes/
ls server/src/routes/ # 期望: 空目录(保留着,里面加个 .gitkeep)
touch server/src/routes/.gitkeep
```
**修改每个迁移文件中的 import 路径**:
- `server/src/subsystems/carlog/routes/vehicles.js` 里原本是 `import { db } from '../../db.js'`
- 现在路径变了: `import { db } from '../../../db.js'`(深一层)
- middleware 同理:`'../../middleware/auth.js'``'../../../middleware/auth.js'`
**13 个文件都要改**,用 `sed -i '' "s|from '../../|from '../../../|g" server/src/subsystems/carlog/routes/*.js`
**新建文件**: `server/src/subsystems/carlog/index.js`
```js
// 聚合导出 CarLog 子系统的所有路由
import vehicles from './routes/vehicles.js';
import washes from './routes/washes.js';
import refuels from './routes/washes.js'; // 注意: refuels 文件名是 refuels.js, 不是 washes
// ... 其他 10 个
// 注意: 上面的 refuels 注释错了,应该是 import refuels from './routes/refuels.js'
// 实际写时按文件名一个个 import
import vehiclesRouter from './routes/vehicles.js';
import washesRouter from './routes/washes.js';
import refuelsRouter from './routes/refuels.js'; // refuels.js 实际叫 refuels.js
import chargingRouter from './routes/charging.js'; // 如果有的话, 实际看 routes/ 下的文件名
import maintenanceRouter from './routes/maintenance.js'; // 同上
import insuranceRouter from './routes/insurance.js';
import chemicalsRouter from './routes/chemicals.js';
import aiRouter from './routes/ai.js';
import authRouter from './routes/auth.js';
import settingsRouter from './routes/settings.js';
import logsRouter from './routes/logs.js';
import operationLogsRouter from './routes/operationLogs.js';
import extraRouter from './routes/extra.js';
import tagsRouter from './routes/tags.js';
import notificationsRouter from './routes/notifications.js';
import achievementsRouter from './routes/achievements.js';
export {
vehiclesRouter,
washesRouter,
refuelsRouter,
insuranceRouter,
chemicalsRouter,
aiRouter,
authRouter,
settingsRouter,
logsRouter,
operationLogsRouter,
extraRouter,
tagsRouter,
notificationsRouter,
achievementsRouter,
};
```
**重要**: 先 `ls server/src/routes/` 看清楚 13 个文件实际叫什么名字, 别凭印象写。如果某个路由文件叫 `refuels.js`, import 时就是 `from './routes/refuels.js'`, 不要瞎猜。
**验证**:
```bash
ls server/src/subsystems/carlog/routes/ # 期望: 13 个 .js 文件
ls server/src/routes/ # 期望: 只有 .gitkeep
cd server && node -e "import('./src/subsystems/carlog/index.js').then(m => console.log(Object.keys(m)))"
# 期望: 所有 router 名字列出
```
---
### Task 2.2: 后端路由挂载从新目录
**修改文件**: `server/src/index.js`
**改动**: 把现有 mount 改用新 index.js 导出的 router
```js
// 原来的:
// import vehiclesRouter from './routes/vehicles.js';
// app.use('/api/vehicles', vehiclesRouter);
// 改成:
import {
vehiclesRouter, washesRouter, refuelsRouter,
insuranceRouter, chemicalsRouter, aiRouter,
authRouter, settingsRouter, logsRouter,
operationLogsRouter, extraRouter, tagsRouter,
notificationsRouter, achievementsRouter,
} from './subsystems/carlog/index.js';
app.use('/api/vehicles', vehiclesRouter);
app.use('/api/washes', washesRouter);
app.use('/api/refuels', refuelsRouter); // 注意: 如果原文件名是 refuels.js 就这样, 看实际
// ... 其他 11 个
app.use('/api/insurance', insuranceRouter);
app.use('/api/chemicals', chemicalsRouter);
app.use('/api/ai', aiRouter);
app.use('/api/auth', authRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/logs', logsRouter);
app.use('/api/operation-logs', operationLogsRouter);
app.use('/api/extra', extraRouter);
app.use('/api/tags', tagsRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/achievements', achievementsRouter);
```
**重要**:
- 路由 path 不要变(保持 `/api/vehicles` 而不是 `/api/carlog/vehicles`
- 文件名要按 `ls server/src/subsystems/carlog/routes/` 实际看到的写
- 一定要先 cd 到 server/src/subsystems/carlog/routes 看看实际文件名, 常见陷阱:
- `refuels.js` vs `refuel.js`
- `washes.js` vs `wash.js` (中文习惯复数)
- `chemicals.js` vs `chemical.js`
- mount 顺序保持不变(先 auth 后业务,因为有些 middleware 依赖 auth
**验证**:
```bash
cd server && npm run dev
# 登录 + 列车辆 + 列洗车 + 列加油 + 列成就 + 列通知 + 全 OK
TOKEN=$(curl -s -X POST http://localhost:8787/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"carwash2026"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
for ep in vehicles washes refuels insurance chemicals tags notifications achievements; do
echo "Testing /api/$ep ..."
curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8787/api/$ep" | head -1
done
# 期望: 每行返回 JSON 不报错
```
---
### Task 2.3: 前端代码目录迁移
**目标**: 把 `client/src/views/*.vue`20 个 view)和相关 component / composable / store 移到子系统目录
**操作**:
```bash
mkdir -p client/src/views/subsystems/carlog
mkdir -p client/src/components/subsystems/carlog
mkdir -p client/src/stores/subsystems/carlog
mkdir -p client/src/composables/subsystems/carlog
# 20 个 view 全部移过去
mv client/src/views/*.vue client/src/views/subsystems/carlog/
# Home.vue 不动(i 平台的 Dashboard 走 views/Platform/Dashboard.vueCarLog 用 views/subsystems/carlog/Home.vue 作为子系统主页)
# Login.vue 不动(i 平台统一登录走 views/Login.vue
# 加 .gitkeep 在原 views/ 目录
touch client/src/views/.gitkeep
```
**修改 router path**: `client/src/router/index.js` 里所有 CarLog 路由的 import 路径改成相对新位置(`'../views/subsystems/carlog/WashesList.vue'` 等),router path 本身**保持不变**`/washes``/vehicles` 等),用户 URL 不变。
**关键 import 修改**:
```js
// 原来的:
// import WashesList from '../views/WashesList.vue';
// 改成:
// import WashesList from '../views/subsystems/carlog/WashesList.vue';
// 其他 19 个 view 同理
```
**验证**:
```bash
ls client/src/views/subsystems/carlog/ # 期望: 20 个 .vue 文件
ls client/src/views/ # 期望: 只有 .gitkeep + Login.vue + Offline.vue 等非子系统文件
cd client && npm run dev
# 浏览器打开 http://localhost:5173/
# 登录后能正常进 /vehicles /washes /refuels /stats 等所有页面
```
---
### Task 2.4: 前端平台层 — 总设置 UI
**新增文件**: `client/src/views/Platform/GlobalSettings.vue`
**内容**: 总设置页面
- 表单: 主题(auto/light/dark select)、语言(zh-CN/en select)、Dashboard 布局(default/compact select)、备份开关(boolean checkbox)、备份路径(string input
- 加载数据: `GET /api/platform/settings` 过滤 platform 层 key(不带 prefix
- 保存: `POST /api/platform/settings/batch`
- 用现有 `<style>` 保持 CarLog 风格(看 client/src/views/Settings.vue
**关键代码骨架**:
```vue
<template>
<div class="global-settings">
<h2>总设置</h2>
<form @submit.prevent="save">
<label>主题 <select v-model="form.theme">
<option value="auto">跟随系统</option>
<option value="light">浅色</option>
<option value="dark">深色</option>
</select></label>
<!-- 其他字段 -->
<button type="submit">保存</button>
</form>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../../api/client.js';
const form = ref({
theme: 'auto',
language: 'zh-CN',
dashboardLayout: 'default',
backupEnabled: false,
backupPath: '',
});
const origKeys = {
theme: 'ui.theme',
language: 'ui.language',
dashboardLayout: 'dashboard.layout',
backupEnabled: 'backup.enabled',
backupPath: 'backup.path',
};
async function load() {
const { data } = await api.get('/api/platform/settings');
for (const s of data) {
const field = Object.entries(origKeys).find(([_, k]) => k === s.key)?.[0];
if (field) form.value[field] = s.value;
}
}
async function save() {
const settings = Object.entries(origKeys).map(([field, key]) => ({
key,
value: form.value[field],
}));
await api.post('/api/platform/settings/batch', { settings });
alert('已保存');
}
onMounted(load);
</script>
```
**注意**:
- 路由 path `/settings/global`
-`api.client.js` 而不是 raw axios(自动带 auth + 解包)
---
### Task 2.5: 前端平台层 — 通用子系统设置渲染器
**新增文件**: `client/src/views/Platform/SubsystemSettings.vue`
**核心能力**: 读 URL 参数 `:subsystem`(比如 `carlog`),加载 `GET /api/platform/subsystems/:id` 拿 settings_schema,渲染动态表单,保存到 `/api/platform/settings/batch`
**支持的字段类型**:
- `string``<input type="text">`
- `number``<input type="number">`
- `boolean``<input type="checkbox">`
- `select``<select>` (options 数组)
- `password``<input type="password">`
- `textarea``<textarea>`
- `multiselect``<select multiple>` (Phase 2 可以暂不做,留 TODO)
**关键代码骨架**:
```vue
<template>
<div class="subsystem-settings" v-if="schema">
<h2>{{ subsystemName }} 设置</h2>
<form @submit.prevent="save">
<div v-for="field in schema.fields" :key="field.key" class="field">
<label>{{ field.label }}</label>
<input v-if="field.type === 'string'" type="text" v-model="values[field.key]" />
<input v-else-if="field.type === 'password'" type="password" v-model="values[field.key]" />
<input v-else-if="field.type === 'number'" type="number" v-model.number="values[field.key]" />
<input v-else-if="field.type === 'boolean'" type="checkbox" v-model="values[field.key]" />
<textarea v-else-if="field.type === 'textarea'" v-model="values[field.key]" />
<select v-else-if="field.type === 'select'" v-model="values[field.key]">
<option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<span v-else>未支持的字段类型: {{ field.type }}</span>
</div>
<button type="submit">保存</button>
</form>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client.js';
const route = useRoute();
const subsystemId = computed(() => route.params.subsystem);
const schema = ref(null);
const subsystemName = ref('');
const values = ref({});
async function load() {
const { data: sub } = await api.get(`/api/platform/subsystems/${subsystemId.value}`);
schema.value = sub.settings_schema;
subsystemName.value = sub.name;
// 加载当前值
values.value = {};
for (const field of sub.settings_schema.fields) {
values.value[field.key] = field.default;
}
const { data: settings } = await api.get(`/api/platform/settings?prefix=${subsystemId.value}.`);
for (const s of settings) {
// key 是 'carlog.weather.default_city', 去掉前缀
const fieldKey = s.key.substring(s.key.indexOf('.') + 1);
values.value[fieldKey] = s.value;
}
}
async function save() {
const settings = Object.entries(values.value).map(([key, value]) => ({
key: `${subsystemId.value}.${key}`,
value,
}));
await api.post('/api/platform/settings/batch', { settings });
alert('已保存');
}
onMounted(load);
</script>
```
**注意**:
- 不要 hardcode schema — 一切从 `subsystems.settings_schema`
- value 是从 server 读到的 JSON(已经 parse 过),直接用
- 保存时 key 加 `${subsystemId}.` 前缀
---
### Task 2.6: 前端平台层 — 左侧菜单按 category 分组
**修改文件**: `client/src/AppLayout.vue`(或类似 — 找到现有左侧导航 component)
**改动**:
1. 删掉硬编码的菜单列表
2.`usePlatformStore``subsystems`
3.`category` 分组渲染,每组内按 `nav_items[].sort` 排序
**新增 store**: `client/src/stores/platform.js`
```js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { api } from '../api/client.js';
export const usePlatformStore = defineStore('platform', () => {
const subsystems = ref([]);
const loading = ref(false);
async function loadSubsystems() {
loading.value = true;
try {
const { data } = await api.get('/api/platform/subsystems');
subsystems.value = data;
} finally {
loading.value = false;
}
}
const groupedCategories = computed(() => {
const groups = {};
for (const sub of subsystems.value) {
if (!sub.enabled) continue;
if (!groups[sub.category]) {
groups[sub.category] = {
category: sub.category,
items: [],
};
}
for (const nav of (sub.nav_items || [])) {
groups[sub.category].items.push({
...nav,
subsystemId: sub.id,
subsystemIcon: sub.icon,
});
}
}
// 每个 category 内按 sort 排序
for (const g of Object.values(groups)) {
g.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
}
// category 顺序按子系统 sort_order 的最小值
const orderMap = {};
for (const sub of subsystems.value) {
if (orderMap[sub.category] === undefined || sub.sort_order < orderMap[sub.category]) {
orderMap[sub.category] = sub.sort_order;
}
}
return Object.values(groups).sort((a, b) => orderMap[a.category] - orderMap[b.category]);
});
return { subsystems, loading, loadSubsystems, groupedCategories };
});
```
**AppLayout.vue 改动**:
- 替换硬编码菜单为 `<template v-for="cat in platform.groupedCategories">` + 子循环
- 启动时调 `platform.loadSubsystems()`
- category 显示: vehicle → 🚗 车辆 / fitness → 💪 健康 / reading → 📚 阅读
**注意**:
- 保留现有所有跳转路径(`/vehicles` 等)
-`/settings/global``/settings/carlog` 入口到「系统」category
---
### Task 2.7: 前端平台层 — 路由 + 入口
**修改文件**: `client/src/router/index.js`
**新增路由**:
```js
{
path: '/settings/global',
name: 'global-settings',
component: () => import('../views/Platform/GlobalSettings.vue'),
meta: { requiresAuth: true, title: '总设置' },
},
{
path: '/settings/:subsystem',
name: 'subsystem-settings',
component: () => import('../views/Platform/SubsystemSettings.vue'),
meta: { requiresAuth: true, title: '子系统设置' },
},
{
path: '/admin/subsystems',
name: 'subsystems-admin',
component: () => import('../views/Platform/Subsystems.vue'),
meta: { requiresAuth: true, title: '子系统管理' },
},
```
**Subsystems.vue 简单骨架**(admin 页面,列出所有子系统 + 启停开关):
```vue
<template>
<div class="subsystems">
<h2>子系统管理</h2>
<div v-for="sub in subsystems" :key="sub.id" class="sub-card">
<span>{{ sub.icon }} {{ sub.name }}</span>
<span class="version">{{ sub.version }}</span>
<label><input type="checkbox" v-model="sub.enabled" @change="toggle(sub)" /> 启用</label>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../../api/client.js';
const subsystems = ref([]);
async function load() {
const { data } = await api.get('/api/platform/subsystems');
subsystems.value = data;
}
async function toggle(sub) {
await api.patch(`/api/platform/subsystems/${sub.id}`, { enabled: sub.enabled });
}
onMounted(load);
</script>
```
---
## Phase 3: 验证
### Task 3.1: 跑 server 测试
```bash
cd server
npm install
npm test
```
**期望**:
- 旧 101 个测试**全部通过**(代码迁移不能破现有功能)
- 新增平台路由测试 12-20 个**全部通过**
### Task 3.2: 跑 client 测试
```bash
cd client
npm install
npm test # 如果有 vitest 配置
npm run lint
```
**期望**: 不报错。
### Task 3.3: 手动 E2E
```bash
# 启动 server
cd server && npm run dev &
# 启动 client
cd client && npm run dev &
# 等都起来后浏览器访问 http://localhost:5173/
```
**E2E 清单**(每条都必须过):
- [ ] 登录 (admin/carwash2026) → 跳到 Dashboard
- [ ] 左侧菜单按 🚗 车辆 / ⚙️ 系统 分组显示
- [ ]`/washes` → 看到洗车记录列表
- [ ]`/vehicles` → 看到车辆列表
- [ ]`/refuels` → 看到加油列表
- [ ]`/stats` → 看到统计图表
- [ ]`/settings/global` → 总设置页加载,5 个字段显示
- [ ] 改主题 → 保存 → 刷新页面 → 主题保持
- [ ]`/settings/carlog` → 5 个 CarLog 设置显示
- [ ]`ai.provider` → 保存 → DB 里有 `carlog.ai.provider` 记录
- [ ]`/admin/subsystems` → 看到 CarLog 一条
- [ ] 取消启用 → 刷新页面 → 菜单没有 CarLog 项
- [ ] 重新启用 → 菜单恢复
- [ ]`/api/platform/dashboard`(浏览器 fetch)→ 看到 carlog stats JSON
- [ ] 退出登录 → token 清空
- [ ] 重新登录 → 之前设置还在
### Task 3.4: MySQL 数据完整性
```bash
mysql -h 162.14.110.130 -P 33306 -u carlog -p carlog -e "
SELECT COUNT(*) AS vehicles FROM vehicles;
SELECT COUNT(*) AS washes FROM wash_records;
SELECT COUNT(*) AS refuels FROM refuel_records;
"
# 期望: 数字跟 Phase 0 备份时一致(数据没动)
```
---
## 4. 不做的事(明确边界)
-**表前缀迁移**:本 Phase 不加 `carlog_` 前缀,留到 Phase 3+ 加第二个子系统时再做
-**JWT / SSO**:单用户 cookie session 已经够
-**iframe 嵌入**:同 SPA,不需要 iframe
-**独立子系统部署**:所有子系统一个进程
-**manifest 协议**subsystem 直接走 SQL 注册,不走 HTTP manifest 拉取
-**子系统 dashboard widget 跨子系统聚合**Phase 4 加第二子系统再做
-**现有 CarLog 业务逻辑修改**:迁移目录不改 SQL、不改业务、不改 UI
-**现有 CarLog 测试代码修改**:除非路径变了(深层 import
---
## 5. 交付清单
完成后应该有:
- [ ] `server/migrations/019_platform.sql`subsystems + platform_settings 表 + seed
- [ ] `server/src/routes/platform/subsystems.js`
- [ ] `server/src/routes/platform/settings.js`
- [ ] `server/src/routes/platform/dashboard.js`
- [ ] `server/src/subsystems/carlog/index.js`(聚合 13 个路由)
- [ ] `server/src/subsystems/carlog/routes/*.js`13 个文件从根 routes/ 移过来)
- [ ] `server/src/index.js`mount 平台路由 + 用新 subsystem index
- [ ] `client/src/views/Platform/GlobalSettings.vue`
- [ ] `client/src/views/Platform/SubsystemSettings.vue`
- [ ] `client/src/views/Platform/Subsystems.vue`
- [ ] `client/src/stores/platform.js`
- [ ] `client/src/views/subsystems/carlog/*.vue`20 个 view 移过来)
- [ ] `client/src/router/index.js`(加 3 个平台路由 + 改 view import 路径)
- [ ] `client/src/AppLayout.vue`(改左侧菜单读 platform store
- [ ] `server/test/routes/platform.subsystems.test.js`
- [ ] `server/test/routes/platform.settings.test.js`
- [ ] `server/test/routes/platform.dashboard.test.js`
**commit message 模板**:
```
feat(platform): add platform base + carlog subsystem化
Phase 1: 平台基座
- migrations/019_platform.sql: subsystems + platform_settings 表 + seed CarLog
- routes/platform/{subsystems,settings,dashboard}.js: 3 个平台路由
- routes/platform/* mount 到 /api/platform/*
- 测试: 12-20 个平台测试通过
Phase 2: CarLog 子系统化
- routes/*.js → subsystems/carlog/routes/*.js (13 个文件)
- subsystems/carlog/index.js: 聚合导出
- index.js: 用新 index, mount 路径不变
- views/*.vue → views/subsystems/carlog/*.vue (20 个 view)
- views/Platform/{GlobalSettings,SubsystemSettings,Subsystems}.vue
- stores/platform.js: 元数据驱动菜单
- AppLayout.vue: 左侧菜单按 category 分组
- router: 加 3 个平台路由
验证:
- 旧 101 测试全过 + 新平台测试全过
- 手动 E2E 全过
- DB 数据完整性确认
```
---
## 6. 提交给 Mavis(我)做 code review 时
请把以下附上:
1. `git diff main --stat`(看了多少行)
2. `cd server && npm test` 输出
3. `cd client && npm run lint` 输出
4. MySQL `SHOW TABLES;` 输出
5. 手动 E2E 截图(关键 5 个页面: Dashboard / 总设置 / CarLog 设置 / 子系统管理 / 任意业务页面)
我会逐文件 review + 跑测试 + 给改进意见。