d7dab31f19
按用户决定「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 张)
1614 lines
64 KiB
Markdown
1614 lines
64 KiB
Markdown
# 开发计划:i 平台基座 + CarLog 子系统化
|
||
|
||
> **目标读者**:Trae(另一个 AI IDE 工具),按本文档逐步实施,最终提交代码由 Mavis(我)做 code review + 跑测试。
|
||
>
|
||
> **完成定义**:所有 Phase 1 + Phase 2 (含 Task 2.8 表前缀迁移) 任务通过验收测试,端到端流程跑通。
|
||
|
||
---
|
||
|
||
## 0. 背景
|
||
|
||
i 是一个生活操作系统平台,**单 Vue SPA + 单 Express 进程 + 单 MySQL**。CarLog(v2.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 number(mysql2 返回 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.vue,CarLog 用 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 helper(baseURL)**
|
||
|
||
不要在每个 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`
|
||
- 平台层 view(GlobalSettings / 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 端 SQL(13 个路由文件 + 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` helper(get/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))
|
||
|
||
- [ ] 所有平台 view(GlobalSettings / 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 反馈都要修完再合并下一阶段。 |