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

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

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

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

41 KiB
Raw Blame History

开发计划:i 平台基座 + CarLog 子系统化

目标读者Trae(另一个 AI IDE 工具),按本文档逐步实施,最终提交代码由 Mavis(我)做 code review + 跑测试。

完成定义:所有 Phase 1 + Phase 2 任务通过验收测试,端到端流程跑通。


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: 77adc8eREADME + ARCHITECTURE.md + .gitignore
  • CarLog v2.8 代码已经拷到 i 仓库
    • server/src/ (13 个 route 文件 + middleware + services)
    • server/migrations/ (0001~0018 共 18 个迁移)
    • client/src/ (20 个 view + components + stores + api)
    • 配置文件 (.editorconfig, .eslintrc.json, .prettierrc.json, vitest.config.js, package.json, etc.)
  • MySQL: 162.14.110.130:33306 / carlog(密码 ZeMRBwXP8JC6B3rF.env 里读)
  • node_modules: 没装,需要 npm install 装 server 和 client

0.2 关键决策

决策 选定 理由
子系统隔离 物理目录 + 表前缀 单用户场景,分进程/分库是过度工程
平台层和子系统共享一个进程 一个 Express server
路由前缀 保持现状 /api/* 改 URL 路径影响前端所有 link/api call,工作量大且无收益
CarLog 代码目录 移到 server/src/subsystems/carlog/ 物理隔离,加子系统不会乱碰
CarLog 前端目录 移到 client/src/views/subsystems/carlog/ 同上
CarLog 路由 path 不变(/washes/vehicles 用户体验一致
CarLog 表前缀 Phase 2 不做(留给 Phase 3 当前阶段数据库里只有 CarLog 的表,没必要急着加前缀;加第二个子系统前再做
平台层路由前缀 /api/platform/* 平台层独立路径空间
平台层 UI 路径 /settings/global, /settings/:subsystem, /admin/subsystems 用户能直接 URL 进入
元数据驱动 subsystems 表的 settings_schema + nav_items JSON 字段 加新子系统不用改平台前端代码

Phase 1: 平台基座

Task 1.1: 数据库迁移(subsystems + platform_settings 表)

新增文件: server/migrations/019_platform.sql

内容:

-- ============================================================
-- 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', '备份路径');

验证:

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

内容:

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

验证:

# 启动 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

内容:

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?}

验证:

# 列表(无 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

内容:

import express from 'express';
import { db } from '../../db.js';
import { requireAuth } from '../../middleware/auth.js';

const router = express.Router();

// GET /api/platform/dashboard — 跨子系统聚合数据
router.get('/', requireAuth, async (req, res, next) => {
    try {
        // Phase 2 只聚合 CarLog 的关键 stats
        const [vehicles] = await db().execute(`SELECT COUNT(*) AS n FROM vehicles WHERE deleted_at IS NULL`);
        const [washesThisMonth] = await db().execute(
            `SELECT COUNT(*) AS n, COALESCE(SUM(total_cost), 0) AS total
             FROM wash_records
             WHERE deleted_at IS NULL
               AND DATE_FORMAT(wash_date, '%Y-%m') = DATE_FORMAT(UTC_DATE(), '%Y-%m')`
        );
        const [refuelsThisMonth] = await db().execute(
            `SELECT COUNT(*) AS n, COALESCE(SUM(total_cost), 0) AS total, COALESCE(SUM(liters), 0) AS liters
             FROM refuel_records
             WHERE deleted_at IS NULL
               AND DATE_FORMAT(refuel_date, '%Y-%m') = DATE_FORMAT(UTC_DATE(), '%Y-%m')`
        );

        res.json({
            ok: true,
            data: {
                carlog: {
                    vehicles_count: vehicles[0].n,
                    this_month: {
                        washes: washesThisMonth[0].n,
                        wash_cost: Number(washesThisMonth[0].total),
                        refuels: refuelsThisMonth[0].n,
                        refuel_cost: Number(refuelsThisMonth[0].total),
                        refuel_liters: Number(refuelsThisMonth[0].liters),
                    },
                },
                // fitness: {...},  // Phase 3 加
                // reading: {...},  // Phase 3 加
            },
        });
    } catch (err) {
        next(err);
    }
});

export default router;

注意:

  • 现在只聚 CarLogFitness / Reading 子系统加进来后再扩
  • 用 UTC_DATE()mysql2 timezone='Z' 配置已经设为 UTC
  • 总数 / 总额用 Number() 转 JS numbermysql2 返回 DECIMAL 是字符串)
  • 后续扩字段时按需加

验证:

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 已有模式),在合适位置加:

// 在 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 顺序不重要(路径都不重叠)

验证:

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)。

验证:

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:

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

// 聚合导出 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', 不要瞎猜。

验证:

ls server/src/subsystems/carlog/routes/  # 期望: 13 个 .js 文件
ls server/src/routes/  # 期望: 只有 .gitkeep

cd server && node -e "import('./src/subsystems/carlog/index.js').then(m => console.log(Object.keys(m)))"
# 期望: 所有 router 名字列出

Task 2.2: 后端路由挂载从新目录

修改文件: server/src/index.js

改动: 把现有 mount 改用新 index.js 导出的 router

// 原来的:
// import vehiclesRouter from './routes/vehicles.js';
// app.use('/api/vehicles', vehiclesRouter);

// 改成:
import {
    vehiclesRouter, washesRouter, refuelsRouter,
    insuranceRouter, chemicalsRouter, aiRouter,
    authRouter, settingsRouter, logsRouter,
    operationLogsRouter, extraRouter, tagsRouter,
    notificationsRouter, achievementsRouter,
} from './subsystems/carlog/index.js';

app.use('/api/vehicles', vehiclesRouter);
app.use('/api/washes', washesRouter);
app.use('/api/refuels', refuelsRouter);  // 注意: 如果原文件名是 refuels.js 就这样, 看实际
// ... 其他 11 个
app.use('/api/insurance', insuranceRouter);
app.use('/api/chemicals', chemicalsRouter);
app.use('/api/ai', aiRouter);
app.use('/api/auth', authRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/logs', logsRouter);
app.use('/api/operation-logs', operationLogsRouter);
app.use('/api/extra', extraRouter);
app.use('/api/tags', tagsRouter);
app.use('/api/notifications', notificationsRouter);
app.use('/api/achievements', achievementsRouter);

重要:

  • 路由 path 不要变(保持 /api/vehicles 而不是 /api/carlog/vehicles
  • 文件名要按 ls server/src/subsystems/carlog/routes/ 实际看到的写
  • 一定要先 cd 到 server/src/subsystems/carlog/routes 看看实际文件名, 常见陷阱:
    • refuels.js vs refuel.js
    • washes.js vs wash.js (中文习惯复数)
    • chemicals.js vs chemical.js
  • mount 顺序保持不变(先 auth 后业务,因为有些 middleware 依赖 auth

验证:

cd server && npm run dev
# 登录 + 列车辆 + 列洗车 + 列加油 + 列成就 + 列通知 + 全 OK
TOKEN=$(curl -s -X POST http://localhost:8787/api/auth/login -H 'Content-Type: application/json' -d '{"username":"admin","password":"carwash2026"}' | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['token'])")
for ep in vehicles washes refuels insurance chemicals tags notifications achievements; do
  echo "Testing /api/$ep ..."
  curl -s -H "Authorization: Bearer $TOKEN" "http://localhost:8787/api/$ep" | head -1
done
# 期望: 每行返回 JSON 不报错

Task 2.3: 前端代码目录迁移

目标: 把 client/src/views/*.vue20 个 view)和相关 component / composable / store 移到子系统目录

操作:

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 修改:

// 原来的:
// import WashesList from '../views/WashesList.vue';

// 改成:
// import WashesList from '../views/subsystems/carlog/WashesList.vue';

// 其他 19 个 view 同理

验证:

ls client/src/views/subsystems/carlog/  # 期望: 20 个 .vue 文件
ls client/src/views/  # 期望: 只有 .gitkeep + Login.vue + Offline.vue 等非子系统文件

cd client && npm run dev
# 浏览器打开 http://localhost:5173/
# 登录后能正常进 /vehicles /washes /refuels /stats 等所有页面

Task 2.4: 前端平台层 — 总设置 UI

新增文件: client/src/views/Platform/GlobalSettings.vue

内容: 总设置页面

  • 表单: 主题(auto/light/dark select)、语言(zh-CN/en select)、Dashboard 布局(default/compact select)、备份开关(boolean checkbox)、备份路径(string input
  • 加载数据: GET /api/platform/settings 过滤 platform 层 key(不带 prefix
  • 保存: POST /api/platform/settings/batch
  • 用现有 <style> 保持 CarLog 风格(看 client/src/views/Settings.vue

关键代码骨架:

<template>
  <div class="global-settings">
    <h2>总设置</h2>
    <form @submit.prevent="save">
      <label>主题 <select v-model="form.theme">
        <option value="auto">跟随系统</option>
        <option value="light">浅色</option>
        <option value="dark">深色</option>
      </select></label>
      <!-- 其他字段 -->
      <button type="submit">保存</button>
    </form>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../../api/client.js';

const form = ref({
    theme: 'auto',
    language: 'zh-CN',
    dashboardLayout: 'default',
    backupEnabled: false,
    backupPath: '',
});

const origKeys = {
    theme: 'ui.theme',
    language: 'ui.language',
    dashboardLayout: 'dashboard.layout',
    backupEnabled: 'backup.enabled',
    backupPath: 'backup.path',
};

async function load() {
    const { data } = await api.get('/api/platform/settings');
    for (const s of data) {
        const field = Object.entries(origKeys).find(([_, k]) => k === s.key)?.[0];
        if (field) form.value[field] = s.value;
    }
}

async function save() {
    const settings = Object.entries(origKeys).map(([field, key]) => ({
        key,
        value: form.value[field],
    }));
    await api.post('/api/platform/settings/batch', { settings });
    alert('已保存');
}

onMounted(load);
</script>

注意:

  • 路由 path /settings/global
  • api.client.js 而不是 raw axios(自动带 auth + 解包)

Task 2.5: 前端平台层 — 通用子系统设置渲染器

新增文件: client/src/views/Platform/SubsystemSettings.vue

核心能力: 读 URL 参数 :subsystem(比如 carlog),加载 GET /api/platform/subsystems/:id 拿 settings_schema,渲染动态表单,保存到 /api/platform/settings/batch

支持的字段类型:

  • string<input type="text">
  • number<input type="number">
  • boolean<input type="checkbox">
  • select<select> (options 数组)
  • password<input type="password">
  • textarea<textarea>
  • multiselect<select multiple> (Phase 2 可以暂不做,留 TODO)

关键代码骨架:

<template>
  <div class="subsystem-settings" v-if="schema">
    <h2>{{ subsystemName }} 设置</h2>
    <form @submit.prevent="save">
      <div v-for="field in schema.fields" :key="field.key" class="field">
        <label>{{ field.label }}</label>
        <input v-if="field.type === 'string'" type="text" v-model="values[field.key]" />
        <input v-else-if="field.type === 'password'" type="password" v-model="values[field.key]" />
        <input v-else-if="field.type === 'number'" type="number" v-model.number="values[field.key]" />
        <input v-else-if="field.type === 'boolean'" type="checkbox" v-model="values[field.key]" />
        <textarea v-else-if="field.type === 'textarea'" v-model="values[field.key]" />
        <select v-else-if="field.type === 'select'" v-model="values[field.key]">
          <option v-for="opt in field.options" :key="opt" :value="opt">{{ opt }}</option>
        </select>
        <span v-else>未支持的字段类型: {{ field.type }}</span>
      </div>
      <button type="submit">保存</button>
    </form>
  </div>
</template>

<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../../api/client.js';

const route = useRoute();
const subsystemId = computed(() => route.params.subsystem);
const schema = ref(null);
const subsystemName = ref('');
const values = ref({});

async function load() {
    const { data: sub } = await api.get(`/api/platform/subsystems/${subsystemId.value}`);
    schema.value = sub.settings_schema;
    subsystemName.value = sub.name;
    // 加载当前值
    values.value = {};
    for (const field of sub.settings_schema.fields) {
        values.value[field.key] = field.default;
    }
    const { data: settings } = await api.get(`/api/platform/settings?prefix=${subsystemId.value}.`);
    for (const s of settings) {
        // key 是 'carlog.weather.default_city', 去掉前缀
        const fieldKey = s.key.substring(s.key.indexOf('.') + 1);
        values.value[fieldKey] = s.value;
    }
}

async function save() {
    const settings = Object.entries(values.value).map(([key, value]) => ({
        key: `${subsystemId.value}.${key}`,
        value,
    }));
    await api.post('/api/platform/settings/batch', { settings });
    alert('已保存');
}

onMounted(load);
</script>

注意:

  • 不要 hardcode schema — 一切从 subsystems.settings_schema
  • value 是从 server 读到的 JSON(已经 parse 过),直接用
  • 保存时 key 加 ${subsystemId}. 前缀

Task 2.6: 前端平台层 — 左侧菜单按 category 分组

修改文件: client/src/AppLayout.vue(或类似 — 找到现有左侧导航 component)

改动:

  1. 删掉硬编码的菜单列表
  2. usePlatformStoresubsystems
  3. category 分组渲染,每组内按 nav_items[].sort 排序

新增 store: client/src/stores/platform.js

import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import { api } from '../api/client.js';

export const usePlatformStore = defineStore('platform', () => {
    const subsystems = ref([]);
    const loading = ref(false);

    async function loadSubsystems() {
        loading.value = true;
        try {
            const { data } = await api.get('/api/platform/subsystems');
            subsystems.value = data;
        } finally {
            loading.value = false;
        }
    }

    const groupedCategories = computed(() => {
        const groups = {};
        for (const sub of subsystems.value) {
            if (!sub.enabled) continue;
            if (!groups[sub.category]) {
                groups[sub.category] = {
                    category: sub.category,
                    items: [],
                };
            }
            for (const nav of (sub.nav_items || [])) {
                groups[sub.category].items.push({
                    ...nav,
                    subsystemId: sub.id,
                    subsystemIcon: sub.icon,
                });
            }
        }
        // 每个 category 内按 sort 排序
        for (const g of Object.values(groups)) {
            g.items.sort((a, b) => (a.sort || 0) - (b.sort || 0));
        }
        // category 顺序按子系统 sort_order 的最小值
        const orderMap = {};
        for (const sub of subsystems.value) {
            if (orderMap[sub.category] === undefined || sub.sort_order < orderMap[sub.category]) {
                orderMap[sub.category] = sub.sort_order;
            }
        }
        return Object.values(groups).sort((a, b) => orderMap[a.category] - orderMap[b.category]);
    });

    return { subsystems, loading, loadSubsystems, groupedCategories };
});

AppLayout.vue 改动:

  • 替换硬编码菜单为 <template v-for="cat in platform.groupedCategories"> + 子循环
  • 启动时调 platform.loadSubsystems()
  • category 显示: vehicle → 🚗 车辆 / fitness → 💪 健康 / reading → 📚 阅读

注意:

  • 保留现有所有跳转路径(/vehicles 等)
  • /settings/global/settings/carlog 入口到「系统」category

Task 2.7: 前端平台层 — 路由 + 入口

修改文件: client/src/router/index.js

新增路由:

{
    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 页面,列出所有子系统 + 启停开关):

<template>
  <div class="subsystems">
    <h2>子系统管理</h2>
    <div v-for="sub in subsystems" :key="sub.id" class="sub-card">
      <span>{{ sub.icon }} {{ sub.name }}</span>
      <span class="version">{{ sub.version }}</span>
      <label><input type="checkbox" v-model="sub.enabled" @change="toggle(sub)" /> 启用</label>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import { api } from '../../api/client.js';
const subsystems = ref([]);
async function load() {
    const { data } = await api.get('/api/platform/subsystems');
    subsystems.value = data;
}
async function toggle(sub) {
    await api.patch(`/api/platform/subsystems/${sub.id}`, { enabled: sub.enabled });
}
onMounted(load);
</script>

Phase 3: 验证

Task 3.1: 跑 server 测试

cd server
npm install
npm test

期望:

  • 旧 101 个测试全部通过(代码迁移不能破现有功能)
  • 新增平台路由测试 12-20 个全部通过

Task 3.2: 跑 client 测试

cd client
npm install
npm test  # 如果有 vitest 配置
npm run lint

期望: 不报错。

Task 3.3: 手动 E2E

# 启动 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 数据完整性

mysql -h 162.14.110.130 -P 33306 -u carlog -p carlog -e "
SELECT COUNT(*) AS vehicles FROM vehicles;
SELECT COUNT(*) AS washes FROM wash_records;
SELECT COUNT(*) AS refuels FROM refuel_records;
"
# 期望: 数字跟 Phase 0 备份时一致(数据没动)

4. 不做的事(明确边界)

  • 表前缀迁移:本 Phase 不加 carlog_ 前缀,留到 Phase 3+ 加第二个子系统时再做
  • JWT / SSO:单用户 cookie session 已经够
  • iframe 嵌入:同 SPA,不需要 iframe
  • 独立子系统部署:所有子系统一个进程
  • manifest 协议subsystem 直接走 SQL 注册,不走 HTTP manifest 拉取
  • 子系统 dashboard widget 跨子系统聚合Phase 4 加第二子系统再做
  • 现有 CarLog 业务逻辑修改:迁移目录不改 SQL、不改业务、不改 UI
  • 现有 CarLog 测试代码修改:除非路径变了(深层 import

5. 交付清单

完成后应该有:

  • server/migrations/019_platform.sqlsubsystems + 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/*.js13 个文件从根 routes/ 移过来)
  • server/src/index.jsmount 平台路由 + 用新 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/*.vue20 个 view 移过来)
  • client/src/router/index.js(加 3 个平台路由 + 改 view import 路径)
  • client/src/AppLayout.vue(改左侧菜单读 platform store
  • server/test/routes/platform.subsystems.test.js
  • server/test/routes/platform.settings.test.js
  • server/test/routes/platform.dashboard.test.js

commit message 模板:

feat(platform): add platform base + carlog subsystem化

Phase 1: 平台基座
- migrations/019_platform.sql: subsystems + platform_settings 表 + seed CarLog
- routes/platform/{subsystems,settings,dashboard}.js: 3 个平台路由
- routes/platform/* mount 到 /api/platform/*
- 测试: 12-20 个平台测试通过

Phase 2: CarLog 子系统化
- routes/*.js → subsystems/carlog/routes/*.js (13 个文件)
- subsystems/carlog/index.js: 聚合导出
- index.js: 用新 index, mount 路径不变
- views/*.vue → views/subsystems/carlog/*.vue (20 个 view)
- views/Platform/{GlobalSettings,SubsystemSettings,Subsystems}.vue
- stores/platform.js: 元数据驱动菜单
- AppLayout.vue: 左侧菜单按 category 分组
- router: 加 3 个平台路由

验证:
- 旧 101 测试全过 + 新平台测试全过
- 手动 E2E 全过
- DB 数据完整性确认

6. 提交给 Mavis(我)做 code review 时

请把以下附上:

  1. git diff main --stat(看了多少行)
  2. cd server && npm test 输出
  3. cd client && npm run lint 输出
  4. MySQL SHOW TABLES; 输出
  5. 手动 E2E 截图(关键 5 个页面: Dashboard / 总设置 / CarLog 设置 / 子系统管理 / 任意业务页面)

我会逐文件 review + 跑测试 + 给改进意见。