Files
i/docs/DEV-PLAN.md
wsh5485 d7dab31f19 docs: add table prefix migration task (21 CarLog tables)
按用户决定「i系统内的carlog数据库表要加carlog_前缀」:
- 之前方案是「留给将来加第二个子系统时」, 现在提前做
- 新增 Task 2.8 表前缀迁移 (含 5 个子节 + review checklist)
- DEV-PLAN.md:
  - 关键决策表「本阶段不做」→「本阶段做」
  - 加 Task 2.8: 备份 + migration 020 + 改 server SQL + reset-all.js + 验证
  - 加 §6.2.11 表前缀迁移 review checklist (10 项)
  - 「不做的事」: 删掉表前缀
  - 「完成定义」: 加 Task 2.8
- ARCHITECTURE.md:
  - §3 决策表同步
  - §7 W2 改成完整 RENAME TABLE 21 张 + 备份警告
  - 删掉过时的 db.js tableName helper 建议
- README.md:
  - 决策表同步
  - 实施路线按 Phase 1 / 2 / 2.8 / 3 重新分组
  - 删掉「暂不做: CarLog 表前缀」

迁移策略:
- 21 张 CarLog 业务表加 carlog_ 前缀 (含 carlog_settings)
- 5 张共享表不动 (users / login_attempts / auth_locks / schema_migrations / 平台表)
- 改前必做 mysqldump 备份 (RENAME 是 DDL 不能事务回滚)
- 直接改 SQL (不加 helper 函数, 20 张表工作量可控)
- sed \\b word boundary 安全替换 (列了完整 21 张表清单, 不动共享表 4 张)
2026-06-20 23:06:12 +08:00

