# 开发计划: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`(最接近总设置的风格) **强制要求**: - ✅ 用 `` 包整个页面 - ✅ 标题 `

` + 副标题 `

` - ✅ loading 用 `

` - ✅ error 用 `
` - ✅ 按钮 `