1614 lines
64 KiB
Markdown
Raw Permalink 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 (含 Task 2.8 表前缀迁移) 任务通过验收测试,端到端流程跑通。
---
## 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 路径前缀** | **`/api/{subsystem}/*`**(如 `/api/carlog/vehicles`) | 子系统隔离 + 以后加 fitness 直接 `/api/fitness/*` 不冲突 |
| CarLog 代码目录 | 移到 `server/src/subsystems/carlog/` | 物理隔离,加子系统不会乱碰 |
| CarLog 前端目录 | 移到 `client/src/views/subsystems/carlog/` | 同上 |
| **CarLog 后端路由 path** | `/api/carlog/*`(不再是 `/api/vehicles` | 跟平台层 `/api/platform/*` 对齐;前端调用要改 |
| **CarLog 前端 API 调用** | 所有 CarLog API 加 `/api/carlog/` 前缀 | 跟后端路由对齐 |
| CarLog 前端路由 path | 不变(`/washes``/vehicles` | 用户体验一致;router path 还是前端路径,不是 API 路径 |
| **CarLog 表前缀** | **本阶段做**(加 `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: {...}, reading: {...}
},
});
} catch (err) {
next(err);
}
});
export default router;
```
**注意**:
- 现在只聚 CarLog;将来加新子系统时再扩字段
- 用 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: 后端路由挂载从新目录(加 /api/carlog/ 前缀)
**修改文件**: `server/src/index.js`
**改动**: 把现有 mount 改用新 index.js 导出的 router, **所有 CarLog 路由加 `/api/carlog/` 前缀**
```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';
// ⚠️ 全部加 /api/carlog/ 前缀(不再是 /api/vehicles
app.use('/api/carlog/vehicles', vehiclesRouter);
app.use('/api/carlog/washes', washesRouter);
app.use('/api/carlog/refuels', refuelsRouter); // 看实际文件名, 可能是 refuel
app.use('/api/carlog/insurance', insuranceRouter);
app.use('/api/carlog/chemicals', chemicalsRouter);
app.use('/api/carlog/ai', aiRouter);
app.use('/api/carlog/auth', authRouter); // 登录路由也加前缀
app.use('/api/carlog/settings', settingsRouter);
app.use('/api/carlog/logs', logsRouter);
app.use('/api/carlog/operation-logs', operationLogsRouter);
app.use('/api/carlog/extra', extraRouter);
app.use('/api/carlog/tags', tagsRouter);
app.use('/api/carlog/notifications', notificationsRouter);
app.use('/api/carlog/achievements', achievementsRouter);
```
**重要**:
- **路由 path 加 `/api/carlog/` 前缀**(用户决定 — 跟 `/api/platform/*` 对齐,方便以后加 fitness/reading 不冲突)
- 文件名要按 `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/carlog/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/carlog/$ep ..."
curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8787/api/carlog/$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.3.5: 前端 API 调用加 `/api/carlog/` 前缀(重要!)
**为什么**:后端路由全部从 `/api/vehicles` 改到 `/api/carlog/vehicles`,前端所有 API 调用必须同步改,否则登录后所有页面都 404。
**方案:每个子系统一个 API helperbaseURL**
不要在每个 view 里手写 `/api/carlog/...`。在 `client/src/api/subsystems.js` 加子系统 baseURL helper
```js
// client/src/api/subsystems.js
import { api } from './client.js';
// 子系统 API helper —— 一个 helper 一个 baseURL
export const carlogApi = {
get: (path, config) => api.get(`/api/carlog${path}`, config),
post: (path, data, config) => api.post(`/api/carlog${path}`, data, config),
put: (path, data, config) => api.put(`/api/carlog${path}`, data, config),
patch: (path, data, config) => api.patch(`/api/carlog${path}`, data, config),
delete: (path, config) => api.delete(`/api/carlog${path}`, config),
};
// 将来加 fitness 子系统直接加一行:
// export const fitnessApi = { ... 同结构, baseURL: '/api/fitness' };
// 平台层 API(不走子系统前缀)
export const platformApi = api; // 平台路由已经是 /api/platform/*, 用原始 api
```
**改造 client/src/api/ 下 9 个 CarLog 文件**:每个文件把 `import { api } from './client.js'` 改成 `import { carlogApi } from './subsystems.js'`,所有 `api.get('xxx')` 改成 `carlogApi.get('xxx')` —— **path 里不要带 `/api/carlog/` 前缀,helper 自动加**
```js
// 原来 (client/src/api/vehicles.js):
import { api } from './client.js';
export const listVehicles = () => api.get('/vehicles');
// 改成:
import { carlogApi } from './subsystems.js';
export const listVehicles = () => carlogApi.get('/vehicles');
// ^^^^^^^^^^ 注意是 carlogApi, path 不带 /api/carlog/
```
**改造 20 个 view 里直接调 api 的地方**(如果有 inline api 调用):
```js
// 原来: api.get('/vehicles')
// 改成: carlogApi.get('/vehicles')
```
**9 个 CarLog api 文件清单**(要看 `ls client/src/api/` 实际):
```
client/src/api/ai.js
client/src/api/auth.js
client/src/api/chemicals.js
client/src/api/client.js ← 这个不动!它是底层 axios 实例
client/src/api/insurance.js
client/src/api/logs.js
client/src/api/operationLogs.js
client/src/api/settings.js
```
**注意**:
- `client/src/api/client.js` 是底层 axios 实例(带 auth interceptor + 解包 `{ok,data}`),**不要动**
- 其他 8 个 api 文件全部 import `carlogApi` 而不是 `api`
- 平台层 viewGlobalSettings / SubsystemSettings / Subsystems)继续用 `api`(因为是 `/api/platform/*`
**验证**:
```bash
# 跑 grep 确认没有残留的 /api/vehicles /api/washes 字符串
grep -rn "/api/vehicles\|/api/washes\|/api/refuels\|/api/insurance\|/api/chemicals\|/api/auth" client/src/ --include="*.js" --include="*.vue"
# 期望: 没有结果(除了 api/subsystems.js 里写 baseURL 那行)
# 跑 server 起来 + 浏览器登录测试
cd server && npm run dev
cd client && npm run dev
# 浏览器登录 → 进所有页面 → 都正常加载
```
---
### Task 2.4: 前端平台层 — 总设置 UI
**⚠️ 先看 [docs/UI-STYLE.md](UI-STYLE.md) — 平台层 UI 必须 100% 复用 CarLog 现有 UI,不另起设计。**
参考页面:`client/src/views/Settings.vue`(最接近总设置的风格)
**强制要求**:
- ✅ 用 `<AppLayout>` 包整个页面
- ✅ 标题 `<h1 class="title">` + 副标题 `<p class="text-soft">`
- ✅ loading 用 `<div class="card card-pad text-soft">`
- ✅ error 用 `<div class="card card-pad text-danger">`
- ✅ 按钮 `<button class="btn btn-primary">`
- ✅ 输入用 `.input / .select / .textarea` + `.label`
- ✅ 卡片用 `.card.card-pad`
- ✅ 消息 `<p class="msg ok">` / `<p class="msg err">`
- ❌ 不要 inline `style="color: ..."`
- ❌ 不要引入新 UI 库
- ❌ 不要写新的 CSS 变量
- ❌ 不要自定义 button 样式
完整模板 + 设计令牌 + 工具类速查表见 UI-STYLE.md。
**新增文件**: `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: 前端平台层 — 通用子系统设置渲染器
**⚠️ UI 规范同 Task 2.4 — 看 [docs/UI-STYLE.md](UI-STYLE.md) 第 4.2 节有完整模板**
参考 Settings.vue 的卡片 + form 结构。字段类型 → 控件映射:
| settings_schema.type | 控件 |
|---|---|
| `string` | `<input class="input">` |
| `password` | `<input type="password" class="input">` |
| `number` | `<input type="number" class="input">` |
| `boolean` | `<input type="checkbox">` |
| `textarea` | `<textarea class="textarea">` |
| `select` | `<select class="select">` + `<option>` |
**强制要求**: 用 `.card.card-pad` + `.section-title` + `.form` 包裹;每个字段用 `<div class="mt-3"><label class="label">` 包裹;未支持的类型显示 `.text-warn`
**新增文件**: `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 分组
**⚠️ UI 规范**: 看 [docs/UI-STYLE.md](UI-STYLE.md) — 不要改 AppHeader 现有样式,只改数据来源
**修改文件**: `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: 前端平台层 — 路由 + 入口
**⚠️ Subsystems.vue 完整模板见 [docs/UI-STYLE.md 第 4.3 节](UI-STYLE.md#43-subsystemsvue管理页)**(桌面 table.data + 移动 MobileCardList
**修改文件**: `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>
```
---
### Task 2.8: 表前缀迁移(21 张 CarLog 表加 `carlog_` 前缀)
**目标**:把 CarLog 业务表全部加 `carlog_` 前缀,将来加新子系统(fitness / reading 等)时不需要再做表前缀迁移。
**为什么放最后**:表前缀迁移涉及 DB 改动 + 大量 SQL 文件修改,独立于前端 UI / 路由改动,便于出问题回滚。Phase 2.1-2.7 全部完成后再做。
#### Task 2.8.1: 备份 DB
**⚠️ 必做:迁移前先备份**(防数据丢失)
```bash
mysqldump -h 162.14.110.130 -P 33306 -u carlog -pZeMRBwXP8JC6B3rF carlog > backup-before-prefix-$(date +%Y%m%d-%H%M%S).sql
ls -lh backup-before-prefix-*.sql # 确认备份文件大小合理(>100KB = 有数据)
```
#### Task 2.8.2: 写 migration `020_carlog_prefix.sql`
**新增文件**: `server/migrations/020_carlog_prefix.sql`
**内容**:
```sql
-- ============================================================
-- 020_carlog_prefix.sql — i 平台基座 (CarLog 表加 carlog_ 前缀)
-- ============================================================
-- 21 张 CarLog 业务表加 carlog_ 前缀。共享表 (users/login_attempts/auth_locks/schema_migrations) 不动。
-- ⚠️ 跑前必须 mysqldump 备份!RENAME 是 DDL 不能事务回滚。
-- 清理 0013 临时表(如果有)
DROP TABLE IF EXISTS _weather_snapshots_new;
RENAME TABLE
vehicles TO carlog_vehicles,
wash_records TO carlog_wash_records,
chemicals TO carlog_chemicals,
insurance_records TO carlog_insurance_records,
chemical_usage TO carlog_chemical_usage,
refuel_records TO carlog_refuel_records,
settings TO carlog_settings,
maintenance_records TO carlog_maintenance_records,
charging_records TO carlog_charging_records,
weather_snapshots TO carlog_weather_snapshots,
operation_logs TO carlog_operation_logs,
record_tags TO carlog_record_tags,
notifications TO carlog_notifications,
wash_photos TO carlog_wash_photos,
tags TO carlog_tags,
user_achievements TO carlog_user_achievements,
notification_prefs TO carlog_notification_prefs,
grocy_sync_logs TO carlog_grocy_sync_logs,
category_mappings TO carlog_category_mappings,
chemical_inventory_log TO carlog_chemical_inventory_log;
-- 不动(共享表):
-- users, login_attempts, auth_locks, schema_migrations
-- subsystems, platform_settings (019 migration 新建)
```
**注意**:
- MySQL RENAME TABLE 自动 update FK 引用(如果有外键),无需手动改 FK
- carlog_settings 是 CarLog 自己的配置表(ai / grocy / 密码等),**不是平台共享表**
- migration 跑完记录在 schema_migrations 表里,重启 server 自动加载
**验证**:
```bash
mysql -h 162.14.110.130 -P 33306 -u carlog -pZeMRBwXP8JC6B3rF carlog -e "SHOW TABLES;"
# 期望: 看到 21 张 carlog_* 表 + 6 张共享表 (users/login_attempts/auth_locks/schema_migrations/subsystems/platform_settings)
# 期望: 没有不带前缀的 vehicles / wash_records 等
mysql -h 162.14.110.130 -P 33306 -u carlog -pZeMRBwXP8JC6B3rF carlog -e "SELECT COUNT(*) FROM carlog_vehicles;"
# 期望: 数字跟改前 vehicles 表一致
```
#### Task 2.8.3: 改 server 端 SQL13 个路由文件 + reset-all.js
**目标**:所有 server/src/ 里引用 CarLog 表名的 SQL 全部加 `carlog_` 前缀。
**CarLog 业务表清单**21 张 — 改这些):
```
vehicles, wash_records, chemicals, insurance_records, chemical_usage,
refuel_records, settings, maintenance_records, charging_records,
weather_snapshots, operation_logs, record_tags, notifications,
wash_photos, tags, user_achievements, notification_prefs,
grocy_sync_logs, category_mappings, chemical_inventory_log
```
**共享表清单**5 张 — **不动**):
```
users, login_attempts, auth_locks, schema_migrations
subsystems + platform_settings 是 019 新建,已经是带 platform 前缀的)
```
**操作方法**:
1. 全局 grep 找所有 CarLog 表名引用
2. 一个个 .js 文件改 SQL
3. 跑 server 测试验证
**必须改的文件**(按引用频次排序,看 `ls server/src/subsystems/carlog/routes/`):
```
vehicles.js (53 次 vehicles)
washes.js (52 次 wash_records)
chemicals.js (30 次 chemicals + 22 次 chemical_usage)
insurance.js (22 次 insurance_records)
extra.js (charging_records + maintenance_records + refuel_records 等)
ai.js (settings 表)
settings.js (settings 表 16 次)
auth.js (users/login_attempts/auth_locks — 共享表,不动)
notifications.js (6 次 notifications + 3 次 notification_prefs)
tags.js (4 次 tags + 7 次 record_tags)
achievements.js (3 次 user_achievements)
operationLogs.js (9 次 operation_logs)
weather.js (14 次 weather_snapshots)
grocy.js (3 次 grocy_sync_logs + 2 次 category_mappings)
```
**安全替换脚本**(按表名替换,**先备份**:
```bash
cd server/src/subsystems/carlog/
# 复制原文件做对照(可选)
for f in $(grep -rl "FROM \`\?vehicles\`\?\|JOIN \`\?vehicles\`\?\|INTO \`\?vehicles\`\?\|UPDATE \`\?vehicles\`\?" routes/); do
sed -i '' 's/\bvehicles\b/carlog_vehicles/g; s/\bwash_records\b/carlog_wash_records/g; s/\bchemicals\b/carlog_chemicals/g; s/\binsurance_records\b/carlog_insurance_records/g; s/\bchemical_usage\b/carlog_chemical_usage/g; s/\brefuel_records\b/carlog_refuel_records/g; s/\bsettings\b/carlog_settings/g; s/\bmaintenance_records\b/carlog_maintenance_records/g; s/\bcharging_records\b/carlog_charging_records/g; s/\bweather_snapshots\b/carlog_weather_snapshots/g; s/\boperation_logs\b/carlog_operation_logs/g; s/\brecord_tags\b/carlog_record_tags/g; s/\bnotifications\b/carlog_notifications/g; s/\bwash_photos\b/carlog_wash_photos/g; s/\btags\b/carlog_tags/g; s/\buser_achievements\b/carlog_user_achievements/g; s/\bnotification_prefs\b/carlog_notification_prefs/g; s/\bgrocy_sync_logs\b/carlog_grocy_sync_logs/g; s/\bcategory_mappings\b/carlog_category_mappings/g; s/\bchemical_inventory_log\b/carlog_chemical_inventory_log/g' "$f"
done
```
**⚠️ 警告**:
- sed 必须用 `\b` word boundary,否则 `notifications` 会匹配 `notification_prefs` 之类
- **`users / login_attempts / auth_locks / schema_migrations` 不要替换**(共享表)— sed 上面没列这 4 个,安全
- 改完跑一次 `grep -E "FROM|JOIN|INTO|UPDATE" server/src/` 检查所有引用都加前缀了
-`cd server && npm test` 看测试是否破
**reset-all.js 必须改**:
```bash
# 在 server/src/bin/reset-all.js 改:
# KEEP_TABLES = ['users', 'carlog_settings', 'schema_migrations', 'auth_locks', 'login_attempts',
# 'carlog_category_mappings'];
# TABLES_IN_ORDER = ['carlog_operation_logs', 'carlog_chemical_usage', ...] 全部加前缀
```
**Trae 自检命令**:
```bash
# 1. 任何 SQL 引用未加前缀的 CarLog 表
grep -rE "(FROM|JOIN|INTO|UPDATE) \`?(vehicles|wash_records|chemicals|refuel_records|maintenance_records|charging_records|insurance_records|chemical_usage|weather_snapshots|operation_logs|tags|record_tags|achievements|user_achievements|notifications|notification_prefs|wash_photos|grocy_sync_logs|category_mappings|chemical_inventory_log|settings)\`?" server/src/ \
| grep -v "carlog_" \
| head -10
# 期望: 没有输出(除了 users/login_attempts/auth_locks/schema_migrations 共享表)
# 2. carlog_ 前缀引用数量应该跟原引用数量一致
echo "原 vehicles 引用: $(grep -r 'carlog_vehicles' server/src/ | wc -l)"
# 期望: 53 (跟 Phase 0 grep 数一致)
```
#### Task 2.8.4: 跑测试 + 手动验证
```bash
# 1. 跑 server 测试
cd server && npm test
# 期望: 旧 101 测试 + 新平台测试全过
# 2. 数据完整性
mysql -h 162.14.110.130 -P 33306 -u carlog -pZeMRBwXP8JC6B3rF carlog -e "
SELECT
(SELECT COUNT(*) FROM carlog_vehicles) AS vehicles,
(SELECT COUNT(*) FROM carlog_wash_records) AS washes,
(SELECT COUNT(*) FROM carlog_refuel_records) AS refuels,
(SELECT COUNT(*) FROM carlog_settings) AS settings_rows
"
# 期望: 数字跟 Phase 0 备份前一致(数据没动)
# 3. 启动 server 跑完整 E2E(同 Phase 3.3 的 14 步)
cd server && npm run dev
cd ../client && npm run dev
# 浏览器登录 → 所有页面 → 都正常加载(说明 SQL 没漏)
```
#### Task 2.8.5: 删除备份(验证完后再删)
```bash
# 跑完所有验证,确认没问题后
rm backup-before-prefix-*.sql
```
---
## 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. 不做的事(明确边界)
-~~**表前缀迁移**~~ → 改为 ✅ Task 2.8 做(用户决定)
-**JWT / SSO**:单用户 cookie session 已经够
-**iframe 嵌入**:同 SPA,不需要 iframe
-**独立子系统部署**:所有子系统一个进程
-**manifest 协议**subsystem 直接走 SQL 注册,不走 HTTP manifest 拉取
-**加第二个子系统**(健身 / 阅读 / 任何):用户还没决定下一子系统做什么,本阶段**只做底座 + CarLog 子系统化**
-**子系统 dashboard widget 跨子系统聚合**:等 2+ 子系统再做
-**现有 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 时
### 6.1 必须附上的材料
```bash
# 1. diff stat
cd /Users/yabozi/wzpstudio/i
git diff main --stat > review-diff-stat.txt
# 2. server 测试
cd server && npm install && npm test > review-server-test.txt 2>&1
# 3. client lint + build
cd ../client && npm install && npm run lint > review-client-lint.txt 2>&1
# 4. DB 表清单
mysql -h 162.14.110.130 -P 33306 -u carlog -pZeMRBwXP8JC6B3rF carlog \
-e "SHOW TABLES; SELECT COUNT(*) AS subsystems FROM subsystems; SELECT COUNT(*) AS settings FROM platform_settings;" > review-db.txt 2>&1
# 5. 关键 5 个页面截图(手动)
# - Dashboard (登录后首页)
# - 总设置 (/settings/global)
# - CarLog 设置 (/settings/carlog)
# - 子系统管理 (/admin/subsystems)
# - 任一业务页面 (/washes 或 /vehicles)
# 命名: review-screen-{1..5}-{name}.png
```
### 6.2 我会逐项检查的内容
#### 6.2.1 数据库(migration 019
- [ ] `019_platform.sql` 是否幂等(再跑一次不报错)
- [ ] `subsystems` 表字段齐全:`id, name, description, icon, color, category, version, enabled, sort_order, settings_schema (JSON), nav_items (JSON), created_at, updated_at`
- [ ] `platform_settings` 表字段齐全:`key (PK), value (JSON), type, description, updated_at`
- [ ] CarLog seed 数据:id='carlog', category='vehicle', nav_items 至少含 `/, /vehicles, /washes, /refuels, /stats, /settings` 6 项
- [ ] settings_schema 至少 5 个字段(string/number/boolean/select/password 各覆盖)
#### 6.2.2 平台后端路由
- [ ] `routes/platform/subsystems.js`: GET 列表、GET 单个、PATCH 启停三件齐
- [ ] `routes/platform/settings.js`: GET list、GET single、PUT single、POST batch 四件齐
- [ ] `routes/platform/dashboard.js`: 聚合 CarLog 4-6 个 stats 字段
- [ ] 所有路由都走 `requireAuth` middleware
- [ ] 响应统一 `{ok: true, data: ...}` 包装
- [ ] JSON 字段(settings_schema / nav_items / value)正确 parse
- [ ] mount 到 `/api/platform/*` 三条线(subsystems / settings / dashboard
#### 6.2.3 CarLog 后端目录迁移
- [ ] `server/src/routes/` 只剩 `.gitkeep`13 个文件全移走)
- [ ] `server/src/subsystems/carlog/routes/` 13 个文件齐全
- [ ] 每个文件 import 路径:`'../../db.js'``'../../../db.js'`, middleware 同理
- [ ] `subsystems/carlog/index.js` 正确聚合导出(**实际 ls 一下 routes/ 名字再 import,别凭印象**
- [ ] `server/src/index.js` mount 全部加 `/api/carlog/` 前缀(13 条 mount line
- [ ] **没有任何残留的 `/api/vehicles` 等老路径 mount**
#### 6.2.4 CarLog 前端目录迁移
- [ ] `client/src/views/subsystems/carlog/` 20 个 view 齐全
- [ ] `client/src/views/` 只剩 `.gitkeep + Login.vue + Offline.vue` 等非子系统
- [ ] `client/src/router/index.js` 所有 CarLog view import 改成新路径
- [ ] **前端 router path 不变**`/washes` 仍是 `/washes`,不是 `/carlog/washes`
#### 6.2.5 前端 API baseURL(最易出 bug 的地方)
- [ ] `client/src/api/subsystems.js` 创建 `carlogApi` helperget/post/put/patch/delete 5 个方法 + `/api/carlog` 前缀)
- [ ] 8 个 CarLog api 文件(ai / auth / chemicals / insurance / logs / operationLogs / settings / ... 实际 ls 一下)**全部 import carlogApi 而不是 api**
- [ ] **grep 验证无残留**:
```bash
grep -rn "/api/vehicles\|/api/washes\|/api/refuels\|/api/insurance\|/api/chemicals\|/api/auth" \
client/src/ --include="*.js" --include="*.vue" \
| grep -v "client/src/api/subsystems.js"
# 期望: 没有输出
```
- [ ] 20 个 view 里如果有 inline `api.get('/xxx')` 调用也改成 `carlogApi.get('/xxx')`
#### 6.2.6 平台前端
- [ ] `GlobalSettings.vue`: 5 个总设置字段 + 加载/保存 + alert 提示
- [ ] `SubsystemSettings.vue`: 通用渲染器支持 string/number/boolean/select/password/textarea 6 种类型
- [ ] `Subsystems.vue`: 列表 + 启停开关
- [ ] `stores/platform.js`: `subsystems` ref + `loadSubsystems()` + `groupedCategories` computed
- [ ] `AppLayout.vue`: 菜单读 `platform.groupedCategories` 而不是硬编码
- [ ] 启动时调 `platform.loadSubsystems()`mounted / onMounted
#### 6.2.6b 平台前端 UI 规范(必查,按 [docs/UI-STYLE.md](UI-STYLE.md)
- [ ] 所有平台 viewGlobalSettings / SubsystemSettings / Subsystems / Dashboard)顶层用 `<AppLayout>` 包
- [ ] 标题用 `<h1 class="title">` + 副标题 `<p class="text-soft">`
- [ ] loading 用 `<div class="card card-pad text-soft">`error 用 `<div class="card card-pad text-danger">`
- [ ] 按钮 `<button class="btn btn-primary">` / `.btn-ghost` / `.btn-danger`,不用自定义
- [ ] 输入控件用 `.input / .select / .textarea` + `.label`
- [ ] 卡片用 `.card.card-pad`pill 用 `.pill` + 颜色变体
- [ ] 消息用 `<p class="msg ok">` / `<p class="msg err">`
- [ ] 响应式用 `.mobile-only` / `.desktop-only` + 现有断点
- [ ] **没有任何 inline `style="color: ..."`**grep 检查)
- [ ] **没有引入新 UI 库**package.json + 静态分析)
- [ ] **没有新加 CSS 变量**(除了 style.css 里已有的)
- [ ] **颜色 / 字体 / 间距全部走 CSS 变量**(`var(--xxx)` 或工具类)
- [ ] **跟现有 `Settings.vue` 视觉一致**(颜色 / 圆角 / 间距)
Trae 自检命令:
```bash
# 1. inline 颜色
grep -rn 'style="color:' client/src/views/Platform/
# 期望: 空
# 2. 新 UI 库
grep -rn 'ant-design\|element-plus\|naive-ui\|vuetify\|@mui' client/src/ package.json
# 期望: 空
# 3. AppLayout 引用
for f in client/src/views/Platform/*.vue; do
grep -L 'AppLayout' "$f" && echo "❌ $f 缺 AppLayout"
done
# 期望: 没有输出
```
#### 6.2.7 路由
- [ ] `client/src/router/index.js` 加 3 条平台路由:`/settings/global`, `/settings/:subsystem`, `/admin/subsystems`
- [ ] 3 个平台 view 都在 `meta.requiresAuth = true`
- [ ] path **不重复**现有 router
#### 6.2.8 测试
- [ ] `server/test/routes/platform.*.test.js` 至少 12 个新测试
- [ ] **旧 101 个测试一个不破**(任何破测试 = Phase 2 失败)
- [ ] 测试用 supertest + mock db,参考 `server/test/setup.js`
#### 6.2.9 E2E 手动验证
我会跑这 14 步(Dev Plan Phase 3.3 清单),任何一步失败都要返工:
1. 登录 admin/carwash2026
2. 左侧菜单按 🚗 车辆 / ⚙️ 系统 分组
3. `/washes` 加载列表
4. `/vehicles` 加载列表
5. `/refuels` 加载列表
6. `/stats` 加载图表
7. `/settings/global` 加载 + 5 字段显示
8. 改主题 → 保存 → 刷新保持
9. `/settings/carlog` 加载 + 5 字段显示
10. 改 `ai.provider` → 保存 → DB 里有 `carlog.ai.provider`
11. `/admin/subsystems` 看到 CarLog 一条
12. 取消启用 → 刷新 → 菜单无 CarLog
13. 重新启用 → 菜单恢复
14. 重新登录 → 设置还在
#### 6.2.10 数据完整性
- [ ] migration 019 **没动**现有 14 张 CarLog 表
- [ ] `SELECT COUNT(*) FROM vehicles;` 等数字跟改造前一致
#### 6.2.11 表前缀迁移(Task 2.8 — 必查)
- [ ] **备份存在**: `backup-before-prefix-*.sql` 大小合理(>100KB
- [ ] **migration 020** 跑了,`SHOW TABLES;` 有 21 张 `carlog_*` 表
- [ ] **共享表没动**: `users / login_attempts / auth_locks / schema_migrations / subsystems / platform_settings` 名字还是老的
- [ ] **数据完整**: `SELECT COUNT(*) FROM carlog_vehicles;` 等跟改前 `vehicles` 一致
- [ ] **server SQL 改全**: grep 验证无残留未加前缀的 CarLog 表名
- [ ] **reset-all.js 改了**: `KEEP_TABLES` 含 `'carlog_settings'`, `TABLES_IN_ORDER` 全部加前缀
- [ ] **旧 101 测试不破**(任何破测试 = SQL 漏改)
- [ ] **新增平台测试不破**
- [ ] **手动 E2E 全过**: 登录 → 进所有页面 → 增删改查 → 通知/成就/提醒/统计都正常
- [ ] **备份可以删**(验证完没问题后 `rm backup-before-prefix-*.sql`
### 6.3 我会特别盯的几个常见坑
1. **mysql2 JSON 字段 parse 漏** — 直接返回字符串给前端,前端 `data.settings_schema` 变 `[object Object]` 或 undefined
2. **TINYINT(1) 不转 boolean** — 前端 `data.enabled` 可能是 0/1 而不是 true/false
3. **mount 顺序错** — auth middleware 在业务 route 之后 mount 导致 token 检查失败
4. **carwash 路由文件 import 路径没改全** — `from '../../db.js'` 还是老的,跑起来才报错
5. **前端 API 没改全** — 有些 view 里 inline `axios.get('/vehicles')` 漏改,所有页面 404
6. **router path 改了** — 用户 URL 从 `/washes` 变 `/carlog/washes`,用户书签/链接全失效(**不要改 router path**
7. **platform store 没在 AppLayout mounted 加载** — 菜单永远空
8. **subsystem 启停后没 reload store** — 切启用状态菜单没更新
9. **nav_items JSON 没 parse** — 菜单变 `[object Object]`
10. **测试 mock 没覆盖新路由** — 测试覆盖率掉了
### 6.4 review 通过标准
- 所有 6.2 子项勾完
- 6.3 10 个常见坑都不存在
- 6.1 五份材料齐全
- 我跑完测试 + E2E 后给「OK」反馈
任何 review 反馈都要修完再合并下一阶段。