feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删 - AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3 - 化学品 / Grocy 实例对接 + 库存镜像同步 - 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护 - 月度报表:Excel 6 sheet + PDF - PWA:manifest / SW / 离线缓存 / iOS 引导 - 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿 - 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就 - 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等 - 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications) - 部署:宝塔面板文档 + PM2 + Nginx
@@ -0,0 +1,19 @@
|
|||||||
|
# .editorconfig — 统一编辑器配置
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[*.{json,yml,yaml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
|
[*.vue]
|
||||||
|
indent_size = 4
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# 复制为 .env 后填入实际值(所有项可选;只配需要的功能即可)
|
||||||
|
|
||||||
|
# 服务监听
|
||||||
|
PORT=8787
|
||||||
|
HOST=127.0.0.1
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# 城市(默认)
|
||||||
|
APP_CITY=Beijing
|
||||||
|
|
||||||
|
# 天气 API(任选其一)
|
||||||
|
QWECHAT_API_KEY=
|
||||||
|
QWECHAT_HOST=devapi.qweather.com
|
||||||
|
OPENWEATHERMAP_API_KEY=
|
||||||
|
|
||||||
|
# Grocy 集成
|
||||||
|
GROCY_URL=
|
||||||
|
GROCY_API_TOKEN=
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2022": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"parser": "espree",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": "latest",
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": { "jsx": false }
|
||||||
|
},
|
||||||
|
"extends": ["eslint:recommended"],
|
||||||
|
"rules": {
|
||||||
|
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||||
|
"no-undef": "error",
|
||||||
|
"no-console": "off",
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "warn",
|
||||||
|
"eqeqeq": ["error", "always", { "null": "ignore" }],
|
||||||
|
"no-implicit-coercion": "warn",
|
||||||
|
"no-throw-literal": "error"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["client/src/**/*.vue"],
|
||||||
|
"parser": "vue-eslint-parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"parser": "espree"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"ignorePatterns": ["node_modules/", "client/dist/", "server/storage/", "uploads/", "*.min.js"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
.env
|
||||||
|
server/data/
|
||||||
|
server/storage/
|
||||||
|
uploads/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-shm
|
||||||
|
*.sqlite-wal
|
||||||
|
.setup_done
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
client/dist/
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.DS_Store
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
|
||||||
|
# Test / coverage
|
||||||
|
coverage/
|
||||||
|
.lighthouseci/
|
||||||
|
.dbg/
|
||||||
|
|
||||||
|
# Mavis plans (local AI workflow artifacts)
|
||||||
|
.mavis/
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# .husky/pre-commit — 提交前自动 lint + format 已暂存文件
|
||||||
|
. "$(dirname -- "$0")/_/husky.sh"
|
||||||
|
|
||||||
|
npx --no-install lint-staged
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"chromeLaunchConfig": {
|
||||||
|
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--headless=new"]
|
||||||
|
},
|
||||||
|
"standard": "WCAG2AA",
|
||||||
|
"runners": ["htmlcs"],
|
||||||
|
"ignore": [
|
||||||
|
"WCAG2AA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr",
|
||||||
|
"WCAG2AA.Principle1.Guideline1_4.1_4_3.Contrast",
|
||||||
|
"color-contrast"
|
||||||
|
],
|
||||||
|
"rules": [],
|
||||||
|
"timeout": 60000
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
server/node_modules/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
client/dist/
|
||||||
|
server/storage/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# 数据
|
||||||
|
server/data/*.sqlite
|
||||||
|
server/data/*.sqlite-shm
|
||||||
|
server/data/*.sqlite-wal
|
||||||
|
server/data/*.db
|
||||||
|
|
||||||
|
# 日志 / 临时
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
.setup_done
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"printWidth": 120,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"arrowParens": "always",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"vueIndentScriptAndStyle": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,870 @@
|
|||||||
|
# 洗车管理系统
|
||||||
|
|
||||||
|
个人 detailer 爱好者的私家车管理系统。Express + MySQL/SQLite + Vue 3 全栈单用户应用,所有数据保存在你自己的服务器上。
|
||||||
|
|
||||||
|
> 适用场景:给自己洗的 / 帮朋友洗的、给车加油 / 充电 / 保养 / 上保险做完整台账,对接 Grocy 做汽美用品库存,自动抓天气。
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
### 🌳 功能树(按领域组织)
|
||||||
|
|
||||||
|
```
|
||||||
|
洗车管理系统
|
||||||
|
├── 🔐 账号与权限
|
||||||
|
│ ├── 单用户登录(admin 默认账号 / bcrypt 密码)
|
||||||
|
│ ├── CSRF token 自动刷新 + 重试
|
||||||
|
│ ├── 登录失败锁定:IP 5 次锁 15 分 / 用户 5 次锁 30 分 / 全局 10 次锁 1 小时
|
||||||
|
│ ├── 锁定时显示「已错 N 次 / 还剩 N 次 / 锁定 X 分」+ 解锁倒计时
|
||||||
|
│ ├── session 过期 401 自动跳登录 + 表单草稿暂存
|
||||||
|
│ └── 修改密码 / 启用 / 停用账号
|
||||||
|
│
|
||||||
|
├── 🚗 车辆管理
|
||||||
|
│ ├── CRUD(车牌 / 品牌 / 型号 / 年款 / 颜色 / 类型)
|
||||||
|
│ ├── 软删除(is_deleted 标记,可从操作日志恢复)
|
||||||
|
│ ├── 车辆详情页:基本信息 + 健康卡片 + 6 月趋势图 + 5 tab 记录
|
||||||
|
│ ├── 车辆健康指标:油耗 / 电耗 / 洗车新鲜度 / 保养预测
|
||||||
|
│ └── 多车总览统计(总数 / 启用数 / 有洗车记录数)
|
||||||
|
│
|
||||||
|
├── 🧽 洗车记录(核心领域)
|
||||||
|
│ ├── CRUD + 类型:快速 / 标准 / 精洗 / 其他
|
||||||
|
│ ├── 自动抓天气快照(洗车当天的天气)
|
||||||
|
│ ├── 选化学品 → 自动扣减 Grocy 库存
|
||||||
|
│ ├── AI 截图识别小票 → 自动填表 + 失败兜底 modal
|
||||||
|
│ ├── 洗车前后对比照:上传 / 删除 / 并排对比
|
||||||
|
│ └── 批量删除
|
||||||
|
│
|
||||||
|
├── ⛽ 加油记录
|
||||||
|
│ ├── CRUD + 油品:92#/95#/98#/0#柴油/-10#/E92/E95/LPG
|
||||||
|
│ ├── 自动算百公里油耗(仅 is_full=1 相邻加满里程差 / 升数 × 100)
|
||||||
|
│ ├── 油价趋势图(按月聚合 derived_unit_price)
|
||||||
|
│ └── AI 截图识别小票
|
||||||
|
│
|
||||||
|
├── 🔌 充电记录
|
||||||
|
│ ├── CRUD + 充电类型:home / slow / fast / public
|
||||||
|
│ ├── 自动算百公里电耗(kWh / 里程差 × 100)
|
||||||
|
│ ├── 起止 SOC(电量 %)
|
||||||
|
│ └── AI 截图识别小票
|
||||||
|
│
|
||||||
|
├── 🔧 保养记录
|
||||||
|
│ ├── CRUD + 动态项目(机油 / 机滤 / 空滤 / 刹车油 / 防冻液...)
|
||||||
|
│ ├── datalist + presets 自动补全
|
||||||
|
│ ├── watch + auto calc total_cost = items Σ
|
||||||
|
│ ├── AI 截图识别小票
|
||||||
|
│ └── 下次保养里程预测
|
||||||
|
│
|
||||||
|
├── 🛡️ 保险记录
|
||||||
|
│ ├── CRUD + 类型:交强 / 商业 / 车损 / 三责 / 座位 / 不计免赔 / 玻璃 / 划痕 / 自燃 / 涉水
|
||||||
|
│ ├── 到期提醒(30 天内橙色,已过期红色)
|
||||||
|
│ ├── 附件上传(PDF 保单 / 照片)
|
||||||
|
│ └── AI 截图识别保单
|
||||||
|
│
|
||||||
|
├── 🧴 化学品 / 汽美用品
|
||||||
|
│ ├── Grocy 实例对接(session cookie / API token 双模式)
|
||||||
|
│ ├── 同步模式:手动 / 启动自动(grocy_pull_auto)
|
||||||
|
│ ├── 产品镜像:拉 Grocy 全量产品 + 库存到本地
|
||||||
|
│ ├── 库存操作:入库 / 扣减 / 盘点 / 低库存预警(grocy_low_stock_pct)
|
||||||
|
│ ├── 分类映射:本地分类 ↔ Grocy 分类
|
||||||
|
│ ├── 批量入库(BatchPurchase)
|
||||||
|
│ └── 全局搜索(grocy-search 走 Grocy API)
|
||||||
|
│
|
||||||
|
├── 📊 数据可视化
|
||||||
|
│ ├── 总览(stats/overview):今日 / 本月 / 30 天频次 / 月度成本
|
||||||
|
│ ├── 健康仪表盘:6 月趋势堆叠柱
|
||||||
|
│ ├── 油价趋势(stats/extra.fuelTrend):按月 derived_unit_price
|
||||||
|
│ ├── 年均养护成本(stats/extra.costPerVehicle):所有成本 / 持有天数 × 365
|
||||||
|
│ ├── 洗车季节频率(stats/extra.washSeason):按月 cnt + avg_cost
|
||||||
|
│ └── 各车成本明细表
|
||||||
|
│
|
||||||
|
├── 📑 报表
|
||||||
|
│ ├── 月度报表 Excel(6 sheet:车辆/洗车/加油/充电/保养/保险)
|
||||||
|
│ ├── 月度报表 PDF
|
||||||
|
│ └── 月份下拉(reports/monthly/list,过去 12-24 个月)
|
||||||
|
│
|
||||||
|
├── 🤖 AI 截图识别
|
||||||
|
│ ├── 5 种类型 OCR schema(wash/refuel/charge/maint/insurance)
|
||||||
|
│ ├── 多 provider:OpenAI 兼容 / MiniMax M3 多模态
|
||||||
|
│ ├── Provider 切换 + API key 配置
|
||||||
|
│ ├── 「测试连接」按钮(动态选真实上传图,避免 1×1 PNG 触发敏感)
|
||||||
|
│ ├── thinking 关闭(MiniMax M3 OCR 任务)
|
||||||
|
│ └── 识别失败兜底 modal(左图右表,手动填)
|
||||||
|
│
|
||||||
|
├── ⚙️ 设置
|
||||||
|
│ ├── 个人信息:用户名 / 修改密码
|
||||||
|
│ ├── AI 配置:provider / URL / key / model / 启用
|
||||||
|
│ ├── Grocy 配置:URL / 用户名 / 密码 / token / 同步策略
|
||||||
|
│ ├── 天气:默认城市(库尔勒)/ 实时天气
|
||||||
|
│ ├── 系统:登录锁定参数 / session 有效期 / cookie secure
|
||||||
|
│ ├── 数据重置(confirm_token 强校验)
|
||||||
|
│ └── Grocy 同步日志
|
||||||
|
│
|
||||||
|
├── 📜 操作日志
|
||||||
|
│ ├── 所有写操作记录(created/updated/deleted/recovered)
|
||||||
|
│ ├── 软删记录可一键恢复
|
||||||
|
│ └── 类型筛选(vehicles/washes/refuels/...)
|
||||||
|
│
|
||||||
|
├── 🛡️ 安全 & 防滥用
|
||||||
|
│ ├── bcrypt 密码
|
||||||
|
│ ├── express-session + httpOnly cookie
|
||||||
|
│ ├── CSRF token(所有非 GET 请求校验)
|
||||||
|
│ ├── 登录防撞库(IP + 用户 + 全局三级)
|
||||||
|
│ ├── IP 限流(AI 60s/10 次,sync 60s/10 次)
|
||||||
|
│ ├── 422 输入校验(字段必填 / 类型 / 长度)
|
||||||
|
│ └── 操作日志审计
|
||||||
|
│
|
||||||
|
├── 📱 PWA
|
||||||
|
│ ├── manifest(id / icons 192/512/maskable/apple-touch / shortcuts)
|
||||||
|
│ ├── Service Worker(vite-plugin-pwa autoUpdate)
|
||||||
|
│ ├── 离线缓存:API static(30 天 CacheFirst)+ uploads + images(SWR)+ fonts
|
||||||
|
│ ├── navigateFallback → /index.html(白名单 /api/、/uploads/)
|
||||||
|
│ ├── iOS / Android / Desktop 安装提示(beforeinstallprompt 拦截 + 引导)
|
||||||
|
│ ├── 新版本可用提示(needRefresh toast)
|
||||||
|
│ └── 离线就绪提示
|
||||||
|
│
|
||||||
|
├── 🔧 工具与脚本
|
||||||
|
│ ├── 备份:bin/backup.js(SQLite 拷贝 / MySQL mysqldump)
|
||||||
|
│ ├── 导出:bin/export.js(JSON / CSV,单表 / 全量)
|
||||||
|
│ ├── 灌种子:bin/seed-demo.js
|
||||||
|
│ ├── 重置:bin/reset-all.js(强 confirm_token)
|
||||||
|
│ ├── Grocy 拉取:bin/grocy-refresh-products.js
|
||||||
|
│ ├── 天气刷新:bin/weather.js
|
||||||
|
│ ├── 账号管理:bin/users.js(disable / enable / reset pwd)
|
||||||
|
│ ├── 验证:bin/verify.js
|
||||||
|
│ └── 迁移:bin/migrate.js(15 个 SQL 幂等执行)
|
||||||
|
│
|
||||||
|
├── 🩺 运维
|
||||||
|
│ ├── 健康检查:`/api/health`(兼容)+ `/api/health/live`(进程活)+ `/api/health/ready`(DB 通)
|
||||||
|
│ ├── 调试面板:DebugPanel(API 调用日志 + Vue error + unhandledrejection)
|
||||||
|
│ └── OpenAPI 文档:`/api/docs`(Swagger UI)+ `/api/openapi.json`
|
||||||
|
│
|
||||||
|
└── 🌐 部署
|
||||||
|
├── Express HTTP 服务(port 8787)
|
||||||
|
├── 静态资源托管(uploads/)
|
||||||
|
├── SPA fallback(client/dist/)
|
||||||
|
├── 宝塔面板部署文档(PM2 + Nginx + SSL)
|
||||||
|
└── Docker-ready(carlog-init.sql 幂等)
|
||||||
|
|
||||||
|
## 🛠 技术栈
|
||||||
|
|
||||||
|
- **后端**:Node.js 18+ / Express 4 / MySQL 8 (主) / SQLite (回退)
|
||||||
|
- **前端**:Vue 3 + Vite + Pinia + Vue Router + Naive UI
|
||||||
|
- **外部依赖**:Grocy(汽美库存,可选)/ wttr.in(天气)/ OpenAI 兼容 AI(可选)
|
||||||
|
|
||||||
|
## 📦 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
洗车管理系统/
|
||||||
|
├── client/ # Vue 3 前端
|
||||||
|
│ ├── dist/ # ← 构建产物(已包含在 zip 里,直接部署)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── api/ # API 客户端
|
||||||
|
│ │ ├── views/ # 页面(17 个)
|
||||||
|
│ │ ├── components/ # 组件
|
||||||
|
│ │ ├── stores/ # Pinia 状态
|
||||||
|
│ │ └── router.js
|
||||||
|
│ └── package.json
|
||||||
|
├── server/ # Express 后端
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── routes/ # 路由(auth/washes/chemicals/vehicles/...)
|
||||||
|
│ │ ├── services/ # 业务逻辑(grocyClient/weather/backup/...)
|
||||||
|
│ │ ├── middleware/ # auth/csrf
|
||||||
|
│ │ ├── db.js # MySQL/SQLite 统一接口
|
||||||
|
│ │ ├── config.js # 配置加载(DB_URL / Grocy / AI)
|
||||||
|
│ │ ├── setup.js # 首次初始化向导
|
||||||
|
│ │ └── bin/ # 命令行工具
|
||||||
|
│ │ ├── serve.js # 启动服务器
|
||||||
|
│ │ ├── migrate.js # 跑迁移
|
||||||
|
│ │ ├── reset-all.js # 清空 + 可选灌种子
|
||||||
|
│ │ ├── backup.js # 备份 SQLite/MySQL
|
||||||
|
│ │ ├── export.js # CSV/JSON 导出
|
||||||
|
│ │ └── ...
|
||||||
|
│ └── migrations/ # SQL 迁移
|
||||||
|
│ ├── 0001_init.sql
|
||||||
|
│ ├── mysql/ # MySQL 专属
|
||||||
|
│ └── ...
|
||||||
|
├── docs/
|
||||||
|
│ └── install/
|
||||||
|
│ └── INSTALL-BT-NODE.md
|
||||||
|
├── 洗车管理系统-v2.0-源码.zip # ← 部署包(492 KB,不含 node_modules/.env)
|
||||||
|
└── .env # 配置(DB / Grocy / AI / Session Secret,不在 zip 里)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 安装部署
|
||||||
|
|
||||||
|
### 1. 准备环境
|
||||||
|
|
||||||
|
- Node.js **≥ 18**
|
||||||
|
- MySQL 8.x(推荐;没有的话会自动回退 SQLite)
|
||||||
|
- 一个 Grocy 实例(可选,没有也能用,只是化学品模块受限)
|
||||||
|
|
||||||
|
### 2. 克隆并安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <your-repo-url> 洗车管理系统
|
||||||
|
cd 洗车管理系统
|
||||||
|
|
||||||
|
# 后端依赖
|
||||||
|
cd server && npm install
|
||||||
|
|
||||||
|
# 前端依赖
|
||||||
|
cd ../client && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置 `.env`
|
||||||
|
|
||||||
|
在项目根目录 `洗车管理系统/.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ====== 数据库(MySQL 优先,缺省回退 SQLite)======
|
||||||
|
DB_HOST=162.14.110.130
|
||||||
|
DB_PORT=33306
|
||||||
|
DB_USER=carlog
|
||||||
|
DB_PASSWORD=你的密码
|
||||||
|
DB_NAME=carlog
|
||||||
|
|
||||||
|
# 或使用完整 URL:
|
||||||
|
# DB_URL=mysql://carlog:你的密码@162.14.110.130:33306/carlog
|
||||||
|
|
||||||
|
# ====== 服务端 ======
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=8787
|
||||||
|
|
||||||
|
# Session 加密(生产必改!)
|
||||||
|
SESSION_SECRET=你的长随机字符串
|
||||||
|
|
||||||
|
# ====== Grocy(可选)======
|
||||||
|
GROCY_URL=https://your-grocy.example.com/
|
||||||
|
GROCY_USERNAME=admin
|
||||||
|
GROCY_PASSWORD=your-password
|
||||||
|
# 也可以用 API Key:
|
||||||
|
# GROCY_API_KEY=your-api-key
|
||||||
|
|
||||||
|
# ====== AI(可选)======
|
||||||
|
AI_PROVIDER_URL=https://api.openai.com/v1
|
||||||
|
AI_API_KEY=sk-xxx
|
||||||
|
AI_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
> 说明:所有设置项都能在 **Web UI 的「设置」页** 里再改。`.env` 只是初始默认值。
|
||||||
|
|
||||||
|
### 4. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
node src/bin/migrate.js
|
||||||
|
# 输出:✓ 0001_init.sql ... ✓ 0014_grocy_auth.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 构建前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../client
|
||||||
|
npm run build # 产物在 client/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ../server
|
||||||
|
node src/bin/serve.js
|
||||||
|
# [server] http://0.0.0.0:8787
|
||||||
|
# [db] MySQL connected (carlog)
|
||||||
|
```
|
||||||
|
|
||||||
|
首次访问 `http://localhost:8787/` 会跳到 `/setup` 引导你创建管理员账号。
|
||||||
|
|
||||||
|
### 7. (可选)灌种子数据
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
node src/bin/reset-all.js --seed # 清空 + 写 2 辆车 + ~50 条记录
|
||||||
|
node src/bin/seed-demo.js # 单独跑种子(不清空)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. 生产部署(PM2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g pm2
|
||||||
|
cd server
|
||||||
|
pm2 start src/bin/serve.js --name carwash
|
||||||
|
pm2 save
|
||||||
|
pm2 startup
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx 反向代理示例:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name carwash.your.domain;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8787;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐯 宝塔面板部署(推荐国内服务器)
|
||||||
|
|
||||||
|
宝塔自带 Node.js / MySQL / Nginx,不用自己折腾环境。
|
||||||
|
|
||||||
|
### 准备工作
|
||||||
|
|
||||||
|
宝塔软件商店装好:
|
||||||
|
|
||||||
|
- **Nginx 1.22+**
|
||||||
|
- **MySQL 8.0** (或 5.7,文档以 8.0 为准)
|
||||||
|
- **PM2 管理器**(宝塔商店搜不到就用 Node.js 版本管理器里的 PM2)
|
||||||
|
- **Node.js 18+**(用宝塔的 Node.js 版本管理器装)
|
||||||
|
|
||||||
|
### 1. 上传源码
|
||||||
|
|
||||||
|
把 `洗车管理系统-v2.0-源码.zip`(492 KB,已排除 node_modules 和 .env)传到宝塔,比如:
|
||||||
|
|
||||||
|
```
|
||||||
|
/www/wwwroot/carwash/
|
||||||
|
```
|
||||||
|
|
||||||
|
宝塔文件管理器 → 上传 zip → 右键解压。解压后结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
/www/wwwroot/carwash/
|
||||||
|
├── client/dist/ # 已构建好的前端
|
||||||
|
├── server/
|
||||||
|
├── docs/
|
||||||
|
├── package.json
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 建数据库
|
||||||
|
|
||||||
|
宝塔 **数据库** → 添加数据库:
|
||||||
|
|
||||||
|
- 数据库名:`carlog`
|
||||||
|
- 用户名:`carlog`
|
||||||
|
- 密码:点「随机生成」,**复制保存**(等下要写到 .env)
|
||||||
|
- 编码:`utf8mb4`
|
||||||
|
- 访问权限:本服务器
|
||||||
|
|
||||||
|
### 2.5 初始化数据库(任选一种方式)
|
||||||
|
|
||||||
|
**方式 A:一键 SQL(推荐,宝塔友好)**
|
||||||
|
|
||||||
|
直接把根目录的 `carlog-init.sql`(28 KB)导入:
|
||||||
|
|
||||||
|
- 宝塔:**数据库** → 选 `carlog` 库 → **导入** → 选 `carlog-init.sql` → 提交
|
||||||
|
- 或命令行:
|
||||||
|
```bash
|
||||||
|
mysql -ucarlog -p carlog < carlog-init.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
> 这个 SQL 已经做了**完全幂等**(存储过程 + try-catch),已存在的表/索引/列会自动跳过,**反复重跑不会破坏数据**。也无需再跑 `migrate.js`(migration 表会标记为已应用)。
|
||||||
|
|
||||||
|
**方式 B:用源码迁移**
|
||||||
|
|
||||||
|
如果偏好用源码版本(库已有数据想增量迁移):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash/server
|
||||||
|
node src/bin/migrate.js
|
||||||
|
```
|
||||||
|
|
||||||
|
在项目根目录(`/www/wwwroot/carwash/.env`)新建文件:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# ====== 数据库 ======
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_USER=carlog
|
||||||
|
DB_PASSWORD=刚才复制保存的密码
|
||||||
|
DB_NAME=carlog
|
||||||
|
|
||||||
|
# ====== 服务端 ======
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=8787
|
||||||
|
|
||||||
|
# Session 加密(务必改成 32 位以上随机字符串,宝塔「终端」跑 node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" 生成)
|
||||||
|
SESSION_SECRET=你的32位随机字符串
|
||||||
|
|
||||||
|
# ====== Grocy(可选)======
|
||||||
|
GROCY_URL=
|
||||||
|
GROCY_USERNAME=
|
||||||
|
GROCY_PASSWORD=
|
||||||
|
|
||||||
|
# ====== AI(可选)======
|
||||||
|
AI_PROVIDER_URL=https://api.openai.com/v1
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=gpt-4o-mini
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 装依赖
|
||||||
|
|
||||||
|
宝塔 → 终端(确保当前在项目根目录):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash/server
|
||||||
|
npm install --production
|
||||||
|
# 后端依赖装好(数据库已经在 2.5 步导入过了)
|
||||||
|
|
||||||
|
cd ../client
|
||||||
|
npm install
|
||||||
|
# 前端依赖装好(dist 已经预构建,不重新 build 也行;改前端代码时才需要 npm run build)
|
||||||
|
```
|
||||||
|
|
||||||
|
> 注意:宝塔终端默认用 root 用户,文件权限直接就是 755。如果遇到权限问题:`chown -R www:www /www/wwwroot/carwash`
|
||||||
|
|
||||||
|
### 5. 用 PM2 启动
|
||||||
|
|
||||||
|
宝塔 PM2 管理器 → 添加项目:
|
||||||
|
|
||||||
|
| 项 | 值 |
|
||||||
|
|---|---|
|
||||||
|
| 项目名称 | `carwash` |
|
||||||
|
| 运行目录 | `/www/wwwroot/carwash/server` |
|
||||||
|
| 启动文件 | `src/bin/serve.js` |
|
||||||
|
| 启动选项 | 留空 |
|
||||||
|
| 端口 | `8787` |
|
||||||
|
| Node 版本 | 18+(用版本管理器切换) |
|
||||||
|
|
||||||
|
点「提交」启动。日志会显示 `[server] http://0.0.0.0:8787` 就是成了。
|
||||||
|
|
||||||
|
### 6. 配 Nginx 反代
|
||||||
|
|
||||||
|
宝塔 **网站** → 添加站点 → 域名填你的(比如 `carwash.your.domain`)→ PHP 选「纯静态」
|
||||||
|
|
||||||
|
然后在站点设置里 **配置文件**,把 `location /` 段替换成:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8787;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
保存。
|
||||||
|
|
||||||
|
### 7. 首次访问
|
||||||
|
|
||||||
|
浏览器打开你的域名,**首次会自动跳到 `/setup`**,跟着引导:
|
||||||
|
|
||||||
|
1. 创建管理员账号(用户名 + 密码)
|
||||||
|
2. 选是否启用 Grocy(也可以先跳过,回设置里填)
|
||||||
|
3. 完成 → 登录
|
||||||
|
|
||||||
|
### 8. 申请 SSL(可选但强烈推荐)
|
||||||
|
|
||||||
|
宝塔站点 → SSL → Let's Encrypt → 选域名 → 申请 → 强制 HTTPS 打开。
|
||||||
|
|
||||||
|
申请完 `SESSION_SECRET` 不动,但 `.env` 里的 `session.cookie_secure` 改成 `true`(在 Web 设置页里改也行),重启 PM2。
|
||||||
|
|
||||||
|
### 9. 备份策略
|
||||||
|
|
||||||
|
宝塔 **计划任务** 加两条:
|
||||||
|
|
||||||
|
- **每日 03:00**:`node /www/wwwroot/carwash/server/src/bin/backup.js`
|
||||||
|
- **每周日 03:00**:`/www/server/mysql/bin/mysqldump -ucarlog -p你的密码 carlog | gzip > /www/backup/carlog_$(date +\%F).sql.gz`
|
||||||
|
|
||||||
|
### 10. 常见问题
|
||||||
|
|
||||||
|
| 现象 | 排查 |
|
||||||
|
|------|------|
|
||||||
|
| PM2 启动后立刻 exit | 看 PM2 日志;多半是 `.env` 拼写错误或数据库连不上 |
|
||||||
|
| 访问域名 502 | PM2 没起;Nginx 反代 IP 错了;先 `curl http://127.0.0.1:8787/api/health` 看后端通不通 |
|
||||||
|
| `/setup` 一直重定向 | `/www/wwwroot/carwash/.setup_done` 文件存在说明已经初始化过;删了重置 |
|
||||||
|
| Grocy 同步失败 | 在 Web 设置页测连接;Grocy 实例要能从服务器访问到 |
|
||||||
|
| 时区不对 | 服务器 `date` 看是 UTC 就 `timedatectl set-timezone Asia/Shanghai`;或保持 UTC,MySQL 内部也是 UTC,前端会按本地显示 |
|
||||||
|
|
||||||
|
### 升级步骤
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash
|
||||||
|
# 1. 备份
|
||||||
|
node server/src/bin/backup.js
|
||||||
|
|
||||||
|
# 2. 拉新代码(如果你用 git)或重新上传新 zip 解压覆盖
|
||||||
|
# 注意保留 .env 和 uploads/
|
||||||
|
|
||||||
|
# 3. 装新依赖
|
||||||
|
cd server && npm install --production
|
||||||
|
cd ../client && npm install && npm run build
|
||||||
|
|
||||||
|
# 4. 跑新迁移(自动跳过已跑过的)
|
||||||
|
cd ../server && node src/bin/migrate.js
|
||||||
|
|
||||||
|
# 5. 重启 PM2
|
||||||
|
pm2 restart carwash
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🧪 测试结果(2026-06-18)
|
||||||
|
|
||||||
|
每一项都用 `curl` 打了真请求,全过:
|
||||||
|
|
||||||
|
| 模块 | 测试 | 结果 |
|
||||||
|
|------|------|------|
|
||||||
|
| 登录 | `POST /api/auth/login` | ✅ 返回 `{ok:true, data:{user}}` |
|
||||||
|
| CSRF | `GET /api/auth/csrf` | ✅ |
|
||||||
|
| 车辆列表 | `GET /api/vehicles` | ✅ 返回数组(每条带 wash_count, total_cost) |
|
||||||
|
| 车辆统计 | `GET /api/vehicles/stats` | ✅ `{total:3, active:3}` |
|
||||||
|
| 车辆创建 | `POST /api/vehicles` | ✅ 返回 id |
|
||||||
|
| 车辆详情 | `GET /api/vehicles/:id` | ✅ |
|
||||||
|
| 车辆更新 | `PUT /api/vehicles/:id` | ✅ |
|
||||||
|
| 车辆软删 | `DELETE /api/vehicles/:id` | ✅ |
|
||||||
|
| 洗车列表 | `GET /api/washes` | ✅ `{rows,total,page,limit}` |
|
||||||
|
| 洗车创建 | `POST /api/washes` | ✅(字段 `wash_type` 非 `service_type`) |
|
||||||
|
| 洗车详情 | `GET /api/washes/:id` | ✅ 含天气快照 + 化学品列表 |
|
||||||
|
| 洗车删除 | `DELETE /api/washes/:id` | ✅ |
|
||||||
|
| 化学品列表 | `GET /api/chemicals` | ✅ 224 条 |
|
||||||
|
| 化学品详情 | `GET /api/chemicals/:id` | ✅ 含 Grocy stock entries |
|
||||||
|
| 化学品更新 | `PUT /api/chemicals/:id` | ✅ |
|
||||||
|
| 化学品创建 | `POST /api/chemicals` | ✅ 写入 Grocy + 本地 |
|
||||||
|
| 化学品扣减 | `POST /api/chemicals/:id/consume` | ✅ 调 Grocy API |
|
||||||
|
| **Grocy 同步** | `POST /api/chemicals/sync` | ✅ **224 个产品从 Grocy 拉取** |
|
||||||
|
| 加油 CRUD | `/api/refuels` | ✅ 全部 |
|
||||||
|
| 充电 CRUD | `/api/chargings` | ✅ 全部 |
|
||||||
|
| 保养 CRUD | `/api/maintenances` | ✅ items 数组 ↔ items_json 自动转换 |
|
||||||
|
| 保险 CRUD | `/api/insurances` | ✅(字段 `company` 非 `insurer`) |
|
||||||
|
| 保险附件 | `POST /api/insurances/:id/upload` | ✅ multer |
|
||||||
|
| 设置读 | `GET /api/settings` | ✅ 返回全部 24 项 |
|
||||||
|
| 设置写 | `POST /api/settings` | ✅ 按 group 通用 update |
|
||||||
|
| 城市读 | `GET /api/settings/city` | ✅ `{saved_city, default_city, is_auto_today}` |
|
||||||
|
| 天气 | `GET /api/settings/weather` | ✅ wttr.in 实时数据 |
|
||||||
|
| Grocy 同步日志 | `GET /api/settings/grocy-logs` | ✅ |
|
||||||
|
| **数据重置** | `POST /api/settings/reset` | ✅ 需要 `confirm_token=RESET-ALL-DATA` |
|
||||||
|
| 操作日志 | `GET /api/operation-logs` | ✅ 软删自动记录 |
|
||||||
|
| 恢复记录 | `POST /api/operation-logs/:id/recover` | ✅ |
|
||||||
|
| AI 配置 | `GET /api/ai/config` | ✅ |
|
||||||
|
| AI 识别 | `POST /api/ai/recognize` | ✅(需先 upload) |
|
||||||
|
| 仪表盘 | `GET /api/stats/overview` | ✅ |
|
||||||
|
| 健康检查 | `GET /api/health` | ✅ |
|
||||||
|
|
||||||
|
### 测试中发现并修复的 Bug
|
||||||
|
|
||||||
|
| # | 文件 | Bug | 修复 |
|
||||||
|
|---|------|-----|------|
|
||||||
|
| 1 | `server/src/services/grocyWrite.js` | `m.ensureCookie is not a function`:`ensureCookie` 未从 grocyClient 导出,却通过 `import().then(m => m.ensureCookie)` 取 | 改用导出函数 `grocyPost` |
|
||||||
|
| 2 | `server/src/routes/logs.js` | `Column 'items_json' cannot be null`:保养 POST 时前端传 `items: []`,但 INSERT 直接拿 `b.items_json`,没序列化 | POST/PUT 加 `Array.isArray(b.items) ? JSON.stringify(b.items) : ...` |
|
||||||
|
| 3 | `server/src/routes/logs.js` | `enrich()` 用 `JSON.parse(r.items_json)`,但 MySQL JSON 列已自动解析成对象 → 抛错 → 返回空数组 | 加 `Array.isArray(r.items_json)` 分支 |
|
||||||
|
| 4 | `server/src/routes/chemicals.js` | `You can't specify target table 'chemicals' for update in FROM clause`:sync 路由 UPDATE 用了 `OR grocy_product_id IN (SELECT FROM chemicals)` 自引用 | 简化为 `WHERE source = 'grocy'` |
|
||||||
|
|
||||||
|
## 📝 更新日志
|
||||||
|
|
||||||
|
### v2.8(当前版本 · 2026-06-20)
|
||||||
|
|
||||||
|
Trae 加了 8 个新功能(高 ROI 4 个 + 中 ROI 3 个 + 长期 1 个),并修了我找到的 4 个 bug:
|
||||||
|
|
||||||
|
**新功能**:
|
||||||
|
|
||||||
|
- **🚗 里程表录入 + 提醒中心**:
|
||||||
|
- 新增 `vehicles.current_km` 字段(手动校准,NULL 时按各日志表 MAX 算)
|
||||||
|
- `/api/reminders` 聚合提醒:加油 > 30 天、保养 > 180 天、洗车 > 14 天
|
||||||
|
- `/api/reminders/prefs` GET/PUT 阈值(按用户)
|
||||||
|
- **💰 成本分类占比**:`/api/stats/cost-breakdown` 返 5 分类(洗车/加油/充电/保养/保险)的总金额 + 百分比 + 颜色(用于饼图)
|
||||||
|
- **🔍 顶栏全局搜索**:`/api/search?q=...` 跨 7 个领域(车辆/洗车/加油/充电/保养/保险/化学品)搜,返带高亮匹配字段的分组结果
|
||||||
|
- **📊 同比/环比**:`/api/stats/compare` 返本月 vs 上月 / YTD vs 去年同月,5 个领域各给 `mom_pct` / `yoy_pct`
|
||||||
|
- **🏷️ 标签系统**:`tags` + `record_tags` 两张表,CRUD + toggle 挂载,5 种 record_type(wash/refuel/charge/maintenance/insurance),可按 tag_id 找所有打了该标签的记录
|
||||||
|
- **🔔 站内通知中心**:`notifications` 表 + 推送工具函数(`pushNotification`),OCR 完成 / 同步成功 / 备份完成等可持久化通知,GET/POST/标已读/全部标已读
|
||||||
|
- **🏆 成就系统**:14 个预置成就(洗车新手→狂魔 / 一周一洗 / 万里征程 / 十万俱乐部 / 保险达人等),自动算 progress、解锁时持久化到 `user_achievements`
|
||||||
|
|
||||||
|
**Bug 修复**(Trae 引入的):
|
||||||
|
|
||||||
|
- 🐛 **MySQL pool 连接超时(生产 P0)**:`server/src/db.js` 的 mysql2 pool **没开 `enableKeepAlive`**。MySQL 默认 `wait_timeout=28800s` 会关掉 idle 连接,客户端不主动 ping → 下次 query 报 `ETIMEDOUT` → login / dashboard / 任何接口卡 60s 直到 axios timeout → 页面"转圈卡,什么也点不动"。**两重修复**:1) mysql2 pool 开 `enableKeepAlive:true` + `keepAliveInitialDelay:30000`(每 30s 发 ping 保持连接活);2) `queryWithRetry()` 包装 — 一次性 `ETIMEDOUT` / `ECONNRESET` / `PROTOCOL_CONNECTION_LOST` 自动 retry 一次(pool 会建新连接)。
|
||||||
|
- 🐛 **4 个新路由 ok() helper 不包 `{ok,data}`**:`extra.js` / `achievements.js` / `tags.js` / `notifications.js` 全部用裸 `res.json(data)`,导致前端 axios interceptor 解包失败拿不到 `data`。统一改成 `res.json({ok:true, data})`。
|
||||||
|
- 🐛 **`/api/stats/compare` 时区错 8 小时**:跟 v2.5 同款 bug(`getMonth()` 等本地方法),但 dateCol 存的是 UTC 字符串。改成 `getUTCMonth()` + `Date.UTC()` 构造。
|
||||||
|
- 🐛 **`user_achievements.id` 没 SELECT 出来导致 UPDATE 失效**:achievements.js line 92 SELECT 没选 `id` 字段,后续 UPDATE 用 `existing.id` 是 undefined。加 `id` 到 SELECT 列表。
|
||||||
|
- 🐛 **`lastInsertRowid` 是 BigInt 没 Number 转换**:tags.js / notifications.js 返 `r.lastID || r.lastInsertRowid`,mysql2 是 BigInt,JSON 序列化会变 `1n`。改成 `Number(r.lastInsertRowid)`。
|
||||||
|
|
||||||
|
**数据库**:3 个新迁移(0016_vehicle_current_km 加 `vehicles.current_km` 列 + `notification_prefs` + `notifications` 表,0017_tags `tags` + `record_tags` 表,0018_achievements `achievements` + `user_achievements` 表 + 14 条预置数据)
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- 新增 `routes.extra.test.js`(7 个用例:reminders 包装 / 加油提醒 / cost-breakdown 5 分类 + 百分比 / compare 月环比同比)
|
||||||
|
- 新增 `routes.tags.test.js`(8 个用例:CRUD / toggle 双向 / 非法 record_type 400 / 重名 409 / 级联删除)
|
||||||
|
- 新增 `routes.notifications.test.js`(6 个用例:列表 + unread 计数 / 创建 / 标已读 / 全部已读)
|
||||||
|
- 总测试数 **76 → 97 全过**
|
||||||
|
|
||||||
|
### v2.7(2026-06-20)
|
||||||
|
|
||||||
|
**新功能**:
|
||||||
|
|
||||||
|
- **401 自动跳登录 + 表单草稿**:`client/src/api/client.js` axios interceptor catch 401 → 触发 `form-draft:flush-all` 事件把所有 useFormDraft 暂存到 sessionStorage → 跳 `/login?reason=expired&redirect=原页`;登录成功后回原页,草稿自动恢复。
|
||||||
|
- **AI OCR 失败兜底 modal**:`client/src/components/AiFallbackModal.vue` + `client/src/composables/useAiRecognize.js` 重构,识别失败时不再 alert 而是打开左图右表的 modal,用户对照图填表。
|
||||||
|
- **IP 限流**:`server/src/middleware/ipRateLimit.js` 内存版限流,`/api/ai/*`(recognize + test)每分钟 10 次,`/api/chemicals/sync` + `/grocy-search` + `/refresh-ids` 每分钟 10 次;429 + Retry-After + X-RateLimit-* headers。
|
||||||
|
- **CSRF 403 自动 refresh 重试**:客户端 interceptor 收到 403 CSRF → 调 `/api/auth/csrf` 拿新 token → 重发原请求 1 次(用 `_csrfRetried` flag 防双发)。
|
||||||
|
- **健康检查拆分**:`/api/health`(兼容旧)+ `/api/health/live`(进程活着)+ `/api/health/ready`(DB `SELECT 1` 通过),k8s/宝塔监控能区分"我在启动"和"我坏了"。
|
||||||
|
- **3 个真正有用的图表数据**:`GET /api/stats/extra` 返 `fuelTrend` / `costPerVehicle` / `washSeason`;Stats.vue 加油价趋势 + 每辆车年均养护 + 洗车季节频率 + 各车成本明细表。
|
||||||
|
- **OpenAPI 文档**:`/api/docs` Swagger UI + `/api/openapi.json` schema,29 条核心路由有 JSDoc 注释。
|
||||||
|
|
||||||
|
**Bug 修复**(Trae 引入的):
|
||||||
|
|
||||||
|
- 🐛 **WashNew.vue modal 永远显示**:line 14 `:show="ai.showFallback.value"` 模板里用 ref 的 `.value` → 传的是 Ref 对象本身(永远 truthy),改成 `:show="ai.showFallback"` 让 Vue 模板自动解包。其他 view(ChargingList/InsuranceList/RefuelList/WashShow/MaintenanceList)都正确用 `const aiBusy = ai.busy` 别名,没踩这个坑。
|
||||||
|
- 🐛 **Swagger 0 paths**:swagger.js 用相对路径 `'./src/routes/*.js'` 但 swaggerJsdoc 跑在进程 cwd,扫不到文件。改成 `path.resolve(__dirname, ...)` 绝对路径,0 → **29 routes**。
|
||||||
|
- 🐛 **`/api/stats/extra` 缺包装**:settings.js 的 `ok()` helper 不包 `{ok,data}`,前端 axios interceptor 解包失败,Stats.vue 拿到的 `extraR.data` 是 undefined → fallback 到空数组 → 3 张图没数据。改成显式 `res.json({ok:true, data:{...}})`。
|
||||||
|
- 🐛 **`/api/ai/test` 没加 rate limit**:Trae 只在 `/ai/recognize` 加了 aiRateLimit,但 `/ai/test` 没加,结果限流测试 12 次都通过。补上。
|
||||||
|
- 🐛 **swagger JSDoc 缺失 + JSDoc 块复制粘贴出错**:Trae 加 swagger 时只注释了 6 处,我给所有 CRUD 路由补齐(共 29 个 paths)。中间出了个 JSDoc 块重复贴导致的语法错误,已修。
|
||||||
|
|
||||||
|
**测试**:
|
||||||
|
|
||||||
|
- 新增 `middleware.ipRateLimit.test.js`(7 个用例:第一次/第二次 headers/max 边界/不同 IP/XFF 优先/窗口过期/_clearBuckets)
|
||||||
|
- 新增 `routes.stats.test.js`(4 个用例:包装/油价字段/车辆成本/季节聚合)
|
||||||
|
- 总测试数 **64 → 76 全过**
|
||||||
|
|
||||||
|
### v2.6(2026-06-19)
|
||||||
|
|
||||||
|
### v2.5(2026-06-19)
|
||||||
|
|
||||||
|
**Bug 修复**:
|
||||||
|
|
||||||
|
- **🐛 登录锁定时区错 8 小时**:`server/src/services/rateLimit.js` 的 `nowIso()` 和 `upsertLock()` 用 `getHours()` 等本地方法生成 DATETIME 字符串,但 `db.js` 配的是 `timezone: 'Z'`(UTC)。在 UTC+8 时区下,5 次输错密码后会被锁 **8 小时而不是 30 分钟**,「locked_until」会显示「明天早上 6:50」而不是「今晚 23:05」。已改成 `getUTCHours()` 等 UTC 方法。**这是严重 bug,所有部署在国内服务器的用户都受影响。**
|
||||||
|
- **🐛 月份列表跨时区少 1 个月**:`server/src/routes/settings.js:624` `new Date(year, month, 1)` 是本地午夜,`.toISOString()` 转 UTC 时可能跨月(尤其在月底)。改成 `Date.UTC()` 构造。
|
||||||
|
|
||||||
|
### v2.4(2026-06-19)
|
||||||
|
|
||||||
|
新增 + 修复:
|
||||||
|
|
||||||
|
- **AI 测试连接智能选图**:`POST /api/ai/test` 不再用源码里内嵌的 1×1 PNG(MiniMax 内容审查会判敏感),改成动态从 `uploads/ai/` 里挑最新的真实图片(>500B);没有就提示用户先上传。避免误报 422。
|
||||||
|
- **OCR 端到端 e2e 已跑通**:上传加油小票 → MiniMax M3 多模态识别 → JSON 填表 → 写入数据库。
|
||||||
|
|
||||||
|
### v2.3(2026-06-19)
|
||||||
|
|
||||||
|
- **登录失败锁定提示**:输错密码现在会显示「已错 N 次 / 还剩 N 次 / 锁定 X 分」;5 次错后锁定 30 分钟,会显示「锁定至时间, 还剩 N 分 N 秒」。后端 `BAD_CREDENTIALS` / `LOCKED` 都返详细字段。
|
||||||
|
- **车辆健康仪表盘**(`/api/vehicles/:id/health`):油耗、电耗、保养预测、洗车新鲜度、6 月趋势图(chart.js 堆叠柱)。
|
||||||
|
- **洗车前后对比照**(`/api/washes/:id/photos`):multer 上传 + 4 路由 + 3 tab UI(gallery / compare / upload)。
|
||||||
|
- **月度报表**(`/api/reports/monthly`):ExcelJS 6 sheet + PDFKit 2.3KB,覆盖车辆 / 洗车 / 加油 / 充电 / 保养 / 保险。
|
||||||
|
- **Migrations 0015_wash_photos**:新增 `wash_photos` 表 + before/after 字段。
|
||||||
|
|
||||||
|
### v2.2(2026-06-19)
|
||||||
|
|
||||||
|
- **MiniMax M3 多模态接入**:Settings → AI 截图识别加 provider 下拉(`openai_compat` / `minimax_vl`)。MiniMax M3 走 OpenAI 兼容协议 `/chat/completions`,域名 `api.minimaxi.com`;OCR 任务关 `thinking: {type:'disabled'}` 防 JSON 污染。
|
||||||
|
- **5 类 OCR schema**:洗车 / 加油 / 充电 / 保养 / 保险,从截图提取字段直接填表。
|
||||||
|
|
||||||
|
### v2.1(2026-06-17)
|
||||||
|
|
||||||
|
- 15 个 MySQL migrations + 完整幂等 `carlog-init.sql`(存储过程 + try-catch 包装 DDL / INDEX / ALTER)。
|
||||||
|
- 化学品 / 加油 / 充电 / 保养 / 保险 5 大模块 CRUD。
|
||||||
|
- 宝塔面板部署文档。
|
||||||
|
|
||||||
|
## 🧰 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库迁移
|
||||||
|
cd server && node src/bin/migrate.js
|
||||||
|
|
||||||
|
# 清空所有数据(不可恢复!要 confirm_token)
|
||||||
|
node src/bin/reset-all.js --confirm
|
||||||
|
|
||||||
|
# 灌种子数据
|
||||||
|
node src/bin/reset-all.js --seed
|
||||||
|
|
||||||
|
# 导出全部为 JSON
|
||||||
|
node src/bin/export.js --format json --output ./backup.json
|
||||||
|
|
||||||
|
# 导出单表为 CSV
|
||||||
|
node src/bin/export.js --format csv --table wash_records --output ./washes.csv
|
||||||
|
|
||||||
|
# 备份(SQLite 拷贝 / MySQL mysqldump)
|
||||||
|
node src/bin/backup.js
|
||||||
|
|
||||||
|
# Grocy 拉取(CLI)
|
||||||
|
node src/bin/grocy-refresh-products.js
|
||||||
|
|
||||||
|
# 验证账号可登录
|
||||||
|
node src/bin/verify.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔐 安全注意事项
|
||||||
|
|
||||||
|
- **生产环境必改 `SESSION_SECRET`**
|
||||||
|
- 如启用 HTTPS,在 `app.locals.config.session.cookie_secure` 设 `true`
|
||||||
|
- Grocy 密码用 session cookie 缓存 24h;也可用 API Key(推荐,永久不过期)
|
||||||
|
- 登录失败有 IP/账号双维度限流(默认 5 次/IP,10 次/账号)
|
||||||
|
- 软删除的记录保留在 DB,可通过 `/api/operation-logs` 恢复
|
||||||
|
|
||||||
|
## 🌐 Grocy 配置
|
||||||
|
|
||||||
|
支持两种鉴权方式:
|
||||||
|
|
||||||
|
| 方式 | 设置 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Session Cookie | `GROCY_URL` + `GROCY_USERNAME` + `GROCY_PASSWORD` | 走 `POST /login` 拿 cookie,缓存 24h |
|
||||||
|
| API Key | `GROCY_URL` + `GROCY_API_KEY` | `GROCY-API-KEY` header,永久 |
|
||||||
|
|
||||||
|
如果都不配:化学品列表能看但不能同步和扣减,其它模块全部正常工作。
|
||||||
|
|
||||||
|
## 📝 字段命名约定
|
||||||
|
|
||||||
|
- 车辆:`type`(car/suv/mpv/truck/other)、`powertrain`(ice/hev/bev)、`plate`、`color`
|
||||||
|
- 洗车:`wash_type`(quick/full/detail/other)、`wash_date`、`cost`、`location`、`duration_min`
|
||||||
|
- 加油:`refuel_date`、`liters`、`price_per_liter`、`total_cost`、`fuel_type`、`is_full`、`station`
|
||||||
|
- 充电:`charge_date`、`kwh`、`price_per_kwh`、`total_cost`、`charge_type`、`start_soc`、`end_soc`
|
||||||
|
- 保养:`maint_date`、`odometer_km`、`total_cost`、`shop`、`items[]`(动态项目)
|
||||||
|
- 保险:`insurance_type`(compulsory/commercial)、`company`、`policy_no`、`start_date`、`end_date`、`premium`
|
||||||
|
|
||||||
|
## 📱 移动端 & PWA
|
||||||
|
|
||||||
|
- **响应式布局**:4 档断点(`480 / 768 / 1024 / 1440`),手机 / iPad / 桌面三端自适应
|
||||||
|
- **导航**:手机端汉堡按钮 + 右滑抽屉导航(核心 / 能耗 / 其他三分组)
|
||||||
|
- **列表页**:桌面端表格 → 手机端自动切换卡片堆叠(`<MobileCardList>` 通用组件)
|
||||||
|
- **表单 / 弹窗**:移动端单列布局 + 底部弹出 Sheet + sticky 操作栏
|
||||||
|
- **iOS safe-area**:全面屏适配(`env(safe-area-inset-*)`)
|
||||||
|
- **PWA**:
|
||||||
|
- 安装 `vite-plugin-pwa`,workbox 自动生成 Service Worker
|
||||||
|
- `manifest.webmanifest` + 192/512/maskable/apple-touch 全套图标
|
||||||
|
- 离线访问已缓存页面,导航降级到 `/offline`
|
||||||
|
- 启动屏(`apple-touch-startup-image` 由浏览器自动截屏处理)
|
||||||
|
- 3 个快捷方式:新建洗车 / 加油 / 保养
|
||||||
|
- 4 种运行时 toast(`PwaToasts.vue`):新版本可用 / 离线就绪 / Android&桌面安装引导 / iOS Safari 添加到主屏幕
|
||||||
|
|
||||||
|
### 移动端使用方式
|
||||||
|
1. iOS Safari:底部分享按钮 ⬆ → 添加到主屏幕(首次进入会有 toast 提示,session 内只弹一次)
|
||||||
|
2. Android Chrome:底栏会出现"安装 CarLog"toast,点"安装"即可
|
||||||
|
3. 桌面 Chrome:地址栏右侧 ➕ 安装 CarLog
|
||||||
|
4. 新版本发布后下拉刷新(或访问站点)会触发"新版本可用"toast,点"刷新"即可更新
|
||||||
|
|
||||||
|
### Lighthouse 评分(最新一次运行,桌面端)
|
||||||
|
| 指标 | 分数 |
|
||||||
|
|---|---|
|
||||||
|
| Performance | 99 |
|
||||||
|
| Accessibility | 87 |
|
||||||
|
| Best Practices | 95 |
|
||||||
|
| SEO | 91 |
|
||||||
|
|
||||||
|
报告 HTML 在 `.lighthouseci/lhr-*.html`,重新跑分:`npm run lighthouse`
|
||||||
|
|
||||||
|
### PWA 检查
|
||||||
|
`npm run lighthouse:pwa` 自动验证 11 项 PWA installability(manifest / icons 192+512+maskable+apple-touch / SW 注册 / theme-color / meta 标签 / 离线 fallback)。**Lighthouse 12 已弃用 PWA 类别**,本项目用 puppeteer 自检代替,结果稳定可重复。
|
||||||
|
|
||||||
|
## 🔌 API 速查
|
||||||
|
|
||||||
|
> 完整路径以 `/api` 为前缀,所有写操作需 `requireAuth` 中间件。返回 `{ ok, data, error }` 三段式 JSON。
|
||||||
|
|
||||||
|
| 模块 | 方法 | 路径 | 说明 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 认证 | POST | `/auth/login` | 登录,返回 csrfToken + Set-Cookie |
|
||||||
|
| 认证 | POST | `/auth/logout` | 登出 |
|
||||||
|
| 认证 | GET | `/auth/me` | 当前用户 |
|
||||||
|
| 认证 | POST | `/auth/change-password` | 改密码(CSRF) |
|
||||||
|
| 车辆 | GET | `/vehicles` | 列表(支持 `?q=&page=&limit=`) |
|
||||||
|
| 车辆 | POST | `/vehicles` | 新建(CSRF) |
|
||||||
|
| 车辆 | PUT | `/vehicles/:id` | 更新(CSRF) |
|
||||||
|
| 车辆 | DELETE | `/vehicles/:id` | 软删(CSRF) |
|
||||||
|
| 车辆 | GET | `/vehicles/:id/stats` | 单车统计(总里程/能耗/花费) |
|
||||||
|
| 洗车 | GET/POST/PUT/DELETE | `/washes[/:id]` | 同上 |
|
||||||
|
| 洗车 | POST | `/washes/:id/photos` | 上传对比照(multipart) |
|
||||||
|
| 加油 | GET/POST/PUT/DELETE | `/refuels[/:id]` | 油耗自动计算 |
|
||||||
|
| 充电 | GET/POST/PUT/DELETE | `/chargings[/:id]` | 电耗自动计算 |
|
||||||
|
| 保养 | GET/POST/PUT/DELETE | `/maintenances[/:id]` | 动态 items[] |
|
||||||
|
| 保险 | GET/POST/PUT/DELETE | `/insurances[/:id]` | 附件上传 |
|
||||||
|
| 保险 | GET | `/insurances/expiring?days=30` | 即将到期 |
|
||||||
|
| 车品 | GET | `/chemicals` | 库存列表 |
|
||||||
|
| 车品 | GET/POST/PUT/DELETE | `/chemicals/:id` | 详情 / 编辑 |
|
||||||
|
| 车品 | GET | `/chemicals/categories` | Grocy 分类 |
|
||||||
|
| 车品 | POST | `/chemicals/sync` | 拉取 Grocy 库存 |
|
||||||
|
| 车品 | GET | `/chemicals/low-stock` | 低库存预警 |
|
||||||
|
| 车品 | POST | `/chemicals/batch-purchase` | 批量采购(事务) |
|
||||||
|
| 日志 | GET | `/operation-logs?page=&action=&user=` | 操作日志 |
|
||||||
|
| 日志 | POST | `/operation-logs/:id/recover` | 一键恢复软删数据 |
|
||||||
|
| AI | POST | `/ai/recognize` | 图片识别(multipart:field=file) |
|
||||||
|
| 统计 | GET | `/stats/summary` | 总览 KPI |
|
||||||
|
| 统计 | GET | `/stats/cost-by-type?from=&to=` | 按类型花费 |
|
||||||
|
| 统计 | GET | `/stats/odometer` | 里程折线图 |
|
||||||
|
| 统计 | GET | `/stats/efficiency?vehicle_id=` | 能耗 |
|
||||||
|
| 导出 | GET | `/export/insurance.csv` | CSV 导出 |
|
||||||
|
| 导出 | GET | `/export/insurance.pdf` | PDF 导出 |
|
||||||
|
| 同步 | GET | `/sync/snapshot` | 全量 JSON 快照 |
|
||||||
|
| 同步 | POST | `/sync/restore` | 还原(确认 token) |
|
||||||
|
| 系统 | GET | `/health` | 健康检查 |
|
||||||
|
|
||||||
|
## 💡 建议增加的功能(按 ROI 排序)
|
||||||
|
|
||||||
|
### 🟢 高 ROI(detailer 日常痛点,必加)
|
||||||
|
|
||||||
|
1. **🚗 车辆里程表(odometer)录入 + 自动同步**
|
||||||
|
现在加油 / 充电 / 保养 / 洗车都只能手动填里程或留空。加一个 `vehicles.current_km` 字段,每次加油时自动建议上次里程 + 这次的差值(典型 400-600km/周)。**直接解决油耗计算准确度问题**(现在是基于「相邻 is_full=1」算,没数据就 fallback 0)。
|
||||||
|
|
||||||
|
2. **⛽ 加油提醒 / 保养提醒推送**
|
||||||
|
现在保险有到期提醒(30 天),但加油 / 保养只能靠用户自己记得。加一个「提醒中心」页:
|
||||||
|
- 距上次加油 > 30 天
|
||||||
|
- 距上次保养 > 6 个月 / 5000km
|
||||||
|
- 距上次洗车 > 14 天(你喜欢保持的车身干净程度)
|
||||||
|
可以「标记已处理」或 snooze。**每天打开应用就能看到该干啥**。
|
||||||
|
|
||||||
|
3. **💰 成本分类分析**
|
||||||
|
按月 / 按年 / 按车显示:洗车 : 加油 : 充电 : 保养 : 保险 的成本占比饼图。**能看出钱花在哪**,决定要不要砍掉某些开销(比如发现保险比加油贵)。
|
||||||
|
|
||||||
|
4. **🔍 全局搜索(v2.0+ 已经在做但没暴露 UI)**
|
||||||
|
`/api/chemicals/grocy-search` 只搜化学品。做一个顶栏 search box,能跨所有领域搜(车牌 / 商家名 / 保养项目 / 保单号),点击跳详情。**找历史记录速度 × 10**。
|
||||||
|
|
||||||
|
### 🟡 中等 ROI(增强体验,做不做都行)
|
||||||
|
|
||||||
|
5. **📸 拍照快捷录入(手机 PWA)**
|
||||||
|
现在 OCR 要先点「AI 识别」按钮 → 选图 → 识别。手机原生 PWA 加「桌面快捷方式」直接打开「拍照录入」页面,扫一眼小票自动填表。**洗车店门口就能记**。
|
||||||
|
|
||||||
|
6. **🏷️ 标签系统**
|
||||||
|
给洗车 / 加油记录打标签:#自驾游 #通勤 #雨季 #精洗 #打蜡。一年后查「#打蜡 多少次」能判断打蜡频率。**纯前端 + 简单 SQL 即可**。
|
||||||
|
|
||||||
|
7. **📊 同比 / 环比对比**
|
||||||
|
Stats 页加:「本月 vs 上月」「今年 vs 去年同月」自动算增减百分比。**一眼看出趋势变化**。
|
||||||
|
|
||||||
|
8. **🔔 系统通知中心**
|
||||||
|
应用内通知(不是 push,是站内消息):OCR 完成 / 同步失败 / 备份成功 / 新版本可用。比 toast 更持久,用户能回看。
|
||||||
|
|
||||||
|
### 🟢 长期 / 玩法(看你个人兴趣)
|
||||||
|
|
||||||
|
9. **🏆 成就系统**(纯前端)
|
||||||
|
- 「连续 30 天洗车」「1 年累计 100 次洗车」「单次最贵的精洗」
|
||||||
|
- 解锁后给个 badge 分享到社交媒体(生成图片卡片)
|
||||||
|
- **detailer 圈子的「show off」属性**
|
||||||
|
|
||||||
|
10. **🛣️ 路线 / 行程记录**
|
||||||
|
加 `trips` 表:起点 / 终点 / 里程 / 油耗 / 备注。
|
||||||
|
- 加油时关联 trip_id,自动算 trip 油耗
|
||||||
|
- 月底看「本月去过哪些地方」
|
||||||
|
- **自驾游爱好者会很喜欢**
|
||||||
|
|
||||||
|
11. **📦 备份到 OSS / 七牛 / 阿里云盘(自动化)**
|
||||||
|
现在 `bin/backup.js` 只能本地。加 cron + 上传到对象存储:
|
||||||
|
- `bin/backup-upload.js` 调 S3 兼容 API
|
||||||
|
- 宝塔 cron `0 3 * * *` 每天凌晨跑
|
||||||
|
- 保留 30 天滚动
|
||||||
|
**你的数据安全网,必须做**
|
||||||
|
|
||||||
|
12. **🔍 OCR 文本预览 + 高亮**
|
||||||
|
现在 OCR 完直接填表,看不到识别到的原始文本。加一个「识别原始」tab:
|
||||||
|
- 显示 AI 返的 raw 文本
|
||||||
|
- 高亮置信度低的字段(让用户重点核对这些)
|
||||||
|
- **提升用户对 OCR 结果的信任**
|
||||||
|
|
||||||
|
13. **🌐 i18n 多语言**
|
||||||
|
现在 hardcode 中文。如果要跟车友分享 / 出国玩可能需要 EN。vue-i18n 一加就够。
|
||||||
|
|
||||||
|
### 🔴 别做(性价比低)
|
||||||
|
|
||||||
|
- **多用户 / 多租户**:你一个人玩,加这个复杂度 × 10,价值 = 0
|
||||||
|
- **TS 重构**:除非你强迫症,JSDoc 已经覆盖了
|
||||||
|
- **复杂 BI / 自定义仪表盘**:你 8 辆车 + 5 年数据,PostgreSQL + Superset 都嫌重
|
||||||
|
- **区块链溯源 / NFT 车辆档案**:开玩笑的 😂
|
||||||
|
|
||||||
|
### ❓ 想问你几个开放问题
|
||||||
|
|
||||||
|
1. **里程表录入**:你加油时会主动记里程吗?(很多人靠加油站小票 + 全程导航算)
|
||||||
|
2. **保养预测**:你 4S 店 / 修理厂会提前打电话提醒,还是你 app 自己管?
|
||||||
|
3. **跟车友分享**:你想不想把数据导出发给车友 / 二手车买家看?
|
||||||
|
4. **车机 / OBD 集成**:你车有 OBD 接口吗?要不要读实时数据?
|
||||||
|
|
||||||
|
回答这几个我能给你更精准的优先级建议。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 洗车管理系统 MySQL 一键初始化(v2.1)
|
||||||
|
-- 适用 MySQL 8.0+ / utf8mb4
|
||||||
|
--
|
||||||
|
-- 用法:
|
||||||
|
-- 1. 宝塔:选 carlog 库 →『导入』→ 选本文件
|
||||||
|
-- 2. 命令行:mysql -uroot -p carlog < carlog-init.sql
|
||||||
|
--
|
||||||
|
-- 完全幂等,反复重跑不会破坏数据
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS _add_index_if_missing;
|
||||||
|
CREATE PROCEDURE _add_index_if_missing(IN p_table VARCHAR(64), IN p_index VARCHAR(64), IN p_def TEXT, IN p_unique INT) BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = p_table AND index_name = p_index) THEN
|
||||||
|
IF p_unique = 1 THEN SET @s = CONCAT('CREATE UNIQUE INDEX ', p_index, ' ON ', p_table, ' ', p_def);
|
||||||
|
ELSE SET @s = CONCAT('CREATE INDEX ', p_index, ' ON ', p_table, ' ', p_def); END IF;
|
||||||
|
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS _try_sql;
|
||||||
|
CREATE PROCEDURE _try_sql(IN p_sql TEXT) BEGIN
|
||||||
|
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN END;
|
||||||
|
SET @s = p_sql;
|
||||||
|
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
|
||||||
|
END;
|
||||||
|
|
||||||
|
|
||||||
|
-- >>> 0001_init.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0001: 基础表 (MySQL 8.x)
|
||||||
|
-- =============================================================================
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. chemicals - 药剂字典(Grocy 缓存层)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS chemicals (
|
||||||
|
grocy_product_id VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(255) DEFAULT NULL,
|
||||||
|
unit VARCHAR(50) NOT NULL DEFAULT 'ml',
|
||||||
|
standard_dose DOUBLE DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
fetched_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (grocy_product_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('chemicals', 'idx_chemicals_category', '(category)', 0);
|
||||||
|
CALL _add_index_if_missing('chemicals', 'idx_chemicals_active', '(is_active)', 0);
|
||||||
|
CALL _add_index_if_missing('chemicals', 'idx_chemicals_fetched', '(fetched_at)', 0);
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. weather_snapshots - 天气快照
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS weather_snapshots (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
snapshot_date VARCHAR(10) NOT NULL,
|
||||||
|
city VARCHAR(100) NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
temp_c DOUBLE DEFAULT NULL,
|
||||||
|
humidity INT DEFAULT NULL,
|
||||||
|
weather_desc VARCHAR(255) DEFAULT NULL,
|
||||||
|
weather_code VARCHAR(20) DEFAULT NULL,
|
||||||
|
wind_kph DOUBLE DEFAULT NULL,
|
||||||
|
precip_mm DOUBLE DEFAULT NULL,
|
||||||
|
raw_json TEXT DEFAULT NULL,
|
||||||
|
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('weather_snapshots', 'uk_weather_city_date', '(city, snapshot_date)', 1);
|
||||||
|
CALL _add_index_if_missing('weather_snapshots', 'idx_weather_date', '(snapshot_date)', 0);
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. wash_records - 洗车记录
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wash_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wash_date VARCHAR(10) NOT NULL,
|
||||||
|
wash_type VARCHAR(20) NOT NULL,
|
||||||
|
weather_snapshot_id INT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
cost DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
duration_min INT DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_wash_type CHECK (wash_type IN ('quick','full','detail','other')),
|
||||||
|
CONSTRAINT fk_wash_weather FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('wash_records', 'idx_wash_records_date', '(wash_date)', 0);
|
||||||
|
CALL _add_index_if_missing('wash_records', 'idx_wash_records_type', '(wash_type)', 0);
|
||||||
|
CALL _add_index_if_missing('wash_records', 'idx_wash_records_weather', '(weather_snapshot_id)', 0);
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. chemical_usage - 药剂消耗
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS chemical_usage (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
usage_date VARCHAR(10) NOT NULL,
|
||||||
|
chemical_id VARCHAR(255) NOT NULL,
|
||||||
|
amount DOUBLE NOT NULL,
|
||||||
|
wash_record_id INT DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
sync_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
sync_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_sync_status CHECK (sync_status IN ('pending','synced','failed')),
|
||||||
|
CONSTRAINT fk_usage_chemical FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_usage_wash FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('chemical_usage', 'idx_usage_date', '(usage_date)', 0);
|
||||||
|
CALL _add_index_if_missing('chemical_usage', 'idx_usage_chemical', '(chemical_id)', 0);
|
||||||
|
CALL _add_index_if_missing('chemical_usage', 'idx_usage_wash', '(wash_record_id)', 0);
|
||||||
|
CALL _add_index_if_missing('chemical_usage', 'idx_usage_sync', '(sync_status)', 0);
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. settings - 运行时配置 KV
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
`key` VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT DEFAULT NULL,
|
||||||
|
is_secret TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('app_city', NULL, 0, '所在城市(用于天气查询)'),
|
||||||
|
('app_timezone', 'Asia/Shanghai', 0, '时区'),
|
||||||
|
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
|
||||||
|
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
|
||||||
|
('backup_keep_count', '10', 0, '本地备份保留份数'),
|
||||||
|
('backup_dir', 'storage/backups', 0, '备份输出目录');
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 6. views
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DROP VIEW IF EXISTS v_wash_monthly_count;
|
||||||
|
CALL _try_sql('CREATE VIEW v_wash_monthly_count AS SELECT SUBSTRING(wash_date, 1, 7) AS month, COUNT(*) AS wash_count, SUM(COALESCE(cost, 0)) AS total_cost FROM wash_records GROUP BY SUBSTRING(wash_date, 1, 7) ORDER BY month DESC');
|
||||||
|
DROP VIEW IF EXISTS v_chemical_monthly_usage;
|
||||||
|
CALL _try_sql('CREATE VIEW v_chemical_monthly_usage AS SELECT SUBSTRING(cu.usage_date, 1, 7) AS month, c.grocy_product_id AS grocy_product_id, c.name AS chemical_name, c.unit AS unit, SUM(cu.amount) AS total_amount, COUNT(*) AS usage_count FROM chemical_usage cu JOIN chemicals c ON c.grocy_product_id = cu.chemical_id GROUP BY SUBSTRING(cu.usage_date, 1, 7), c.grocy_product_id ORDER BY month DESC, total_amount DESC');
|
||||||
|
DROP VIEW IF EXISTS v_last_wash;
|
||||||
|
CALL _try_sql('CREATE VIEW v_last_wash AS SELECT id AS wash_id, wash_date, wash_type, DATEDIFF(NOW(), STR_TO_DATE(wash_date, ''%Y-%m-%d'')) AS days_since FROM wash_records ORDER BY wash_date DESC, id DESC LIMIT 1');
|
||||||
|
|
||||||
|
-- >>> 0002_auth.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0002_auth.sql - 用户认证 + 防撞库 (MySQL)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
last_login_at DATETIME DEFAULT NULL,
|
||||||
|
last_login_ip VARCHAR(45) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_role CHECK (role IN ('user','admin'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('users', 'idx_users_active', '(is_active)', 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
success TINYINT(1) NOT NULL,
|
||||||
|
user_agent VARCHAR(500) DEFAULT NULL,
|
||||||
|
failure_reason VARCHAR(100) DEFAULT NULL,
|
||||||
|
CONSTRAINT chk_success CHECK (success IN (0, 1))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('login_attempts', 'idx_attempts_ip_time', '(ip_address, attempted_at)', 0);
|
||||||
|
CALL _add_index_if_missing('login_attempts', 'idx_attempts_user_time', '(username, attempted_at)', 0);
|
||||||
|
CALL _add_index_if_missing('login_attempts', 'idx_attempts_time', '(attempted_at)', 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_locks (
|
||||||
|
lock_key VARCHAR(100) PRIMARY KEY,
|
||||||
|
lock_type VARCHAR(10) NOT NULL,
|
||||||
|
target VARCHAR(50) NOT NULL,
|
||||||
|
locked_until DATETIME NOT NULL,
|
||||||
|
reason VARCHAR(255) DEFAULT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_lock_type CHECK (lock_type IN ('ip','user'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('auth_locks', 'idx_locks_until', '(locked_until)', 0);
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
|
||||||
|
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
|
||||||
|
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
|
||||||
|
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
|
||||||
|
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
|
||||||
|
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
|
||||||
|
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
|
||||||
|
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
|
||||||
|
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
|
||||||
|
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
|
||||||
|
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
|
||||||
|
|
||||||
|
-- >>> 0003_vehicles.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0003_vehicles.sql - 车辆管理 (MySQL)
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
plate VARCHAR(20) DEFAULT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL DEFAULT 'car',
|
||||||
|
color VARCHAR(30) DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_vehicle_type CHECK (type IN ('car','suv','mpv','truck','other'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('vehicles', 'idx_vehicles_active', '(is_active)', 0);
|
||||||
|
CALL _add_index_if_missing('vehicles', 'idx_vehicles_sort', '(sort_order)', 0);
|
||||||
|
CALL _try_sql('ALTER TABLE wash_records ADD COLUMN vehicle_id INT DEFAULT NULL');
|
||||||
|
CALL _add_index_if_missing('wash_records', 'idx_wash_records_vehicle', '(vehicle_id)', 0);
|
||||||
|
DROP VIEW IF EXISTS v_last_wash;
|
||||||
|
CALL _try_sql('CREATE VIEW v_last_wash AS SELECT w.id AS wash_id, w.wash_date, w.wash_type, w.vehicle_id, v.name AS vehicle_name, DATEDIFF(NOW(), STR_TO_DATE(w.wash_date, ''%Y-%m-%d'')) AS days_since FROM wash_records w LEFT JOIN vehicles v ON v.id = w.vehicle_id ORDER BY w.wash_date DESC, w.id DESC LIMIT 1');
|
||||||
|
|
||||||
|
-- >>> 0004_grocy_full.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0004_grocy_full.sql - Grocy 主数据同步字段 (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE chemicals ADD COLUMN description TEXT DEFAULT NULL, ADD COLUMN current_amount DOUBLE NOT NULL DEFAULT 0, ADD COLUMN current_value DOUBLE NOT NULL DEFAULT 0, ADD COLUMN min_stock_amount DOUBLE NOT NULL DEFAULT 0, ADD COLUMN best_before_date VARCHAR(20) DEFAULT NULL, ADD COLUMN location VARCHAR(255) DEFAULT NULL, ADD COLUMN product_group_id INT DEFAULT NULL, ADD COLUMN qu_id INT DEFAULT NULL, ADD COLUMN location_id INT DEFAULT NULL, ADD COLUMN picture_file_name VARCHAR(255) DEFAULT NULL, ADD COLUMN last_synced_at DATETIME DEFAULT NULL');
|
||||||
|
CALL _add_index_if_missing('chemicals', 'idx_chem_amount', '(current_amount)', 0);
|
||||||
|
CALL _add_index_if_missing('chemicals', 'idx_chem_pg', '(product_group_id)', 0);
|
||||||
|
CALL _add_index_if_missing('chemicals', 'idx_chem_synced', '(last_synced_at)', 0);
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
|
||||||
|
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比)'),
|
||||||
|
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
|
||||||
|
|
||||||
|
-- >>> 0005_inventory_detail.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0005_inventory_detail.sql (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE chemicals ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT ''manual'', ADD COLUMN grocy_last_pulled_at DATETIME DEFAULT NULL');
|
||||||
|
CREATE TABLE IF NOT EXISTS category_mappings (
|
||||||
|
grocy_group_id INT PRIMARY KEY,
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
chemical_id VARCHAR(255) NOT NULL,
|
||||||
|
change_type VARCHAR(20) NOT NULL,
|
||||||
|
amount_delta DOUBLE NOT NULL,
|
||||||
|
amount_after DOUBLE DEFAULT NULL,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'local',
|
||||||
|
source_ref VARCHAR(255) DEFAULT NULL,
|
||||||
|
note TEXT DEFAULT NULL,
|
||||||
|
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_change_type CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('chemical_inventory_log', 'idx_invlog_chem', '(chemical_id, occurred_at DESC)', 0);
|
||||||
|
CALL _add_index_if_missing('chemical_inventory_log', 'idx_invlog_type', '(change_type)', 0);
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('grocy_categories_json', '[]', 0, 'Grocy 分类映射 JSON');
|
||||||
|
|
||||||
|
-- >>> 0006_unit_conversion.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0006_unit_conversion.sql (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE chemicals ADD COLUMN qu_factor DOUBLE NOT NULL DEFAULT 1.0, ADD COLUMN consume_unit_id INT DEFAULT NULL, ADD COLUMN consume_unit_name VARCHAR(100) DEFAULT NULL');
|
||||||
|
CALL _try_sql('ALTER TABLE chemical_usage ADD COLUMN unit VARCHAR(50) DEFAULT NULL, ADD COLUMN stock_amount DOUBLE DEFAULT NULL, ADD COLUMN consume_unit_id INT DEFAULT NULL');
|
||||||
|
|
||||||
|
-- >>> 0007_vehicle_logs.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0007_vehicle_logs.sql (MySQL)
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
maint_date VARCHAR(10) NOT NULL,
|
||||||
|
odometer_km INT DEFAULT NULL,
|
||||||
|
total_cost DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
shop VARCHAR(255) DEFAULT NULL,
|
||||||
|
items_json JSON NOT NULL DEFAULT ('[]'),
|
||||||
|
next_due_date VARCHAR(10) DEFAULT NULL,
|
||||||
|
next_due_km INT DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('maintenance_records', 'idx_maint_vehicle_date', '(vehicle_id, maint_date DESC)', 0);
|
||||||
|
CALL _add_index_if_missing('maintenance_records', 'idx_maint_date', '(maint_date DESC)', 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS refuel_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
refuel_date VARCHAR(10) NOT NULL,
|
||||||
|
odometer_km INT DEFAULT NULL,
|
||||||
|
liters DOUBLE NOT NULL,
|
||||||
|
price_per_liter DOUBLE DEFAULT NULL,
|
||||||
|
total_cost DOUBLE NOT NULL,
|
||||||
|
fuel_type VARCHAR(20) DEFAULT NULL,
|
||||||
|
is_full TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
station VARCHAR(255) DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('refuel_records', 'idx_refuel_vehicle_date', '(vehicle_id, refuel_date DESC)', 0);
|
||||||
|
CALL _add_index_if_missing('refuel_records', 'idx_refuel_date', '(refuel_date DESC)', 0);
|
||||||
|
CREATE TABLE IF NOT EXISTS charging_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
charge_date VARCHAR(10) NOT NULL,
|
||||||
|
odometer_km INT DEFAULT NULL,
|
||||||
|
kwh DOUBLE NOT NULL,
|
||||||
|
price_per_kwh DOUBLE DEFAULT NULL,
|
||||||
|
total_cost DOUBLE NOT NULL,
|
||||||
|
charge_type VARCHAR(20) DEFAULT NULL,
|
||||||
|
start_soc INT DEFAULT NULL,
|
||||||
|
end_soc INT DEFAULT NULL,
|
||||||
|
station VARCHAR(255) DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('charging_records', 'idx_charging_vehicle_date', '(vehicle_id, charge_date DESC)', 0);
|
||||||
|
CALL _add_index_if_missing('charging_records', 'idx_charging_date', '(charge_date DESC)', 0);
|
||||||
|
DROP VIEW IF EXISTS v_recent_logs;
|
||||||
|
CALL _try_sql('CREATE VIEW v_recent_logs AS SELECT ''maintenance'' AS log_type, id, vehicle_id, maint_date AS log_date, total_cost, odometer_km, shop AS location FROM maintenance_records UNION ALL SELECT ''refuel'' AS log_type, id, vehicle_id, refuel_date AS log_date, total_cost, odometer_km, station AS location FROM refuel_records UNION ALL SELECT ''charging'' AS log_type, id, vehicle_id, charge_date AS log_date, total_cost, odometer_km, station AS location FROM charging_records');
|
||||||
|
|
||||||
|
-- >>> 0008_mileage_and_insurance.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0008_mileage_and_insurance.sql (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE maintenance_records ADD COLUMN ev_km INT DEFAULT NULL, ADD COLUMN hev_km INT DEFAULT NULL');
|
||||||
|
CREATE TABLE IF NOT EXISTS insurance_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
vehicle_id INT NOT NULL,
|
||||||
|
insurance_type VARCHAR(50) NOT NULL,
|
||||||
|
company VARCHAR(100) DEFAULT NULL,
|
||||||
|
policy_no VARCHAR(100) DEFAULT NULL,
|
||||||
|
start_date VARCHAR(10) NOT NULL,
|
||||||
|
end_date VARCHAR(10) NOT NULL,
|
||||||
|
premium DOUBLE DEFAULT NULL,
|
||||||
|
coverage_amount DOUBLE DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
attachment_path VARCHAR(500) DEFAULT NULL,
|
||||||
|
attachment_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
attachment_mime VARCHAR(100) DEFAULT NULL,
|
||||||
|
attachment_size INT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('insurance_records', 'idx_insurance_vehicle', '(vehicle_id)', 0);
|
||||||
|
CALL _add_index_if_missing('insurance_records', 'idx_insurance_end_date', '(end_date)', 0);
|
||||||
|
|
||||||
|
-- >>> 0009_vehicle_powertrain.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0009_vehicle_powertrain.sql (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE vehicles ADD COLUMN powertrain VARCHAR(10) NOT NULL DEFAULT ''ice''');
|
||||||
|
|
||||||
|
-- >>> 0010_operation_logs.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0010_operation_logs.sql (MySQL)
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT DEFAULT NULL,
|
||||||
|
username VARCHAR(50) DEFAULT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
target_type VARCHAR(50) NOT NULL,
|
||||||
|
target_ids TEXT NOT NULL,
|
||||||
|
target_summary TEXT DEFAULT NULL,
|
||||||
|
detail_json TEXT DEFAULT NULL,
|
||||||
|
ip VARCHAR(45) DEFAULT NULL,
|
||||||
|
user_agent VARCHAR(500) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('operation_logs', 'idx_oplog_created', '(created_at DESC)', 0);
|
||||||
|
CALL _add_index_if_missing('operation_logs', 'idx_oplog_user_time', '(username, created_at DESC)', 0);
|
||||||
|
CALL _add_index_if_missing('operation_logs', 'idx_oplog_action', '(action, target_type, created_at DESC)', 0);
|
||||||
|
|
||||||
|
-- >>> 0011_soft_delete.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0011_soft_delete.sql (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE vehicles ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _try_sql('ALTER TABLE wash_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _try_sql('ALTER TABLE chemical_usage ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _try_sql('ALTER TABLE maintenance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _try_sql('ALTER TABLE refuel_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _try_sql('ALTER TABLE charging_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _try_sql('ALTER TABLE insurance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
CALL _add_index_if_missing('vehicles', 'ix_vehicles_is_deleted', '(is_deleted)', 0);
|
||||||
|
CALL _add_index_if_missing('wash_records', 'ix_wash_records_is_deleted', '(is_deleted)', 0);
|
||||||
|
CALL _add_index_if_missing('maintenance_records', 'ix_maintenance_is_deleted', '(is_deleted)', 0);
|
||||||
|
CALL _add_index_if_missing('refuel_records', 'ix_refuel_is_deleted', '(is_deleted)', 0);
|
||||||
|
CALL _add_index_if_missing('charging_records', 'ix_charging_is_deleted', '(is_deleted)', 0);
|
||||||
|
CALL _add_index_if_missing('insurance_records', 'ix_insurance_is_deleted', '(is_deleted)', 0);
|
||||||
|
|
||||||
|
-- >>> 0012_operation_logs_recovery.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0012_operation_logs_recovery.sql (MySQL)
|
||||||
|
CALL _try_sql('ALTER TABLE operation_logs ADD COLUMN recovered_at DATETIME DEFAULT NULL');
|
||||||
|
|
||||||
|
-- >>> 0013_weather_wttr.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0013_weather_wttr.sql (MySQL)
|
||||||
|
-- 删除旧 CHECK 并重建(MySQL 允许 ALTER TABLE 改 CHECK,但为保险用 ALTER COLUMN)
|
||||||
|
CALL _try_sql('ALTER TABLE weather_snapshots MODIFY COLUMN provider VARCHAR(50) NOT NULL');
|
||||||
|
|
||||||
|
-- >>> 0014_grocy_auth.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0014_grocy_auth.sql (MySQL)
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('grocy_username', '', 1, 'Grocy 用户名(session cookie 鉴权)'),
|
||||||
|
('grocy_password', '', 1, 'Grocy 密码(session cookie 鉴权)'),
|
||||||
|
('app_city_default', '', 0, '天气默认城市(永久生效)');
|
||||||
|
CREATE TABLE IF NOT EXISTS grocy_sync_logs (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL,
|
||||||
|
ok_count INT NOT NULL DEFAULT 0,
|
||||||
|
fail_count INT NOT NULL DEFAULT 0,
|
||||||
|
detail TEXT DEFAULT NULL,
|
||||||
|
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
finished_at DATETIME DEFAULT NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
CALL _add_index_if_missing('grocy_sync_logs', 'idx_grocy_sync_logs_action', '(action)', 0);
|
||||||
|
CALL _add_index_if_missing('grocy_sync_logs', 'idx_grocy_sync_logs_started', '(started_at DESC)', 0);
|
||||||
|
|
||||||
|
-- >>> 0015_wash_photos.sql
|
||||||
|
-- ==========================================================
|
||||||
|
-- 0015_wash_photos.sql (MySQL) — 洗车对比照(before / after / detail)
|
||||||
|
CREATE TABLE IF NOT EXISTS wash_photos (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wash_id INT NOT NULL,
|
||||||
|
photo_type VARCHAR(20) NOT NULL DEFAULT 'detail', -- before / after / detail / scene
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
file_size INT DEFAULT NULL,
|
||||||
|
width INT DEFAULT NULL,
|
||||||
|
height INT DEFAULT NULL,
|
||||||
|
caption VARCHAR(255) DEFAULT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_wash_photos_wash (wash_id, is_deleted),
|
||||||
|
INDEX idx_wash_photos_type (photo_type),
|
||||||
|
INDEX idx_wash_photos_created (created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
DROP PROCEDURE IF EXISTS _add_index_if_missing;
|
||||||
|
DROP PROCEDURE IF EXISTS _try_sql;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0001_init.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0002_auth.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0003_vehicles.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0004_grocy_full.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0005_inventory_detail.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0006_unit_conversion.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0007_vehicle_logs.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0008_mileage_and_insurance.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0009_vehicle_powertrain.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0010_operation_logs.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0011_soft_delete.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0012_operation_logs_recovery.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0013_weather_wttr.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0014_grocy_auth.sql');
|
||||||
|
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0015_wash_photos.sql');
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-Hans-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||||
|
<title>CarLog 车记 · 个人爱车管理</title>
|
||||||
|
<meta name="description" content="Vue 3 + Node.js 个人爱车记录系统">
|
||||||
|
<meta name="theme-color" content="#1B6EF3" media="(prefers-color-scheme: light)">
|
||||||
|
<meta name="theme-color" content="#0B4FB8" media="(prefers-color-scheme: dark)">
|
||||||
|
|
||||||
|
<!-- PWA -->
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/pwa-icon.svg">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png">
|
||||||
|
|
||||||
|
<!-- iOS PWA -->
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="CarLog">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="format-detection" content="telephone=no">
|
||||||
|
<meta name="msapplication-TileColor" content="#1B6EF3">
|
||||||
|
<meta name="msapplication-config" content="none">
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
|
||||||
|
<noscript><link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet"></noscript>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<noscript>
|
||||||
|
<div style="padding:40px;text-align:center;font-family:system-ui">
|
||||||
|
<h1>CarLog 需要启用 JavaScript</h1>
|
||||||
|
<p>请在浏览器中开启 JavaScript 后刷新页面。</p>
|
||||||
|
</div>
|
||||||
|
</noscript>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "carwash-client",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.7",
|
||||||
|
"chart.js": "^4.4.4",
|
||||||
|
"pinia": "^2.2.4",
|
||||||
|
"vue": "^3.5.12",
|
||||||
|
"vue-router": "^4.4.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.1.4",
|
||||||
|
"sharp": "^0.35.1",
|
||||||
|
"vite": "^5.4.10",
|
||||||
|
"vite-plugin-pwa": "^1.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 578 B |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1B6EF3"/>
|
||||||
|
<stop offset="100%" stop-color="#0B4FB8"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="96" fill="url(#g)"/>
|
||||||
|
<!-- car silhouette -->
|
||||||
|
<g fill="#ffffff" transform="translate(96 200)">
|
||||||
|
<path d="M0 90 C0 60 30 40 70 40 L130 30 C170 0 230 0 270 30 L330 40 C370 40 400 60 400 90 L400 140 L0 140 Z"/>
|
||||||
|
<circle cx="100" cy="150" r="34" fill="#1B6EF3" stroke="#fff" stroke-width="10"/>
|
||||||
|
<circle cx="300" cy="150" r="34" fill="#1B6EF3" stroke="#fff" stroke-width="10"/>
|
||||||
|
</g>
|
||||||
|
<!-- water drops -->
|
||||||
|
<g fill="#B8E0FF" opacity="0.85">
|
||||||
|
<circle cx="160" cy="100" r="10"/>
|
||||||
|
<circle cx="200" cy="80" r="6"/>
|
||||||
|
<circle cx="320" cy="100" r="8"/>
|
||||||
|
<circle cx="360" cy="80" r="5"/>
|
||||||
|
</g>
|
||||||
|
<text x="256" y="430" font-family="-apple-system,Segoe UI,Roboto,sans-serif" font-size="64" font-weight="800" text-anchor="middle" fill="#ffffff">CL</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* PWA 安装性验证脚本
|
||||||
|
* 检查:
|
||||||
|
* 1. manifest.webmanifest 合法
|
||||||
|
* 2. Service Worker 注册成功
|
||||||
|
* 3. 图标全部能加载
|
||||||
|
* 4. PWA 必需字段(name, icons[192/512], start_url, display, theme_color, background_color)
|
||||||
|
* 5. 离线 fallback(/offline 或 navigateFallback 命中)
|
||||||
|
*/
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
|
||||||
|
const URL_TO_TEST = process.env.PWA_URL || 'http://localhost:4173/login';
|
||||||
|
const CHROME_PATH =
|
||||||
|
process.env.CHROME_PATH ||
|
||||||
|
'/Users/yabozi/.cache/puppeteer/chrome/mac-148.0.7778.97/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
let pass = 0;
|
||||||
|
let fail = 0;
|
||||||
|
function ok(name, detail) {
|
||||||
|
checks.push({ status: '✅', name, detail });
|
||||||
|
pass++;
|
||||||
|
}
|
||||||
|
function ko(name, detail) {
|
||||||
|
checks.push({ status: '❌', name, detail });
|
||||||
|
fail++;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchStatus(url) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(url, { redirect: 'follow' });
|
||||||
|
return r.status;
|
||||||
|
} catch (e) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`🔍 PWA check: ${URL_TO_TEST}\n`);
|
||||||
|
|
||||||
|
// 1. manifest 文件可访问 + 合法 JSON
|
||||||
|
const manifestUrl = new URL('/manifest.webmanifest', URL_TO_TEST).toString();
|
||||||
|
const manifestStatus = await fetchStatus(manifestUrl);
|
||||||
|
if (manifestStatus === 200) {
|
||||||
|
ok('manifest 200', manifestUrl);
|
||||||
|
const r = await fetch(manifestUrl);
|
||||||
|
const m = await r.json();
|
||||||
|
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
|
||||||
|
const missing = required.filter((k) => !m[k]);
|
||||||
|
if (missing.length === 0) {
|
||||||
|
ok('manifest 必备字段', Object.keys(m).join(', '));
|
||||||
|
} else {
|
||||||
|
ko('manifest 必备字段缺失', missing.join(', '));
|
||||||
|
}
|
||||||
|
// 图标尺寸
|
||||||
|
const sizes = (m.icons || []).map((i) => i.sizes).filter(Boolean);
|
||||||
|
const has192 = sizes.some((s) => s.includes('192'));
|
||||||
|
const has512 = sizes.some((s) => s.includes('512'));
|
||||||
|
const hasMaskable = (m.icons || []).some((i) => i.purpose === 'maskable');
|
||||||
|
const hasApple = (m.icons || []).some((i) =>
|
||||||
|
(i.src || '').includes('apple-touch')
|
||||||
|
);
|
||||||
|
if (has192 && has512) ok('icons 192+512', sizes.join(', '));
|
||||||
|
else ko('icons 192/512 缺失', sizes.join(', '));
|
||||||
|
if (hasMaskable) ok('maskable icon', 'present');
|
||||||
|
else ko('maskable icon', 'missing');
|
||||||
|
if (hasApple) ok('apple-touch icon', 'present');
|
||||||
|
else ko('apple-touch icon', 'missing');
|
||||||
|
} else {
|
||||||
|
ko('manifest 不可访问', `status=${manifestStatus}, url=${manifestUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Service Worker 注册
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: CHROME_PATH,
|
||||||
|
headless: 'new',
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
`--user-data-dir=/tmp/lh-cache-pwa-check-${Date.now()}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
const swLogs = [];
|
||||||
|
page.on('console', (m) => {
|
||||||
|
const t = m.text();
|
||||||
|
if (t.includes('[PWA]') || t.includes('ServiceWorker')) swLogs.push(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(URL_TO_TEST, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||||
|
// 等几秒给 SW 机会注册
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
const swInfo = await page.evaluate(async () => {
|
||||||
|
if (!('serviceWorker' in navigator)) return { supported: false };
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations();
|
||||||
|
return {
|
||||||
|
supported: true,
|
||||||
|
count: regs.length,
|
||||||
|
scopes: regs.map((r) => r.scope),
|
||||||
|
active: regs.map((r) => !!r.active),
|
||||||
|
scripts: regs.map((r) => r.active && r.active.scriptURL),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (swInfo.supported && swInfo.count > 0 && swInfo.active.every(Boolean)) {
|
||||||
|
ok('Service Worker 已注册', `scope=${swInfo.scopes.join(',')}`);
|
||||||
|
} else if (swInfo.supported && swInfo.count > 0) {
|
||||||
|
ko('Service Worker 未激活', JSON.stringify(swInfo));
|
||||||
|
} else {
|
||||||
|
ko('Service Worker 未注册', 'getRegistrations() 为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 关键 meta 标签
|
||||||
|
const meta = await page.evaluate(() => {
|
||||||
|
const get = (sel) => document.querySelector(sel)?.getAttribute('content') || null;
|
||||||
|
return {
|
||||||
|
themeColor: get('meta[name="theme-color"]'),
|
||||||
|
appleCapable: get('meta[name="apple-mobile-web-app-capable"]'),
|
||||||
|
appleTitle: get('meta[name="apple-mobile-web-app-title"]'),
|
||||||
|
viewport: get('meta[name="viewport"]'),
|
||||||
|
manifestLink: !!document.querySelector('link[rel="manifest"]'),
|
||||||
|
appleTouchIcon: !!document.querySelector('link[rel="apple-touch-icon"]'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (meta.themeColor) ok('theme-color', meta.themeColor);
|
||||||
|
else ko('theme-color', 'missing');
|
||||||
|
if (meta.appleCapable === 'yes') ok('apple-mobile-web-app-capable', 'yes');
|
||||||
|
else ko('apple-mobile-web-app-capable', meta.appleCapable || 'missing');
|
||||||
|
if (meta.manifestLink) ok('manifest link', 'present');
|
||||||
|
else ko('manifest link', 'missing');
|
||||||
|
if (meta.appleTouchIcon) ok('apple-touch-icon link', 'present');
|
||||||
|
else ko('apple-touch-icon link', 'missing');
|
||||||
|
|
||||||
|
// 4. SW 日志确认 offline ready
|
||||||
|
const offlineReady = swLogs.some((l) => l.includes('离线缓存就绪') || l.includes('offline'));
|
||||||
|
if (offlineReady) ok('PWA offline log', '检测到 offline ready');
|
||||||
|
// 离线就绪不是强校验,标 warn 即可
|
||||||
|
if (!offlineReady) {
|
||||||
|
checks.push({
|
||||||
|
status: '⚠️',
|
||||||
|
name: 'PWA offline 日志',
|
||||||
|
detail: '运行后无离线 ready 日志(可能 SW 还没预缓存完,可忽略)',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// 输出
|
||||||
|
for (const c of checks) {
|
||||||
|
console.log(`${c.status} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
||||||
|
}
|
||||||
|
console.log(`\n总计: ✅ ${pass} ❌ ${fail}`);
|
||||||
|
process.exit(fail > 0 ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('PWA check 失败:', e);
|
||||||
|
process.exit(2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// 用 sharp 把 SVG 转为多尺寸 PNG
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const svg = await readFile(path.join(__dirname, '../public/pwa-icon.svg'));
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
// 标准 PWA icons
|
||||||
|
{ out: 'public/pwa/pwa-192x192.png', size: 192, type: 'normal' },
|
||||||
|
{ out: 'public/pwa/pwa-512x512.png', size: 512, type: 'normal' },
|
||||||
|
// maskable:四周留 20% 安全区,logo 居中缩到 60%
|
||||||
|
{ out: 'public/pwa/pwa-maskable-512x512.png', size: 512, type: 'maskable' },
|
||||||
|
// apple touch
|
||||||
|
{ out: 'public/pwa/apple-touch-icon.png', size: 180, type: 'normal' },
|
||||||
|
// favicon
|
||||||
|
{ out: 'public/favicon-32x32.png', size: 32, type: 'normal' },
|
||||||
|
{ out: 'public/favicon-16x16.png', size: 16, type: 'normal' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bgColor = { r: 27, g: 110, b: 243 }; // 渐变起始色 #1B6EF3
|
||||||
|
|
||||||
|
for (const { out, size, type } of targets) {
|
||||||
|
let buffer = await sharp(svg).resize(size, size).png().toBuffer();
|
||||||
|
|
||||||
|
if (type === 'maskable') {
|
||||||
|
// maskable 重新画:蓝底全填 + logo 60%
|
||||||
|
const inner = await sharp(svg).resize(Math.floor(size * 0.6), Math.floor(size * 0.6)).png().toBuffer();
|
||||||
|
buffer = await sharp({
|
||||||
|
create: { width: size, height: size, channels: 4, background: bgColor },
|
||||||
|
})
|
||||||
|
.composite([{ input: inner, gravity: 'center' }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
await writeFile(path.join(__dirname, '..', out), buffer);
|
||||||
|
console.log('✓', out, `${size}x${size}`, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done.');
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<template>
|
||||||
|
<router-view v-slot="{ Component, route }">
|
||||||
|
<component :is="Component" :key="route.fullPath" />
|
||||||
|
</router-view>
|
||||||
|
<PwaToasts />
|
||||||
|
<DebugPanel />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
import DebugPanel from './components/DebugPanel.vue';
|
||||||
|
import PwaToasts from './components/PwaToasts.vue';
|
||||||
|
const auth = useAuthStore();
|
||||||
|
onMounted(() => auth.refresh());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 极短的全局 fade,避免 out-in 模式下空窗期过长 */
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity 80ms ease; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// client/src/api/ai.js — AI 截图识别
|
||||||
|
import http from './client';
|
||||||
|
|
||||||
|
export const getConfig = () => http.get('/ai/config');
|
||||||
|
export const saveConfig = (data) => http.post('/ai/config', data);
|
||||||
|
export const test = (data) => http.post('/ai/test', data || {});
|
||||||
|
|
||||||
|
// 上传图片,返回 { image_id, url, name, size, mime }
|
||||||
|
export const uploadImage = (file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return http.post('/ai/upload', fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 识别图片
|
||||||
|
// type: 'wash' | 'refuel' | 'charge' | 'maint' | 'insurance'
|
||||||
|
export const recognize = (image_id, type) => http.post('/ai/recognize', { image_id, type });
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// client/src/api/auth.js
|
||||||
|
import client from './client';
|
||||||
|
export const login = (username, password) => client.post('/auth/login', { username, password });
|
||||||
|
export const logout = () => client.post('/auth/logout');
|
||||||
|
export const changeAccount = (data) => client.post('/auth/account', data);
|
||||||
|
export const me = () => client.get('/auth/me');
|
||||||
|
export const csrf = () => client.get('/auth/csrf');
|
||||||
|
export const health = () => client.get('/health');
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// client/src/api/chemicals.js
|
||||||
|
import client from './client';
|
||||||
|
export const list = (params) => client.get('/chemicals', { params });
|
||||||
|
export const all = () => client.get('/chemicals/list');
|
||||||
|
export const get = (id) => client.get(`/chemicals/${id}`);
|
||||||
|
export const update = (id, data) => client.put(`/chemicals/${id}`, data);
|
||||||
|
export const create = (data) => client.post('/chemicals', data);
|
||||||
|
export const sync = () => client.post('/chemicals/sync');
|
||||||
|
export const refreshIds = () => client.post('/chemicals/refresh-ids');
|
||||||
|
export const addStock = (id, data) => client.post(`/chemicals/${id}/add`, data);
|
||||||
|
export const consumeStock = (id, data) => client.post(`/chemicals/${id}/consume`, data);
|
||||||
|
export const grocySearch = (q) => client.get('/chemicals/grocy-search', { params: { q } });
|
||||||
|
export const getCategories = () => client.get('/chemicals/categories');
|
||||||
|
export const getCategoryMappings = () => client.get('/chemicals/category-mappings');
|
||||||
|
export const saveCategoryMappings = (mappings) => client.post('/chemicals/category-mappings', { mappings });
|
||||||
|
export const deleteCategoryMapping = (id) => client.delete(`/chemicals/category-mappings/${id}`);
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// client/src/api/client.js — axios 实例,自动带 cookie + CSRF
|
||||||
|
import axios from 'axios';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useDebugStore } from '../stores/debug';
|
||||||
|
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
withCredentials: true,
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 特殊端点:长 timeout(同步类操作)
|
||||||
|
const LONG_TIMEOUT_OVERRIDE = {
|
||||||
|
'/chemicals/sync': 90000, // Grocy 拉取最多 1.5 分钟
|
||||||
|
'/grocy/sync': 90000,
|
||||||
|
'/chemicals/grocy-search': 60000, // Grocy 全局搜索(要拉全量 products 列表)
|
||||||
|
'/chemicals/refresh-ids': 45000, // 后台轻量同步:只拉一次 /api/objects/products
|
||||||
|
};
|
||||||
|
const origRequest = client.interceptors.request;
|
||||||
|
client.interceptors.request.use((cfg) => {
|
||||||
|
const path = (cfg.url || '').replace(/^\/+/, '/');
|
||||||
|
if (LONG_TIMEOUT_OVERRIDE[path]) {
|
||||||
|
cfg.timeout = LONG_TIMEOUT_OVERRIDE[path];
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统一处理 server 的 {ok, data, error} 包装:
|
||||||
|
// 成功 → 把 data 字段提到顶层(业务代码直接拿到)
|
||||||
|
// 失败 → 抛错,err.response.data = server 的 error 对象
|
||||||
|
client.interceptors.response.use(
|
||||||
|
(r) => {
|
||||||
|
const body = r.data;
|
||||||
|
if (body && typeof body === 'object' && 'ok' in body) {
|
||||||
|
if (body.ok) {
|
||||||
|
r.data = body.data; // 剥掉包装,client 拿到业务 data
|
||||||
|
} else {
|
||||||
|
// ok=false:构造一个类 axios 错误抛出去
|
||||||
|
const err = new Error(body.error?.message || '请求失败');
|
||||||
|
err.response = { status: r.status, data: body.error || body };
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 调试模式:记录所有调用
|
||||||
|
try {
|
||||||
|
const debug = useDebugStore();
|
||||||
|
if (debug.enabled) {
|
||||||
|
debug.logCall({
|
||||||
|
method: r.config?.method?.toUpperCase(),
|
||||||
|
url: r.config?.url,
|
||||||
|
status: r.status,
|
||||||
|
body: summarize(r.data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return r;
|
||||||
|
},
|
||||||
|
async (err) => {
|
||||||
|
const status = err.response?.status;
|
||||||
|
const body = err.response?.data;
|
||||||
|
// 把 server 的 error 也归一化到 err.response.data
|
||||||
|
if (body && typeof body === 'object' && 'ok' in body && body.ok === false) {
|
||||||
|
err.response.data = body.error || body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调试模式:上报到 DebugPanel
|
||||||
|
try {
|
||||||
|
const debug = useDebugStore();
|
||||||
|
if (debug.enabled) {
|
||||||
|
const cfg = err.config || {};
|
||||||
|
// 预期错误白名单:启动时检查登录状态、未登录访问受保护资源、CSRF 缺失
|
||||||
|
const url = cfg.url || '';
|
||||||
|
const isExpected = (
|
||||||
|
(status === 401 && (url === '/auth/me' || url === '/auth/csrf')) ||
|
||||||
|
(status === 401 && location.pathname === '/login') ||
|
||||||
|
(status === 403 && url === '/auth/me')
|
||||||
|
);
|
||||||
|
if (!isExpected) {
|
||||||
|
debug.log({
|
||||||
|
kind: 'api',
|
||||||
|
title: `${(cfg.method || 'GET').toUpperCase()} ${cfg.url} → ${status || 'Network Error'}`,
|
||||||
|
detail: {
|
||||||
|
method: cfg.method?.toUpperCase(),
|
||||||
|
url: cfg.url,
|
||||||
|
baseURL: cfg.baseURL,
|
||||||
|
fullURL: (cfg.baseURL || '') + (cfg.url || ''),
|
||||||
|
status,
|
||||||
|
statusText: err.response?.statusText,
|
||||||
|
requestHeaders: cfg.headers,
|
||||||
|
requestBody: cfg.data ? safeParse(cfg.data) : null,
|
||||||
|
responseBody: err.response?.data,
|
||||||
|
message: err.message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
debug.logCall({
|
||||||
|
method: cfg.method?.toUpperCase(),
|
||||||
|
url: cfg.url,
|
||||||
|
status: status || 'ERR',
|
||||||
|
body: err.response?.data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
if (status === 401) {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
auth.clear();
|
||||||
|
if (location.pathname !== '/login') {
|
||||||
|
// 把当前页所有 form 草稿强制刷盘(不丢用户填的数据)
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(new CustomEvent('form-draft:flush-all'));
|
||||||
|
} catch {}
|
||||||
|
const returnTo = location.pathname + location.search;
|
||||||
|
location.href = '/login?redirect=' + encodeURIComponent(returnTo) + '&reason=expired';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CSRF token 失效:自动刷新 + 重试原请求。retry 标记挂在 config 上防死循环。
|
||||||
|
if (status === 403 && (body?.code === 'CSRF' || body?.error?.code === 'CSRF') && !err.config?.__csrfRetried) {
|
||||||
|
try {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
await auth.refreshCsrf();
|
||||||
|
const retryCfg = { ...err.config, __csrfRetried: true };
|
||||||
|
// 用新 token 重新发
|
||||||
|
if (auth.csrfToken && ['post', 'put', 'delete', 'patch'].includes(retryCfg.method)) {
|
||||||
|
retryCfg.headers = { ...(retryCfg.headers || {}), 'X-CSRF-Token': auth.csrfToken };
|
||||||
|
}
|
||||||
|
return client.request(retryCfg);
|
||||||
|
} catch (refreshErr) {
|
||||||
|
// refresh 失败就 fall through 到 reject
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(err);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
function safeParse(s) {
|
||||||
|
if (typeof s !== 'string') return s;
|
||||||
|
try { return JSON.parse(s); } catch { return s; }
|
||||||
|
}
|
||||||
|
function summarize(d) {
|
||||||
|
if (d == null) return d;
|
||||||
|
if (Array.isArray(d)) return `Array(${d.length})`;
|
||||||
|
if (typeof d === 'object') {
|
||||||
|
const keys = Object.keys(d);
|
||||||
|
return `Object{${keys.slice(0, 6).join(',')}${keys.length > 6 ? '…' : ''}}`;
|
||||||
|
}
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.interceptors.request.use((cfg) => {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
if (auth.csrfToken && ['post', 'put', 'delete', 'patch'].includes(cfg.method)) {
|
||||||
|
cfg.headers['X-CSRF-Token'] = auth.csrfToken;
|
||||||
|
}
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 统一解包:list API 可能直接返 array,也可能返 {key: [...]}
|
||||||
|
* 用法:const list = asArray(r.data, 'vehicles'); */
|
||||||
|
export function asArray(data, key) {
|
||||||
|
if (Array.isArray(data)) return data;
|
||||||
|
if (data && typeof data === 'object' && key && Array.isArray(data[key])) return data[key];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default client;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// client/src/api/insurance.js — 保险记录 CRUD + 附件上传
|
||||||
|
import http from './client';
|
||||||
|
|
||||||
|
export const list = (params) => http.get('/insurances', { params });
|
||||||
|
export const get = (id) => http.get(`/insurances/${id}`);
|
||||||
|
export const create = (data) => http.post('/insurances', data);
|
||||||
|
export const update = (id, data) => http.put(`/insurances/${id}`, data);
|
||||||
|
export const remove = (id) => http.delete(`/insurances/${id}`);
|
||||||
|
|
||||||
|
// 上传保单附件(图片或 PDF)
|
||||||
|
export const upload = (id, file) => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', file);
|
||||||
|
return http.post(`/insurances/${id}/upload`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteAttachment = (id) => http.delete(`/insurances/${id}/attachment`);
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// client/src/api/logs.js — 保养 / 加油 / 充电三个领域共用
|
||||||
|
import http from './client';
|
||||||
|
|
||||||
|
const RES = {
|
||||||
|
maintenances: '/maintenances',
|
||||||
|
refuels: '/refuels',
|
||||||
|
chargings: '/chargings',
|
||||||
|
};
|
||||||
|
|
||||||
|
function factory(base) {
|
||||||
|
return {
|
||||||
|
list: (params) => http.get(base, { params }),
|
||||||
|
get: (id) => http.get(`${base}/${id}`),
|
||||||
|
create: (data) => http.post(base, data),
|
||||||
|
update: (id, data) => http.put(`${base}/${id}`, data),
|
||||||
|
remove: (id) => http.delete(`${base}/${id}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const maintApi = factory(RES.maintenances);
|
||||||
|
export const refuelApi = factory(RES.refuels);
|
||||||
|
export const chargingApi = factory(RES.chargings);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// client/src/api/operationLogs.js
|
||||||
|
import client from './client';
|
||||||
|
export const list = (params) => client.get('/operation-logs', { params });
|
||||||
|
export const get = (id) => client.get(`/operation-logs/${id}`);
|
||||||
|
export const options = () => client.get('/operation-logs/options');
|
||||||
|
export const recover = (id) => client.post(`/operation-logs/${id}/recover`);
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// client/src/api/settings.js
|
||||||
|
import client from './client';
|
||||||
|
export const get = () => client.get('/settings');
|
||||||
|
export const update = (data) => client.post('/settings', data);
|
||||||
|
export const overview = () => client.get('/stats/overview');
|
||||||
|
export const dashboardExtra = () => client.get('/dashboard/extra');
|
||||||
|
export const getCity = () => client.get('/settings/city');
|
||||||
|
export const grocyLogs = (limit) => client.get('/settings/grocy-logs', { params: { limit } });
|
||||||
|
export const getWeather = () => client.get('/settings/weather');
|
||||||
|
export const resetAll = (confirm_token, seed = true) =>
|
||||||
|
client.post('/settings/reset', { confirm_token, seed });
|
||||||
|
|
||||||
|
// 月度报表
|
||||||
|
export const reportMonths = (limit = 12) => client.get('/reports/monthly/list', { params: { limit } });
|
||||||
|
export const reportExcelUrl = (month) => `/api/reports/monthly/excel?month=${encodeURIComponent(month)}`;
|
||||||
|
export const reportPdfUrl = (month) => `/api/reports/monthly/pdf?month=${encodeURIComponent(month)}`;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// client/src/api/vehicles.js
|
||||||
|
import client from './client';
|
||||||
|
export const list = (params) => client.get('/vehicles', { params });
|
||||||
|
export const get = (id) => client.get(`/vehicles/${id}`);
|
||||||
|
export const create = (data) => client.post('/vehicles', data);
|
||||||
|
export const update = (id, data) => client.put(`/vehicles/${id}`, data);
|
||||||
|
export const remove = (id) => client.delete(`/vehicles/${id}`);
|
||||||
|
export const stats = () => client.get('/vehicles/stats');
|
||||||
|
export const health = (id) => client.get(`/vehicles/${id}/health`);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// client/src/api/washes.js
|
||||||
|
import client from './client';
|
||||||
|
export const list = (params) => client.get('/washes', { params });
|
||||||
|
export const get = (id) => client.get(`/washes/${id}`);
|
||||||
|
export const create = (data) => client.post('/washes', data);
|
||||||
|
export const remove = (id) => client.delete(`/washes/${id}`);
|
||||||
|
export const batchDelete = (ids, challenge) => client.post('/washes/batch-delete', { ids, challenge });
|
||||||
|
export const types = () => client.get('/washes/types');
|
||||||
|
|
||||||
|
// 对比照
|
||||||
|
export const listPhotos = (id) => client.get(`/washes/${id}/photos`);
|
||||||
|
export const uploadPhoto = (id, formData) => client.post(`/washes/${id}/photos`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
|
});
|
||||||
|
export const deletePhoto = (id, photoId) => client.delete(`/washes/${id}/photos/${photoId}`);
|
||||||
|
export const comparePhotos = (id, type1 = 'before', type2 = 'after') =>
|
||||||
|
client.get(`/washes/${id}/photos/compare`, { params: { type1, type2 } });
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="show" class="ai-fallback-backdrop" @click.self="$emit('cancel')">
|
||||||
|
<div class="ai-fallback-modal">
|
||||||
|
<header class="hd">
|
||||||
|
<h2>AI 识别未成功,手动填一下</h2>
|
||||||
|
<p class="sub">AI 只是加速器,不是真理 — 看着图填完提交就行</p>
|
||||||
|
<button class="x" @click="$emit('cancel')" aria-label="关闭">×</button>
|
||||||
|
</header>
|
||||||
|
<div class="body">
|
||||||
|
<div class="img-pane">
|
||||||
|
<img v-if="imageUrl" :src="imageUrl" alt="待识别图片" />
|
||||||
|
<div v-else class="img-missing">图片不可预览</div>
|
||||||
|
<p class="hint">📌 对照右栏填表,遇到看不清的字段直接留空</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-pane">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<footer class="ft">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="$emit('cancel')">取消</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="$emit('confirm')">填好了,提交</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
show: { type: Boolean, default: false },
|
||||||
|
imageUrl: { type: String, default: '' },
|
||||||
|
});
|
||||||
|
defineEmits(['confirm', 'cancel']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-fallback-backdrop {
|
||||||
|
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 9999; padding: 16px;
|
||||||
|
}
|
||||||
|
.ai-fallback-modal {
|
||||||
|
background: #fff; border-radius: 14px; box-shadow: 0 24px 60px rgba(0,0,0,.25);
|
||||||
|
width: min(1100px, 100%); max-height: 90vh; display: flex; flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hd { padding: 18px 20px 12px; border-bottom: 1px solid var(--c-border); position: relative; }
|
||||||
|
.hd h2 { margin: 0 0 4px; font-size: 18px; }
|
||||||
|
.hd .sub { margin: 0; color: var(--c-mute); font-size: 13px; }
|
||||||
|
.hd .x { position: absolute; top: 12px; right: 12px; background: transparent; border: 0; font-size: 24px; cursor: pointer; color: var(--c-mute); }
|
||||||
|
.hd .x:hover { color: var(--c-fg); }
|
||||||
|
.body { display: grid; grid-template-columns: 1fr 1fr; gap: 0; flex: 1; min-height: 0; }
|
||||||
|
.img-pane { background: #f8fafc; padding: 16px; overflow: auto; border-right: 1px solid var(--c-border); display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.img-pane img { max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
||||||
|
.img-pane .img-missing { color: var(--c-mute); padding: 40px; text-align: center; }
|
||||||
|
.img-pane .hint { font-size: 12px; color: var(--c-mute); margin: 0; }
|
||||||
|
.form-pane { padding: 16px 20px; overflow: auto; }
|
||||||
|
.ft { padding: 12px 20px; border-top: 1px solid var(--c-border); display: flex; justify-content: flex-end; gap: 10px; }
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.body { grid-template-columns: 1fr; }
|
||||||
|
.img-pane { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--c-border); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<template>
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<div class="left">
|
||||||
|
<router-link to="/" class="brand" @click="close">
|
||||||
|
<span class="logo">CL</span>
|
||||||
|
<span class="brand-text">
|
||||||
|
<span class="brand-name">CarLog</span>
|
||||||
|
<span class="brand-sub">车记</span>
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
<!-- 桌面端:横排导航 -->
|
||||||
|
<nav class="nav-desktop">
|
||||||
|
<div class="nav-group">
|
||||||
|
<router-link to="/" class="nav-item" exact-active-class="active" @click="close">概览</router-link>
|
||||||
|
<router-link to="/washes" class="nav-item" active-class="active" @click="close">洗车记录</router-link>
|
||||||
|
<router-link to="/vehicles" class="nav-item" active-class="active" @click="close">车辆</router-link>
|
||||||
|
<router-link to="/maintenances" class="nav-item" active-class="active" @click="close">保养</router-link>
|
||||||
|
</div>
|
||||||
|
<span class="nav-sep" aria-hidden="true">│</span>
|
||||||
|
<div class="nav-group">
|
||||||
|
<router-link to="/refuels" class="nav-item" active-class="active" @click="close">加油</router-link>
|
||||||
|
<router-link to="/chargings" class="nav-item" active-class="active" @click="close">充电</router-link>
|
||||||
|
<router-link to="/insurances" class="nav-item" active-class="active" @click="close">保险</router-link>
|
||||||
|
<router-link to="/chemicals" class="nav-item" active-class="active" @click="close">车品</router-link>
|
||||||
|
</div>
|
||||||
|
<span class="nav-sep" aria-hidden="true">│</span>
|
||||||
|
<div class="nav-group">
|
||||||
|
<router-link to="/stats" class="nav-item" active-class="active" @click="close">统计</router-link>
|
||||||
|
<router-link to="/operation-logs" class="nav-item" active-class="active" @click="close">操作日志</router-link>
|
||||||
|
<router-link to="/settings" class="nav-item" active-class="active" @click="close">设置</router-link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="right">
|
||||||
|
<span v-if="auth.user" class="user">
|
||||||
|
<span class="avatar">{{ initial }}</span>
|
||||||
|
<span class="username">{{ auth.user.username }}</span>
|
||||||
|
</span>
|
||||||
|
<form @submit.prevent="onLogout" class="logout-form">
|
||||||
|
<button class="btn btn-ghost btn-sm desktop-only" type="submit">退出</button>
|
||||||
|
</form>
|
||||||
|
<!-- 移动端:汉堡按钮 -->
|
||||||
|
<button
|
||||||
|
class="hamburger mobile-only"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="open"
|
||||||
|
aria-label="打开菜单"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<span class="bar" :class="{ on: open }"></span>
|
||||||
|
<span class="bar" :class="{ on: open }"></span>
|
||||||
|
<span class="bar" :class="{ on: open }"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 移动端抽屉菜单 -->
|
||||||
|
<Transition name="drawer">
|
||||||
|
<div v-if="open" class="drawer" role="dialog" aria-label="导航菜单">
|
||||||
|
<div class="drawer-mask" @click="close"></div>
|
||||||
|
<nav class="drawer-panel">
|
||||||
|
<div class="drawer-head">
|
||||||
|
<div class="user">
|
||||||
|
<span class="avatar">{{ initial }}</span>
|
||||||
|
<div class="user-meta">
|
||||||
|
<div class="username">{{ auth.user?.username || '未登录' }}</div>
|
||||||
|
<div class="user-sub text-mute">CarLog 车记</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-section">
|
||||||
|
<div class="drawer-section-title">核心</div>
|
||||||
|
<router-link to="/" class="drawer-item" exact-active-class="active" @click="close">概览</router-link>
|
||||||
|
<router-link to="/washes" class="drawer-item" active-class="active" @click="close">洗车记录</router-link>
|
||||||
|
<router-link to="/vehicles" class="drawer-item" active-class="active" @click="close">车辆</router-link>
|
||||||
|
<router-link to="/maintenances" class="drawer-item" active-class="active" @click="close">保养</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-section">
|
||||||
|
<div class="drawer-section-title">能耗</div>
|
||||||
|
<router-link to="/refuels" class="drawer-item" active-class="active" @click="close">加油</router-link>
|
||||||
|
<router-link to="/chargings" class="drawer-item" active-class="active" @click="close">充电</router-link>
|
||||||
|
<router-link to="/insurances" class="drawer-item" active-class="active" @click="close">保险</router-link>
|
||||||
|
<router-link to="/chemicals" class="drawer-item" active-class="active" @click="close">车品</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-section">
|
||||||
|
<div class="drawer-section-title">其他</div>
|
||||||
|
<router-link to="/stats" class="drawer-item" active-class="active" @click="close">统计</router-link>
|
||||||
|
<router-link to="/operation-logs" class="drawer-item" active-class="active" @click="close">操作日志</router-link>
|
||||||
|
<router-link to="/settings" class="drawer-item" active-class="active" @click="close">设置</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-foot">
|
||||||
|
<form @submit.prevent="onLogout">
|
||||||
|
<button class="btn btn-ghost btn-block" type="submit">退出登录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const initial = computed(() => (auth.user?.username?.[0] || '?').toUpperCase());
|
||||||
|
|
||||||
|
const open = ref(false);
|
||||||
|
function close() { open.value = false; }
|
||||||
|
|
||||||
|
// 路由切换时自动关闭抽屉
|
||||||
|
watch(() => route.fullPath, () => { open.value = false; });
|
||||||
|
|
||||||
|
async function onLogout() {
|
||||||
|
await auth.logout();
|
||||||
|
router.push({ name: 'login' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.header {
|
||||||
|
position: sticky; top: 0; z-index: 50;
|
||||||
|
background: var(--card);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-top: var(--safe-top);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1240px; margin: 0 auto; padding: 0 24px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
.left { display: flex; align-items: center; gap: 32px; min-width: 0; flex: 1; }
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||||
|
.logo {
|
||||||
|
width: 32px; height: 32px; border-radius: 8px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 14px; font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-text { display: flex; align-items: baseline; gap: 6px; }
|
||||||
|
.brand-name { font-size: 16px; }
|
||||||
|
.brand-sub { font-size: 13px; color: var(--text-soft); font-weight: 500; }
|
||||||
|
|
||||||
|
/* === 桌面端导航 === */
|
||||||
|
.nav-desktop { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.nav-group { display: flex; align-items: center; gap: 2px; }
|
||||||
|
.nav-sep {
|
||||||
|
color: var(--line);
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
padding: 6px 12px; border-radius: var(--pill);
|
||||||
|
font-size: 14px; color: var(--text-soft);
|
||||||
|
transition: all .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nav-item:hover { color: var(--text); background: var(--bg-soft); }
|
||||||
|
.nav-item.active { color: #fff; background: var(--accent); }
|
||||||
|
|
||||||
|
.right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||||
|
.user { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
.avatar {
|
||||||
|
width: 28px; height: 28px; border-radius: 50%;
|
||||||
|
background: var(--brand); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 12px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.logout-form { margin: 0; }
|
||||||
|
|
||||||
|
/* === 移动端:显示汉堡按钮,隐藏桌面导航 === */
|
||||||
|
.mobile-only { display: none; }
|
||||||
|
.desktop-only { display: inline-flex; }
|
||||||
|
.hamburger {
|
||||||
|
background: transparent; border: 0;
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.hamburger:hover { background: var(--bg-soft); }
|
||||||
|
.bar {
|
||||||
|
display: block; width: 22px; height: 2px;
|
||||||
|
background: var(--text); border-radius: 2px;
|
||||||
|
transition: all .25s;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
.bar.on:nth-child(1) { transform: translateY(7px) rotate(45deg); }
|
||||||
|
.bar.on:nth-child(2) { opacity: 0; }
|
||||||
|
.bar.on:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||||
|
|
||||||
|
/* === 抽屉 === */
|
||||||
|
.drawer {
|
||||||
|
position: fixed; inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
.drawer-mask {
|
||||||
|
position: absolute; inset: 0;
|
||||||
|
background: rgba(15, 34, 51, 0.5);
|
||||||
|
}
|
||||||
|
.drawer-panel {
|
||||||
|
position: absolute; top: 0; right: 0; bottom: 0;
|
||||||
|
width: 280px; max-width: 80vw;
|
||||||
|
background: var(--card);
|
||||||
|
box-shadow: -4px 0 24px rgba(15, 34, 51, 0.15);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
padding-top: var(--safe-top);
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
}
|
||||||
|
.drawer-head {
|
||||||
|
padding: 20px 20px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.user-meta { display: flex; flex-direction: column; }
|
||||||
|
.username { font-weight: 600; font-size: 15px; }
|
||||||
|
.user-sub { font-size: 12px; margin-top: 2px; }
|
||||||
|
.drawer-section { padding: 12px 12px 0; }
|
||||||
|
.drawer-section-title {
|
||||||
|
font-size: 11px; color: var(--text-mute);
|
||||||
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
padding: 8px 8px 4px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.drawer-item {
|
||||||
|
display: block;
|
||||||
|
padding: 12px 12px; margin: 2px 0;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.drawer-item:active { background: var(--bg-soft); }
|
||||||
|
.drawer-item.active {
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
}
|
||||||
|
.drawer-foot {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.btn-block { width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
/* 抽屉动画 */
|
||||||
|
.drawer-enter-active, .drawer-leave-active {
|
||||||
|
transition: opacity .2s;
|
||||||
|
}
|
||||||
|
.drawer-enter-active .drawer-panel, .drawer-leave-active .drawer-panel {
|
||||||
|
transition: transform .25s ease;
|
||||||
|
}
|
||||||
|
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
|
||||||
|
.drawer-enter-from .drawer-panel, .drawer-leave-to .drawer-panel {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 响应式断点 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
/* 平板:保留桌面导航(很多 1024 平板横屏能装下),但缩小间距 */
|
||||||
|
.container { gap: 16px; }
|
||||||
|
.left { gap: 16px; }
|
||||||
|
.nav-sep { margin: 0 4px; }
|
||||||
|
.nav-item { padding: 6px 8px; font-size: 13px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* 移动端:切到汉堡菜单 */
|
||||||
|
.container { height: 56px; padding: 0 16px; }
|
||||||
|
.left { gap: 12px; }
|
||||||
|
.nav-desktop { display: none; }
|
||||||
|
.desktop-only { display: none; }
|
||||||
|
.mobile-only { display: flex; }
|
||||||
|
.user .username { display: none; } /* 顶栏不再显示用户名(抽屉里有) */
|
||||||
|
.brand-text { display: none; } /* 顶栏只保留 logo */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.container { padding: 0 12px; }
|
||||||
|
.hamburger { width: 36px; height: 36px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<AppHeader />
|
||||||
|
<main class="main">
|
||||||
|
<div class="container">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<footer class="footer">
|
||||||
|
<span>CarLog 车记 · 个人洗车管理 · Node.js + Vue 3</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import AppHeader from './AppHeader.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.layout { min-height: 100%; display: flex; flex-direction: column; }
|
||||||
|
.main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28px 0 60px;
|
||||||
|
padding-bottom: calc(60px + var(--safe-bottom));
|
||||||
|
padding-left: var(--safe-left);
|
||||||
|
padding-right: var(--safe-right);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1240px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
padding: 18px 24px;
|
||||||
|
padding-bottom: calc(18px + var(--safe-bottom));
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 12px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: var(--card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.main { padding: 24px 0 48px; }
|
||||||
|
.container { padding: 0 20px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.main { padding: 16px 0 40px; }
|
||||||
|
.container { padding: 0 16px; }
|
||||||
|
.footer { font-size: 11px; padding: 14px 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.main { padding: 12px 0 32px; }
|
||||||
|
.container { padding: 0 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<template>
|
||||||
|
<canvas ref="canvas"></canvas>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* ChartBlock.vue — 通用 Chart.js 包装
|
||||||
|
* - 只在 mounted 时按需 import chart.js/auto(避免首屏静态依赖)
|
||||||
|
* - props.type / props.data / props.options 直接传给 Chart 构造器
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
type: { type: String, required: true },
|
||||||
|
data: { type: Object, required: true },
|
||||||
|
options: { type: Object, default: () => ({}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvas = ref(null);
|
||||||
|
let chart = null;
|
||||||
|
let ChartMod = null;
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
async function ensureChart() {
|
||||||
|
if (!ChartMod) {
|
||||||
|
const mod = await import('chart.js/auto');
|
||||||
|
ChartMod = mod.default || mod.Chart || mod;
|
||||||
|
}
|
||||||
|
return ChartMod;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!mounted) return;
|
||||||
|
// 等 canvas 真的完成 DOM 插入
|
||||||
|
await nextTick();
|
||||||
|
if (!mounted || !canvas.value) return;
|
||||||
|
if (chart) {
|
||||||
|
// 已有实例:仅更新
|
||||||
|
try {
|
||||||
|
chart.data = props.data;
|
||||||
|
if (props.options) chart.options = props.options;
|
||||||
|
chart.update();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[ChartBlock] update 失败,重建', e);
|
||||||
|
safeDestroy();
|
||||||
|
await createChart();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await createChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createChart() {
|
||||||
|
try {
|
||||||
|
const Chart = await ensureChart();
|
||||||
|
if (!mounted || !canvas.value) return;
|
||||||
|
chart = new Chart(canvas.value, {
|
||||||
|
type: props.type,
|
||||||
|
data: props.data,
|
||||||
|
options: props.options,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ChartBlock] 构造 Chart 失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDestroy() {
|
||||||
|
if (!chart) return;
|
||||||
|
try {
|
||||||
|
chart.destroy();
|
||||||
|
} catch (e) {
|
||||||
|
/* 忽略 */
|
||||||
|
}
|
||||||
|
chart = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
mounted = false;
|
||||||
|
safeDestroy();
|
||||||
|
});
|
||||||
|
watch(
|
||||||
|
() => [props.data, props.options],
|
||||||
|
() => render(),
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chem-picker" ref="rootEl">
|
||||||
|
<!-- 搜索框(tag + input 同一行) -->
|
||||||
|
<div class="search-wrap">
|
||||||
|
<!-- 已选标签 -->
|
||||||
|
<span v-if="selected.length > 0" class="tag">
|
||||||
|
{{ selected[0].name }}
|
||||||
|
<button type="button" class="tag-x" @click.stop="clearSelected">×</button>
|
||||||
|
</span>
|
||||||
|
<!-- 输入框 -->
|
||||||
|
<input
|
||||||
|
ref="inputEl"
|
||||||
|
:value="query"
|
||||||
|
class="input chem-search"
|
||||||
|
:placeholder="selected.length > 0 ? '' : placeholder"
|
||||||
|
autocomplete="off"
|
||||||
|
@input="query = $event.target.value"
|
||||||
|
@focus="open = true"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
<!-- 下拉 -->
|
||||||
|
<div v-if="open && filtered.length > 0" class="dropdown">
|
||||||
|
<div
|
||||||
|
v-for="(ch, idx) in filtered"
|
||||||
|
:key="ch.grocy_product_id"
|
||||||
|
class="item"
|
||||||
|
:class="{ active: idx === activeIdx }"
|
||||||
|
@click="add(ch)"
|
||||||
|
@mouseover="activeIdx = idx"
|
||||||
|
>
|
||||||
|
<span class="item-name">{{ ch.name }}</span>
|
||||||
|
<span class="item-meta">
|
||||||
|
{{ ch.category || '—' }}
|
||||||
|
<span class="item-stock">库存 {{ ch.current_amount }} {{ ch.unit || '' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="open && query && filtered.length === 0" class="dropdown empty">
|
||||||
|
无匹配「{{ query }}」
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: String, default: '' }, // 当前选中 id
|
||||||
|
chemicals: { type: Array, default: () => [] },
|
||||||
|
placeholder: { type: String, default: '搜索化学品…' },
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue', 'change']);
|
||||||
|
|
||||||
|
// 当前已选标签(从 chemicals 解析)
|
||||||
|
const selected = computed(() =>
|
||||||
|
props.chemicals.filter(c => c.grocy_product_id === props.modelValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = ref('');
|
||||||
|
const open = ref(false);
|
||||||
|
const activeIdx = ref(0);
|
||||||
|
const rootEl = ref(null);
|
||||||
|
const inputEl = ref(null);
|
||||||
|
|
||||||
|
// 搜索框显示:选中项显示名字,无选中时显示用户输入
|
||||||
|
const inputDisplay = computed(() => {
|
||||||
|
if (selected.value.length > 0) return selected.value[0].name;
|
||||||
|
return query.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 模糊搜索:匹配 name 和 category
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = query.value.trim().toLowerCase();
|
||||||
|
if (!q) return props.chemicals.slice(0, 30); // 无关键词显示前30个
|
||||||
|
return props.chemicals
|
||||||
|
.filter(c =>
|
||||||
|
c.name.toLowerCase().includes(q) ||
|
||||||
|
(c.category || '').toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
.slice(0, 50); // 最多50条
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(query, () => { activeIdx.value = 0; });
|
||||||
|
watch(open, (v) => { if (v) nextTick(() => inputEl.value?.focus()); });
|
||||||
|
|
||||||
|
function add(ch) {
|
||||||
|
emit('update:modelValue', ch.grocy_product_id);
|
||||||
|
emit('change', ch);
|
||||||
|
query.value = '';
|
||||||
|
open.value = false;
|
||||||
|
nextTick(() => inputEl.value?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id) {
|
||||||
|
if (id === props.modelValue) {
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
emit('change', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSelected() {
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
emit('change', null);
|
||||||
|
query.value = '';
|
||||||
|
open.value = false;
|
||||||
|
nextTick(() => inputEl.value?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e) {
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIdx.value = Math.min(activeIdx.value + 1, filtered.value.length - 1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
activeIdx.value = Math.max(activeIdx.value - 1, 0);
|
||||||
|
} else if (e.key === 'Enter' && filtered.value.length > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
add(filtered.value[activeIdx.value]);
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
open.value = false;
|
||||||
|
} else if (e.key === 'Backspace' && !query.value && props.modelValue) {
|
||||||
|
emit('update:modelValue', '');
|
||||||
|
emit('change', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击外部关闭
|
||||||
|
function onDocClick(e) {
|
||||||
|
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onMounted(() => document.addEventListener('click', onDocClick));
|
||||||
|
onUnmounted(() => document.removeEventListener('click', onDocClick));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chem-picker { position: relative; }
|
||||||
|
.search-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
min-height: 36px; /* 固定高度 = 跟其他 input 一致 */
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 2px 8px;
|
||||||
|
}
|
||||||
|
.search-wrap:focus-within {
|
||||||
|
border-color: var(--brand);
|
||||||
|
box-shadow: 0 0 0 2px rgba(30, 91, 138, 0.15);
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
background: var(--brand); color: #fff;
|
||||||
|
padding: 2px 6px 2px 8px; border-radius: 12px;
|
||||||
|
font-size: 12px; white-space: nowrap; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.tag-x {
|
||||||
|
background: none; border: none; color: inherit;
|
||||||
|
cursor: pointer; padding: 0; line-height: 1;
|
||||||
|
font-size: 14px; opacity: 0.7;
|
||||||
|
}
|
||||||
|
.tag-x:hover { opacity: 1; }
|
||||||
|
.chem-search {
|
||||||
|
flex: 1; min-width: 80px;
|
||||||
|
border: none; outline: none; background: transparent;
|
||||||
|
font-size: 14px; color: var(--text);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.chem-search::placeholder { color: var(--text-soft); }
|
||||||
|
.dropdown {
|
||||||
|
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||||
|
background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0,0,0,.1);
|
||||||
|
z-index: 200; max-height: 320px; overflow-y: auto;
|
||||||
|
}
|
||||||
|
.dropdown.empty { padding: 8px 12px; color: var(--text-soft); font-size: 13px; }
|
||||||
|
.item {
|
||||||
|
padding: 8px 12px; cursor: pointer;
|
||||||
|
border-bottom: 1px solid var(--border); font-size: 13px;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
.item:last-child { border-bottom: none; }
|
||||||
|
.item:hover, .item.active { background: var(--bg-soft); }
|
||||||
|
.item-name { font-weight: 500; }
|
||||||
|
.item-meta { font-size: 12px; color: var(--text-soft); display: flex; gap: 8px; align-items: center; }
|
||||||
|
.item-stock { color: var(--brand); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="modelValue" class="modal-mask" @click.self="onCancel">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-title">
|
||||||
|
<span class="warn-icon">{{ titleIcon }}</span>
|
||||||
|
{{ title }}
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="modal-message">{{ message }}</p>
|
||||||
|
<slot />
|
||||||
|
|
||||||
|
<!-- 数学题模式 -->
|
||||||
|
<div v-if="mode === 'math'" class="challenge">
|
||||||
|
<div class="challenge-q">
|
||||||
|
请计算:
|
||||||
|
<code class="math">{{ challenge.a }} {{ challenge.op }} {{ challenge.b }} = ?</code>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="answer"
|
||||||
|
type="number"
|
||||||
|
class="input"
|
||||||
|
:placeholder="`输入结果`"
|
||||||
|
@keyup.enter="onConfirm"
|
||||||
|
ref="inputEl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 打字确认模式 -->
|
||||||
|
<div v-else-if="mode === 'type'" class="challenge">
|
||||||
|
<div class="challenge-q">
|
||||||
|
请输入 <code class="type-word">{{ confirmWord }}</code> 以确认
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="typed"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
:placeholder="`输入 ${confirmWord}`"
|
||||||
|
@keyup.enter="onConfirm"
|
||||||
|
ref="inputEl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 温馨提示 -->
|
||||||
|
<div v-if="tips.length" class="tips">
|
||||||
|
<div class="tips-label">💡 温馨提示</div>
|
||||||
|
<ul class="tips-list">
|
||||||
|
<li v-for="(tip, i) in tips" :key="i">{{ tip }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="modal-error">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-ghost" :disabled="busy" @click="onCancel">取消</button>
|
||||||
|
<button class="btn btn-danger" :disabled="!canSubmit || busy" @click="onConfirm">
|
||||||
|
{{ busy ? '处理中…' : confirmLabel }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch, nextTick } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
title: { type: String, default: '确认操作' },
|
||||||
|
message: { type: String, default: '' },
|
||||||
|
mode: { type: String, default: 'type' }, // 'math' | 'type'
|
||||||
|
confirmLabel: { type: String, default: '确认删除' },
|
||||||
|
confirmWord: { type: String, default: '删除' },
|
||||||
|
busy: { type: Boolean, default: false },
|
||||||
|
error: { type: String, default: '' },
|
||||||
|
tips: { type: Array, default: () => [] }, // 温馨提示列表
|
||||||
|
dangerType: { type: String, default: 'delete' }, // 'delete' | 'recover'
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
|
||||||
|
|
||||||
|
const challenge = ref(genChallenge());
|
||||||
|
const answer = ref(null);
|
||||||
|
const typed = ref('');
|
||||||
|
const inputEl = ref(null);
|
||||||
|
|
||||||
|
function genChallenge() {
|
||||||
|
const a = 1 + Math.floor(Math.random() * 9);
|
||||||
|
const b = 1 + Math.floor(Math.random() * 9);
|
||||||
|
const ops = ['+', '-', '*'];
|
||||||
|
const op = ops[Math.floor(Math.random() * ops.length)];
|
||||||
|
return { a, b, op };
|
||||||
|
}
|
||||||
|
|
||||||
|
const titleIcon = computed(() => {
|
||||||
|
if (props.dangerType === 'recover') return '🔄';
|
||||||
|
return '⚠️';
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
if (props.mode === 'math') {
|
||||||
|
if (answer.value == null || answer.value === '') return false;
|
||||||
|
const { a, b, op } = challenge.value;
|
||||||
|
const expected = op === '+' ? a + b : op === '-' ? a - b : a * b;
|
||||||
|
return Number(answer.value) === expected;
|
||||||
|
}
|
||||||
|
if (props.mode === 'type') {
|
||||||
|
return String(typed.value || '').trim() === props.confirmWord;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => {
|
||||||
|
if (v) {
|
||||||
|
challenge.value = genChallenge();
|
||||||
|
answer.value = null;
|
||||||
|
typed.value = '';
|
||||||
|
nextTick(() => inputEl.value?.focus());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onConfirm() {
|
||||||
|
if (!canSubmit.value || props.busy) return;
|
||||||
|
if (props.mode === 'math') {
|
||||||
|
emit('confirm', { a: challenge.value.a, b: challenge.value.b, op: challenge.value.op, answer: Number(answer.value) });
|
||||||
|
} else {
|
||||||
|
emit('confirm', typed.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
if (props.busy) return;
|
||||||
|
emit('cancel');
|
||||||
|
emit('update:modelValue', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed; inset: 0; z-index: 1000;
|
||||||
|
background: rgba(15, 34, 51, 0.42);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 100%; max-width: 480px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.modal-title {
|
||||||
|
padding: 18px 22px 6px;
|
||||||
|
font-size: 17px; font-weight: 600;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.warn-icon { font-size: 20px; }
|
||||||
|
.modal-body { padding: 0 22px 16px; }
|
||||||
|
.modal-message { margin: 0 0 12px; color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||||
|
.challenge {
|
||||||
|
background: var(--bg-soft);
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.challenge-q { font-size: 14px; margin-bottom: 10px; color: var(--text); }
|
||||||
|
.math, .type-word {
|
||||||
|
background: var(--card);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
.tips {
|
||||||
|
background: #EFF6FF;
|
||||||
|
border: 1px solid #BFDBFE;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.tips-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1D4ED8;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.tips-list {
|
||||||
|
margin: 0; padding-left: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #1E40AF;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.tips-list li { margin: 0; }
|
||||||
|
.modal-error {
|
||||||
|
margin: 0 22px 14px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
background: #FBE3DF;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
display: flex; justify-content: flex-end; gap: 10px;
|
||||||
|
padding: 12px 22px 18px;
|
||||||
|
background: var(--bg-soft);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
background: var(--danger); color: #fff;
|
||||||
|
}
|
||||||
|
.btn-danger:hover:not(:disabled) { background: #d63c2f; }
|
||||||
|
.btn-danger:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* === 移动端:底部弹出 sheet(全屏式) === */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.modal-mask {
|
||||||
|
align-items: flex-end;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: sheetUp .25s ease;
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
}
|
||||||
|
@keyframes sheetUp {
|
||||||
|
from { transform: translateY(100%); }
|
||||||
|
to { transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.modal-actions {
|
||||||
|
position: sticky; bottom: 0;
|
||||||
|
padding: 12px 16px calc(12px + var(--safe-bottom));
|
||||||
|
}
|
||||||
|
.modal-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px; /* iOS HIG 触控标准 */
|
||||||
|
}
|
||||||
|
.modal-title { padding: 16px 16px 4px; font-size: 16px; }
|
||||||
|
.modal-body { padding: 0 16px 14px; }
|
||||||
|
.modal-error { margin: 0 16px 12px; }
|
||||||
|
.challenge { padding: 12px 14px; }
|
||||||
|
.challenge .input { min-height: 44px; font-size: 16px; } /* iOS 16px 防缩放 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.tips-list { font-size: 11px; }
|
||||||
|
.modal-message { font-size: 13px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="store.enabled" class="debug-root" :class="{ collapsed: store.collapsed }">
|
||||||
|
<!-- 折叠态:小气泡 -->
|
||||||
|
<button v-if="store.collapsed" class="bubble" @click="store.togglePanel()">
|
||||||
|
🐞 <span v-if="store.count" class="dot"></span>
|
||||||
|
</button>
|
||||||
|
<!-- 展开态:完整面板 -->
|
||||||
|
<div v-else class="panel">
|
||||||
|
<header class="hdr">
|
||||||
|
<div class="tabs">
|
||||||
|
<button :class="['tab', { active: store.tab==='errors' }]" @click="store.setTab('errors')">
|
||||||
|
错误 <span v-if="store.count" class="badge err-badge">{{ store.count }}</span>
|
||||||
|
</button>
|
||||||
|
<button :class="['tab', { active: store.tab==='calls' }]" @click="store.setTab('calls')">
|
||||||
|
API <span class="badge">{{ store.callCount }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-mini" @click="store.clear()">清空</button>
|
||||||
|
<button class="btn-mini" @click="copyAll" :disabled="!store.count && !store.callCount">复制</button>
|
||||||
|
<button class="btn-mini" @click="store.togglePanel()">−</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<!-- 错误 tab -->
|
||||||
|
<div v-if="store.tab==='errors'" class="list" :class="{ 'empty-wrap': !store.count }">
|
||||||
|
<article v-for="e in [...store.errors].reverse()" :key="e.id" class="card-err">
|
||||||
|
<header class="row">
|
||||||
|
<span class="ts">{{ fmtTime(e.ts) }}</span>
|
||||||
|
<span class="kind" :class="e.kind">{{ kindLabel(e.kind) }}</span>
|
||||||
|
<span class="ttl">{{ e.title }}</span>
|
||||||
|
<button class="mini" @click="copyOne(e)">复制</button>
|
||||||
|
<button class="mini x" @click="remove(e.id)">×</button>
|
||||||
|
</header>
|
||||||
|
<pre class="detail">{{ fmtDetail(e.detail) }}</pre>
|
||||||
|
</article>
|
||||||
|
<div v-if="!store.count" class="empty">暂无错误 · 一切正常 ✨</div>
|
||||||
|
</div>
|
||||||
|
<!-- API tab -->
|
||||||
|
<div v-else class="list" :class="{ 'empty-wrap': !store.callCount }">
|
||||||
|
<article v-for="c in [...store.calls].reverse()" :key="c.id" class="card-err">
|
||||||
|
<header class="row">
|
||||||
|
<span class="ts">{{ fmtTime(c.ts) }}</span>
|
||||||
|
<span class="status" :class="statusClass(c.status)">{{ c.status }}</span>
|
||||||
|
<span class="method" :class="mClass(c.method)">{{ c.method }}</span>
|
||||||
|
<span class="url" :title="c.url">{{ c.url }}</span>
|
||||||
|
<button class="mini" @click="copyCall(c)">复制</button>
|
||||||
|
</header>
|
||||||
|
<pre v-if="c.body != null" class="detail">{{ fmtBody(c.body) }}</pre>
|
||||||
|
</article>
|
||||||
|
<div v-if="!store.callCount" class="empty">暂无 API 调用记录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useDebugStore } from '../stores/debug';
|
||||||
|
const store = useDebugStore();
|
||||||
|
|
||||||
|
function fmtTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
|
||||||
|
}
|
||||||
|
function kindLabel(k) {
|
||||||
|
return ({ api: 'API', vue: '组件', runtime: '运行', promise: 'Promise' }[k] || k);
|
||||||
|
}
|
||||||
|
function statusClass(s) {
|
||||||
|
if (s == null) return 's-net';
|
||||||
|
if (s >= 500) return 's-err';
|
||||||
|
if (s >= 400) return 's-warn';
|
||||||
|
if (s >= 200 && s < 300) return 's-ok';
|
||||||
|
return 's-info';
|
||||||
|
}
|
||||||
|
function mClass(m) { return 'm-' + (m || '').toLowerCase(); }
|
||||||
|
function fmtDetail(d) {
|
||||||
|
if (!d || typeof d !== 'object') return String(d);
|
||||||
|
return JSON.stringify(d, null, 2);
|
||||||
|
}
|
||||||
|
function fmtBody(b) {
|
||||||
|
if (typeof b === 'string') return b;
|
||||||
|
return JSON.stringify(b, null, 2);
|
||||||
|
}
|
||||||
|
function copyOne(e) {
|
||||||
|
const text = `[${e.ts}] [${e.kind}] ${e.title}\n${fmtDetail(e.detail)}`;
|
||||||
|
copyText(text);
|
||||||
|
}
|
||||||
|
function copyCall(c) {
|
||||||
|
const text = `[${c.ts}] ${c.method} ${c.url} → ${c.status}\n${c.body != null ? fmtBody(c.body) : ''}`;
|
||||||
|
copyText(text);
|
||||||
|
}
|
||||||
|
function copyAll() {
|
||||||
|
const parts = [];
|
||||||
|
if (store.errors.length) parts.push('## 错误\n' + store.errors.map(e =>
|
||||||
|
`[${e.ts}] [${e.kind}] ${e.title}\n${fmtDetail(e.detail)}`
|
||||||
|
).join('\n\n'));
|
||||||
|
if (store.calls.length) parts.push('## API\n' + store.calls.map(c =>
|
||||||
|
`[${c.ts}] ${c.method} ${c.url} → ${c.status}\n${c.body != null ? fmtBody(c.body) : ''}`
|
||||||
|
).join('\n'));
|
||||||
|
copyText(parts.join('\n\n────\n\n'));
|
||||||
|
}
|
||||||
|
async function copyText(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
flash('已复制到剪贴板 ✓');
|
||||||
|
} catch {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text; document.body.appendChild(ta); ta.select();
|
||||||
|
try { document.execCommand('copy'); flash('已复制(兼容模式)✓'); }
|
||||||
|
catch { flash('复制失败,请手动 Ctrl+C'); }
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function flash(msg) {
|
||||||
|
const t = document.createElement('div');
|
||||||
|
t.className = 'flash-toast';
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.classList.add('show'), 10);
|
||||||
|
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 1500);
|
||||||
|
}
|
||||||
|
function remove(id) {
|
||||||
|
const i = store.errors.findIndex(e => e.id === id);
|
||||||
|
if (i >= 0) store.errors.splice(i, 1);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.debug-root {
|
||||||
|
position: fixed; right: 16px; bottom: 16px; z-index: 9999;
|
||||||
|
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 620px;
|
||||||
|
}
|
||||||
|
.bubble {
|
||||||
|
background: #0F2233; color: #fff; border: 0; border-radius: 999px;
|
||||||
|
padding: 8px 14px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||||
|
cursor: pointer; position: relative;
|
||||||
|
}
|
||||||
|
.bubble:hover { background: #1A3A55; }
|
||||||
|
.dot {
|
||||||
|
position: absolute; top: 4px; right: 4px; width: 6px; height: 6px;
|
||||||
|
background: #D9695C; border-radius: 50%;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
background: #fff; border: 1px solid #E1ECF2; border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
max-height: 70vh; width: 620px;
|
||||||
|
}
|
||||||
|
.hdr {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
background: #0F2233; color: #fff; padding: 0 8px 0 0;
|
||||||
|
}
|
||||||
|
.tabs { display: flex; }
|
||||||
|
.tab {
|
||||||
|
background: transparent; color: #B0BEC5; border: 0;
|
||||||
|
padding: 10px 16px; font-size: 12px; cursor: pointer; font-weight: 500;
|
||||||
|
border-bottom: 2px solid transparent; transition: all .15s;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.tab:hover { color: #fff; }
|
||||||
|
.tab.active { color: #fff; border-bottom-color: #4DBA9A; }
|
||||||
|
.badge {
|
||||||
|
background: rgba(255,255,255,0.15); color: #fff; padding: 1px 7px;
|
||||||
|
border-radius: 10px; font-size: 10px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.err-badge { background: #D9695C; }
|
||||||
|
.actions { display: flex; gap: 6px; padding-right: 8px; }
|
||||||
|
.btn-mini {
|
||||||
|
background: rgba(255,255,255,0.1); color: #fff; border: 0;
|
||||||
|
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 11px;
|
||||||
|
}
|
||||||
|
.btn-mini:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
|
||||||
|
.btn-mini:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
.list { overflow-y: auto; flex: 1; padding: 8px; }
|
||||||
|
.list.empty-wrap { padding: 0; }
|
||||||
|
.card-err {
|
||||||
|
background: #FAFBFC; border: 1px solid #E1ECF2; border-radius: 8px;
|
||||||
|
margin-bottom: 6px; padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.ts { color: #8A9CAB; font-size: 11px; }
|
||||||
|
.kind {
|
||||||
|
font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.kind.api { background: #FBE3DF; color: #A33B30; }
|
||||||
|
.kind.vue { background: #FBEED9; color: #8B6510; }
|
||||||
|
.kind.runtime { background: #DEF4EC; color: #2E8A6B; }
|
||||||
|
.kind.promise { background: #E0F0FA; color: #1E5B8A; }
|
||||||
|
.ttl { font-weight: 500; color: #0F2233; flex: 1; min-width: 0; }
|
||||||
|
.status {
|
||||||
|
font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600;
|
||||||
|
font-family: 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
.s-ok { background: #DEF4EC; color: #2E8A6B; }
|
||||||
|
.s-warn { background: #FBEED9; color: #8B6510; }
|
||||||
|
.s-err { background: #FBE3DF; color: #A33B30; }
|
||||||
|
.s-net { background: #EEF2F5; color: #5A6F80; }
|
||||||
|
.s-info { background: #E0F0FA; color: #1E5B8A; }
|
||||||
|
.method {
|
||||||
|
font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600;
|
||||||
|
font-family: 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
.m-get { background: #E0F0FA; color: #1E5B8A; }
|
||||||
|
.m-post { background: #DEF4EC; color: #2E8A6B; }
|
||||||
|
.m-put { background: #FBEED9; color: #8B6510; }
|
||||||
|
.m-delete { background: #FBE3DF; color: #A33B30; }
|
||||||
|
.m-patch { background: #E0F0FA; color: #1E5B8A; }
|
||||||
|
.url {
|
||||||
|
font-size: 11px; color: #0F2233; flex: 1; min-width: 0;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
font-family: 'SF Mono', monospace;
|
||||||
|
}
|
||||||
|
.mini {
|
||||||
|
background: transparent; border: 1px solid #E1ECF2; color: #5A6F80;
|
||||||
|
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 11px;
|
||||||
|
}
|
||||||
|
.mini:hover { background: #E0F0FA; color: #1E5B8A; }
|
||||||
|
.mini.x:hover { background: #FBE3DF; color: #A33B30; }
|
||||||
|
.detail {
|
||||||
|
background: #F2F8FB; padding: 6px 8px; border-radius: 4px;
|
||||||
|
margin: 6px 0 0; font-size: 11px; line-height: 1.5;
|
||||||
|
white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto;
|
||||||
|
}
|
||||||
|
.empty { padding: 32px; text-align: center; color: #8A9CAB; font-family: 'Outfit', system-ui; }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.flash-toast {
|
||||||
|
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(20px);
|
||||||
|
background: #0F2233; color: #fff; padding: 8px 18px; border-radius: 8px;
|
||||||
|
font-size: 13px; opacity: 0; transition: all .3s; z-index: 10000;
|
||||||
|
}
|
||||||
|
.flash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 桌面端:表格视图 -->
|
||||||
|
<table v-if="!isMobile" class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-if="$slots.checkbox" class="check-col"></th>
|
||||||
|
<th v-for="col in columns" :key="col.key" :class="col.thClass">
|
||||||
|
{{ col.label }}
|
||||||
|
</th>
|
||||||
|
<th v-if="$slots.actions" class="actions-col"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(row, idx) in rows"
|
||||||
|
:key="rowKey ? row[rowKey] : idx"
|
||||||
|
:class="[rowClass, { selected: isSelected?.(row) }]"
|
||||||
|
@click="onRowClick(row, $event)"
|
||||||
|
>
|
||||||
|
<td v-if="$slots.checkbox" class="check-col" @click.stop>
|
||||||
|
<slot name="checkbox" :row="row" />
|
||||||
|
</td>
|
||||||
|
<td v-for="col in columns" :key="col.key" :class="col.tdClass" @click="onCellClick(row, col, $event)">
|
||||||
|
<slot :name="`cell-${col.key}`" :row="row" :col="col">
|
||||||
|
{{ col.formatter ? col.formatter(row[col.key], row) : row[col.key] }}
|
||||||
|
</slot>
|
||||||
|
</td>
|
||||||
|
<td v-if="$slots.actions" class="row-actions" @click.stop>
|
||||||
|
<slot name="actions" :row="row" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!rows.length">
|
||||||
|
<td :colspan="columns.length + (($slots.checkbox ? 1 : 0) + ($slots.actions ? 1 : 0))" class="text-mute" style="text-align:center; padding:32px">
|
||||||
|
<slot name="empty">{{ emptyText }}</slot>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 移动端:卡片视图 -->
|
||||||
|
<div v-else class="card-list">
|
||||||
|
<div
|
||||||
|
v-for="(row, idx) in rows"
|
||||||
|
:key="rowKey ? row[rowKey] : idx"
|
||||||
|
class="card-item"
|
||||||
|
:class="{ selected: isSelected?.(row), 'with-actions': $slots.actions }"
|
||||||
|
@click="onRowClick(row, $event)"
|
||||||
|
>
|
||||||
|
<div v-if="$slots.checkbox" class="card-check" @click.stop>
|
||||||
|
<slot name="checkbox" :row="row" />
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- 主标题行(按 columns 第一个作为主) -->
|
||||||
|
<div v-for="(col, ci) in columns" :key="col.key" class="card-row" :class="col.key === primaryKey ? 'primary' : 'secondary'">
|
||||||
|
<span v-if="ci === 0 || col.key === primaryKey || col.alwaysShow" class="card-label">{{ col.label }}</span>
|
||||||
|
<span class="card-value">
|
||||||
|
<slot :name="`cell-${col.key}`" :row="row" :col="col">
|
||||||
|
{{ col.formatter ? col.formatter(row[col.key], row) : row[col.key] }}
|
||||||
|
</slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="card-actions" @click.stop>
|
||||||
|
<slot name="actions" :row="row" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!rows.length" class="card-empty text-mute">
|
||||||
|
<slot name="empty">{{ emptyText }}</slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
/**
|
||||||
|
* MobileCardList — 桌面端表格 / 移动端卡片 自动切换
|
||||||
|
*
|
||||||
|
* props:
|
||||||
|
* columns: Array<{ key, label, thClass?, tdClass?, formatter?, alwaysShow? }>
|
||||||
|
* - key: 字段名
|
||||||
|
* - label: 列头 / 卡片小标签
|
||||||
|
* - formatter: (value, row) => string
|
||||||
|
* - alwaysShow: 卡片里也显示(默认 primary 显示,其余 hidden 防信息过载)
|
||||||
|
* rows: Array
|
||||||
|
* rowKey: 主键字段(默认用 index)
|
||||||
|
* rowClass: 行 class
|
||||||
|
* emptyText: 空状态文字
|
||||||
|
* primaryKey: 卡片主行(默认第一列)
|
||||||
|
* isSelected: (row) => boolean
|
||||||
|
*
|
||||||
|
* slots:
|
||||||
|
* cell-{key}: 自定义单元格渲染
|
||||||
|
* checkbox: 行内复选框(行首)
|
||||||
|
* actions: 行内操作(行末)
|
||||||
|
* empty: 空状态
|
||||||
|
*/
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
columns: { type: Array, required: true },
|
||||||
|
rows: { type: Array, required: true },
|
||||||
|
rowKey: { type: String, default: '' },
|
||||||
|
rowClass: { type: String, default: '' },
|
||||||
|
emptyText: { type: String, default: '暂无数据' },
|
||||||
|
primaryKey: { type: String, default: '' },
|
||||||
|
isSelected: { type: Function, default: null },
|
||||||
|
clickable: { type: Boolean, default: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['row-click', 'cell-click']);
|
||||||
|
|
||||||
|
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440);
|
||||||
|
const isMobile = computed(() => windowWidth.value < 768);
|
||||||
|
|
||||||
|
function onResize() {
|
||||||
|
windowWidth.value = window.innerWidth;
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', onResize, { passive: true });
|
||||||
|
});
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', onResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onRowClick(row, e) {
|
||||||
|
if (!props.clickable) return;
|
||||||
|
// 避免在 checkbox / actions 区域触发
|
||||||
|
if (e.target.closest('.check-col, .row-actions, .card-check, .card-actions, a, button')) return;
|
||||||
|
emit('row-click', row);
|
||||||
|
}
|
||||||
|
function onCellClick(row, col, e) {
|
||||||
|
emit('cell-click', { row, col, event: e });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
table.data { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
table.data th, table.data td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
table.data th { font-weight: 500; color: var(--text-soft); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
table.data tr:hover td { background: var(--bg-soft); }
|
||||||
|
table.data tr:last-child td { border-bottom: 0; }
|
||||||
|
.check-col { width: 36px; padding-left: 16px; padding-right: 0; }
|
||||||
|
.row-actions { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* === 移动端:卡片 === */
|
||||||
|
.card-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card-item {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
transition: box-shadow .15s, transform .1s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.card-item:active { transform: scale(0.99); }
|
||||||
|
.card-item.selected {
|
||||||
|
background: linear-gradient(0deg, var(--bg-soft), var(--bg-soft)), var(--card);
|
||||||
|
box-shadow: 0 0 0 2px var(--brand-soft), var(--card-shadow);
|
||||||
|
}
|
||||||
|
.card-check {
|
||||||
|
padding-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.card-row.primary {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.card-row.primary .card-label {
|
||||||
|
display: none; /* 主行只显值,节省空间 */
|
||||||
|
}
|
||||||
|
.card-row.secondary {
|
||||||
|
color: var(--text-soft);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.card-label {
|
||||||
|
color: var(--text-mute);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 56px;
|
||||||
|
}
|
||||||
|
.card-value {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.card-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
.card-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* 卡片样式下,按钮变得更易点 */
|
||||||
|
.card-actions :deep(.btn) {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.card-actions :deep(.btn-link) {
|
||||||
|
padding: 6px 8px;
|
||||||
|
min-width: 32px;
|
||||||
|
min-height: 32px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<template>
|
||||||
|
<div class="pwa-toast-stack" role="status" aria-live="polite">
|
||||||
|
<!-- 新版本可用 -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div v-if="pwa.needRefresh" class="pwa-toast pwa-toast--update" role="alert">
|
||||||
|
<span class="pwa-toast__icon">🔄</span>
|
||||||
|
<div class="pwa-toast__body">
|
||||||
|
<strong>新版本可用</strong>
|
||||||
|
<span>点击刷新以加载最新内容</span>
|
||||||
|
</div>
|
||||||
|
<button class="pwa-toast__btn pwa-toast__btn--primary" @click="pwa.applyUpdate()">刷新</button>
|
||||||
|
<button class="pwa-toast__btn" @click="pwa.dismissNeedRefresh()" aria-label="稍后">×</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- 离线就绪 -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div v-if="pwa.offlineReady" class="pwa-toast pwa-toast--offline">
|
||||||
|
<span class="pwa-toast__icon">📦</span>
|
||||||
|
<div class="pwa-toast__body">
|
||||||
|
<strong>已可离线使用</strong>
|
||||||
|
<span>无网络时仍能打开</span>
|
||||||
|
</div>
|
||||||
|
<button class="pwa-toast__btn" @click="pwa.dismissOfflineReady()" aria-label="知道了">×</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- Android/桌面安装引导 -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div
|
||||||
|
v-if="pwa.installPromptEvent && !pwa.isInstalled"
|
||||||
|
class="pwa-toast pwa-toast--install"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="安装应用"
|
||||||
|
>
|
||||||
|
<span class="pwa-toast__icon">⬇️</span>
|
||||||
|
<div class="pwa-toast__body">
|
||||||
|
<strong>安装 CarLog</strong>
|
||||||
|
<span>添加到主屏幕,像 App 一样使用</span>
|
||||||
|
</div>
|
||||||
|
<button class="pwa-toast__btn pwa-toast__btn--primary" @click="onInstall">安装</button>
|
||||||
|
<button class="pwa-toast__btn" @click="dismissInstall" aria-label="稍后">×</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
|
||||||
|
<!-- iOS Safari 引导 -->
|
||||||
|
<transition name="slide-up">
|
||||||
|
<div
|
||||||
|
v-if="showIosHint"
|
||||||
|
class="pwa-toast pwa-toast--ios"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="iOS 安装提示"
|
||||||
|
>
|
||||||
|
<span class="pwa-toast__icon">📱</span>
|
||||||
|
<div class="pwa-toast__body">
|
||||||
|
<strong>添加到主屏幕</strong>
|
||||||
|
<span>点击底部分享 <span class="pwa-ios-share">⬆</span>,选「添加到主屏幕」</span>
|
||||||
|
</div>
|
||||||
|
<button class="pwa-toast__btn" @click="dismissIos" aria-label="知道了">×</button>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
|
import { usePwaStore } from '../stores/pwa';
|
||||||
|
|
||||||
|
const pwa = usePwaStore();
|
||||||
|
|
||||||
|
// 用 sessionStorage 标记这次会话已关过 iOS 提示,避免反复弹
|
||||||
|
const IOS_HINT_KEY = 'pwa.iosHint.dismissed';
|
||||||
|
const showIosHint = ref(false);
|
||||||
|
const dismissed = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// iOS Safari + 未安装 + 没关过 → 弹一次
|
||||||
|
if (pwa.isIosSafari && !pwa.isInstalled) {
|
||||||
|
dismissed.value = sessionStorage.getItem(IOS_HINT_KEY) === '1';
|
||||||
|
showIosHint.value = !dismissed.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
onUnmounted(() => {});
|
||||||
|
|
||||||
|
const canShowIos = computed(() => showIosHint.value && !dismissed.value);
|
||||||
|
void canShowIos;
|
||||||
|
|
||||||
|
function dismissInstall() {
|
||||||
|
// Pinia 自动解包 ref,直接赋值即可,不要用 .value
|
||||||
|
// 原写法 pwa.installPromptEvent.value = null 会抛 TypeError(对 null)或静默无效(对 Event)
|
||||||
|
pwa.installPromptEvent = null;
|
||||||
|
}
|
||||||
|
function dismissIos() {
|
||||||
|
sessionStorage.setItem(IOS_HINT_KEY, '1');
|
||||||
|
showIosHint.value = false;
|
||||||
|
dismissed.value = true;
|
||||||
|
}
|
||||||
|
async function onInstall() {
|
||||||
|
const accepted = await pwa.promptInstall();
|
||||||
|
if (!accepted) {
|
||||||
|
// 用户拒绝,3 天内不再弹
|
||||||
|
const ts = Date.now();
|
||||||
|
localStorage.setItem('pwa.install.dismissedAt', String(ts));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.pwa-toast-stack {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: min(420px, calc(100vw - 24px));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.pwa-toast {
|
||||||
|
pointer-events: auto;
|
||||||
|
background: var(--bg-elev, #fff);
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
border: 1px solid var(--border, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.18);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.pwa-toast--update {
|
||||||
|
border-color: var(--brand, #1b6ef3);
|
||||||
|
}
|
||||||
|
.pwa-toast--install {
|
||||||
|
border-color: #10b981;
|
||||||
|
}
|
||||||
|
.pwa-toast__icon {
|
||||||
|
font-size: 22px;
|
||||||
|
flex: 0 0 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pwa-toast__body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.pwa-toast__body strong {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong, #111827);
|
||||||
|
}
|
||||||
|
.pwa-toast__body span {
|
||||||
|
color: var(--text-soft, #6b7280);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.pwa-toast__btn {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-soft, #6b7280);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.pwa-toast__btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.pwa-toast__btn--primary {
|
||||||
|
background: var(--brand, #1b6ef3);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
width: auto;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.pwa-toast__btn--primary:hover {
|
||||||
|
background: #1858c4;
|
||||||
|
}
|
||||||
|
.pwa-ios-share {
|
||||||
|
display: inline-block;
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
.slide-up-enter-active,
|
||||||
|
.slide-up-leave-active {
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
.slide-up-enter-from,
|
||||||
|
.slide-up-leave-to {
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.pwa-toast {
|
||||||
|
background: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
color: #f3f4f6;
|
||||||
|
}
|
||||||
|
.pwa-toast__body strong {
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
.pwa-toast__body span {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.pwa-toast__btn:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stat-card card">
|
||||||
|
<div class="head">
|
||||||
|
<span class="label">{{ title }}</span>
|
||||||
|
<span v-if="icon" class="icon">{{ icon }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="value">{{ value }}</div>
|
||||||
|
<div v-if="hint" class="hint" :class="trendClass">{{ hint }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
const props = defineProps({
|
||||||
|
title: String, value: [String, Number], hint: String,
|
||||||
|
icon: { type: String, default: '' },
|
||||||
|
trend: { type: String, default: 'neutral' }, // up/down/neutral
|
||||||
|
});
|
||||||
|
const trendClass = computed(() => ({ up: 'text-green', down: 'text-danger' }[props.trend] || 'text-mute'));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat-card { padding: 20px 22px; }
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.label { color: var(--text-soft); font-size: 13px; }
|
||||||
|
.icon { font-size: 18px; opacity: .8; }
|
||||||
|
.value { font-size: 28px; font-weight: 600; margin-top: 8px; letter-spacing: -0.02em; }
|
||||||
|
.hint { font-size: 12px; margin-top: 4px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// client/src/composables/useAiRecognize.js
|
||||||
|
// 通用 AI 截图识别 composable — 5 个表单复用
|
||||||
|
// 用法:
|
||||||
|
// const ai = useAiRecognize();
|
||||||
|
// <button @click="onAiRecognize" :disabled="ai.busy.value">📷 AI 识别</button>
|
||||||
|
// <AiFallbackModal
|
||||||
|
// :show="ai.showFallback.value"
|
||||||
|
// :image-url="ai.fallback.value?.preview_url"
|
||||||
|
// @cancel="ai.cancelFallback()"
|
||||||
|
// @confirm="onManualConfirm"
|
||||||
|
// >
|
||||||
|
// <!-- 这里放手动填表字段 -->
|
||||||
|
// </AiFallbackModal>
|
||||||
|
//
|
||||||
|
// 调用流程:
|
||||||
|
// open(type, onSuccess) — 成功 → onSuccess(data)
|
||||||
|
// — 失败 → 打开 AiFallbackModal(不再 alert)
|
||||||
|
//
|
||||||
|
// 兜底数据:fallback.value = { image_id, preview_url, type, error }
|
||||||
|
// 调用方在 @confirm 里读 fallback.value 并把它当作成功数据使用(手动填的字段)即可。
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import * as aiApi from '../api/ai';
|
||||||
|
|
||||||
|
export function useAiRecognize() {
|
||||||
|
const busy = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const showFallback = ref(false);
|
||||||
|
const fallback = ref(null); // { image_id, preview_url, type, error }
|
||||||
|
|
||||||
|
function pickFile() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.onchange = (e) => resolve(e.target.files[0] || null);
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewUrlFor(imageId) {
|
||||||
|
// 上传目录固定在 /api/uploads/ai/
|
||||||
|
return `/api/uploads/ai/${imageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(type, onSuccess) {
|
||||||
|
error.value = '';
|
||||||
|
fallback.value = null;
|
||||||
|
showFallback.value = false;
|
||||||
|
const file = await pickFile();
|
||||||
|
if (!file) return;
|
||||||
|
busy.value = true;
|
||||||
|
let uploadedId = null;
|
||||||
|
let uploadedUrl = null;
|
||||||
|
try {
|
||||||
|
// 1) 上传
|
||||||
|
const upR = await aiApi.uploadImage(file);
|
||||||
|
uploadedId = upR.data.image_id;
|
||||||
|
uploadedUrl = upR.data.url || previewUrlFor(uploadedId);
|
||||||
|
// 2) 识别
|
||||||
|
const recR = await aiApi.recognize(uploadedId, type);
|
||||||
|
const data = recR.data.data || {};
|
||||||
|
// 3) 回调
|
||||||
|
onSuccess?.(data, recR.data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.response?.data?.error?.message || e.message;
|
||||||
|
error.value = msg;
|
||||||
|
if (uploadedId) {
|
||||||
|
// 至少上传成功了,弹出兜底 modal 让用户对着图填
|
||||||
|
fallback.value = {
|
||||||
|
image_id: uploadedId,
|
||||||
|
preview_url: uploadedUrl,
|
||||||
|
type,
|
||||||
|
error: msg,
|
||||||
|
};
|
||||||
|
showFallback.value = true;
|
||||||
|
} else {
|
||||||
|
// 上传就挂了 — 只能 alert
|
||||||
|
const isConfig = msg.includes('未配置 AI API key') || msg.includes('请先');
|
||||||
|
alert(
|
||||||
|
'AI 识别失败:' + msg + (isConfig ? '' :
|
||||||
|
'\n\n可能原因:\n1. AI API key 未配置或无效(设置 → AI 截图识别)\n2. 网络无法访问 AI provider\n3. 文件过大或格式不支持'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recognizeFromFile(file, type, onSuccess) {
|
||||||
|
error.value = '';
|
||||||
|
fallback.value = null;
|
||||||
|
showFallback.value = false;
|
||||||
|
if (!file) return;
|
||||||
|
busy.value = true;
|
||||||
|
let uploadedId = null;
|
||||||
|
let uploadedUrl = null;
|
||||||
|
try {
|
||||||
|
const upR = await aiApi.uploadImage(file);
|
||||||
|
uploadedId = upR.data.image_id;
|
||||||
|
uploadedUrl = upR.data.url || previewUrlFor(uploadedId);
|
||||||
|
const recR = await aiApi.recognize(uploadedId, type);
|
||||||
|
const data = recR.data.data || {};
|
||||||
|
onSuccess?.(data, recR.data);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.response?.data?.error?.message || e.message;
|
||||||
|
error.value = msg;
|
||||||
|
if (uploadedId) {
|
||||||
|
fallback.value = { image_id: uploadedId, preview_url: uploadedUrl, type, error: msg };
|
||||||
|
showFallback.value = true;
|
||||||
|
} else {
|
||||||
|
alert('AI 识别失败:' + msg);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelFallback() {
|
||||||
|
showFallback.value = false;
|
||||||
|
fallback.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { open, recognizeFromFile, busy, error, showFallback, fallback, cancelFallback };
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// client/src/main.js — 入口
|
||||||
|
import { createApp } from 'vue';
|
||||||
|
import { createPinia } from 'pinia';
|
||||||
|
import App from './App.vue';
|
||||||
|
import router from './router';
|
||||||
|
import { useDebugStore } from './stores/debug';
|
||||||
|
import { usePwaStore } from './stores/pwa';
|
||||||
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
|
import './style.css';
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.use(router);
|
||||||
|
|
||||||
|
// PWA Service Worker 注册
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const pwa = usePwaStore();
|
||||||
|
const updateSW = registerSW({
|
||||||
|
immediate: true,
|
||||||
|
onNeedRefresh() {
|
||||||
|
console.info('[PWA] 新版本可用');
|
||||||
|
pwa.triggerNeedRefresh();
|
||||||
|
},
|
||||||
|
onOfflineReady() {
|
||||||
|
console.info('[PWA] 离线缓存就绪');
|
||||||
|
pwa.triggerOfflineReady();
|
||||||
|
},
|
||||||
|
onRegisterError(err) {
|
||||||
|
console.warn('[PWA] SW 注册失败', err);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
pwa.bindRegisterSw(updateSW);
|
||||||
|
// 暴露到 window 方便调试 / 强制更新
|
||||||
|
window.__pwaUpdate = () => updateSW(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局错误捕获 → 调试面板
|
||||||
|
app.config.errorHandler = (err, instance, info) => {
|
||||||
|
const debug = useDebugStore();
|
||||||
|
debug.log({
|
||||||
|
kind: 'vue',
|
||||||
|
title: `[${info}] ${err?.message || err}`,
|
||||||
|
detail: {
|
||||||
|
message: err?.message,
|
||||||
|
stack: err?.stack,
|
||||||
|
info,
|
||||||
|
component: instance?.$options?.name || instance?.$options?.__name || '<anonymous>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error('[vue error]', err, info);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const debug = useDebugStore();
|
||||||
|
debug.log({
|
||||||
|
kind: 'promise',
|
||||||
|
title: `未捕获的 Promise 异常: ${e.reason?.message || e.reason}`,
|
||||||
|
detail: {
|
||||||
|
message: e.reason?.message || String(e.reason),
|
||||||
|
stack: e.reason?.stack,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error('[unhandledrejection]', e.reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
const debug = useDebugStore();
|
||||||
|
if (e.error) {
|
||||||
|
debug.log({
|
||||||
|
kind: 'runtime',
|
||||||
|
title: `全局错误: ${e.message}`,
|
||||||
|
detail: {
|
||||||
|
message: e.message,
|
||||||
|
filename: e.filename,
|
||||||
|
lineno: e.lineno,
|
||||||
|
colno: e.colno,
|
||||||
|
stack: e.error?.stack,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.mount('#app');
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// client/src/router/index.js — 路由 + 守卫(路由级 code-split)
|
||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
|
// 路由级别懒加载 → 每个 view 独立 chunk,首屏只下载 Home + Login
|
||||||
|
const Login = () => import(/* webpackChunkName: "v-login" */ '../views/Login.vue');
|
||||||
|
const Home = () => import(/* webpackChunkName: "v-home" */ '../views/Home.vue');
|
||||||
|
const Offline = () => import(/* webpackChunkName: "v-offline" */ '../views/Offline.vue');
|
||||||
|
const WashesList = () => import('../views/WashesList.vue');
|
||||||
|
const WashNew = () => import('../views/WashNew.vue');
|
||||||
|
const WashShow = () => import('../views/WashShow.vue');
|
||||||
|
const ChemicalsList = () => import('../views/ChemicalsList.vue');
|
||||||
|
const ChemicalNew = () => import('../views/ChemicalNew.vue');
|
||||||
|
const BatchPurchase = () => import('../views/BatchPurchase.vue');
|
||||||
|
const ChemicalDetail = () => import('../views/ChemicalDetail.vue');
|
||||||
|
const VehiclesList = () => import('../views/VehiclesList.vue');
|
||||||
|
const VehicleForm = () => import('../views/VehicleForm.vue');
|
||||||
|
const VehicleDetail = () => import('../views/VehicleDetail.vue');
|
||||||
|
const MaintenanceList = () => import('../views/MaintenanceList.vue');
|
||||||
|
const RefuelList = () => import('../views/RefuelList.vue');
|
||||||
|
const ChargingList = () => import('../views/ChargingList.vue');
|
||||||
|
const InsuranceList = () => import('../views/InsuranceList.vue');
|
||||||
|
const Stats = () => import('../views/Stats.vue');
|
||||||
|
const Settings = () => import('../views/Settings.vue');
|
||||||
|
const OperationLogs = () => import('../views/OperationLogs.vue');
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
|
||||||
|
{ path: '/', name: 'home', component: Home },
|
||||||
|
{ path: '/washes', name: 'washes', component: WashesList },
|
||||||
|
{ path: '/washes/new', name: 'wash-new', component: WashNew },
|
||||||
|
{ path: '/washes/:id', name: 'wash-show', component: WashShow },
|
||||||
|
{ path: '/chemicals', name: 'chemicals', component: ChemicalsList },
|
||||||
|
{ path: '/chemicals/new', name: 'chemical-new', component: ChemicalNew },
|
||||||
|
{ path: '/chemicals/purchase', name: 'chemical-purchase', component: BatchPurchase },
|
||||||
|
{ path: '/chemicals/:id', name: 'chemical-show', component: ChemicalDetail },
|
||||||
|
{ path: '/vehicles', name: 'vehicles', component: VehiclesList },
|
||||||
|
{ path: '/vehicles/new', name: 'vehicle-new', component: VehicleForm },
|
||||||
|
{ path: '/vehicles/:id', name: 'vehicle-show', component: VehicleDetail },
|
||||||
|
{ path: '/vehicles/:id/edit', name: 'vehicle-edit', component: VehicleForm },
|
||||||
|
{ path: '/maintenances', name: 'maintenances', component: MaintenanceList },
|
||||||
|
{ path: '/refuels', name: 'refuels', component: RefuelList },
|
||||||
|
{ path: '/chargings', name: 'chargings', component: ChargingList },
|
||||||
|
{ path: '/insurances', name: 'insurances', component: InsuranceList },
|
||||||
|
{ path: '/stats', name: 'stats', component: Stats },
|
||||||
|
{ path: '/settings', name: 'settings', component: Settings },
|
||||||
|
{ path: '/operation-logs', name: 'operation-logs', component: OperationLogs },
|
||||||
|
{ path: '/offline', name: 'offline', component: Offline, meta: { public: true } },
|
||||||
|
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes,
|
||||||
|
scrollBehavior: () => ({ top: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.beforeEach(async (to, from) => {
|
||||||
|
const auth = useAuthStore();
|
||||||
|
if (!auth.bootstrapped) await auth.refresh();
|
||||||
|
if (!to.meta.public && !auth.user) {
|
||||||
|
return { name: 'login', query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
if (to.name === 'login' && auth.user) {
|
||||||
|
return { name: 'home' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// client/src/stores/auth.js
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import * as authApi from '../api/auth';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
user: null,
|
||||||
|
csrfToken: '',
|
||||||
|
bootstrapped: false,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async refresh() {
|
||||||
|
try {
|
||||||
|
const me = await authApi.me();
|
||||||
|
this.user = me.data?.user || me.data;
|
||||||
|
if (!this.user) throw new Error('no user');
|
||||||
|
} catch {
|
||||||
|
this.user = null;
|
||||||
|
}
|
||||||
|
await this.refreshCsrf();
|
||||||
|
this.bootstrapped = true;
|
||||||
|
},
|
||||||
|
async refreshCsrf() {
|
||||||
|
try {
|
||||||
|
const r = await authApi.csrf();
|
||||||
|
this.csrfToken = r.data?.csrf_token || '';
|
||||||
|
} catch {
|
||||||
|
this.csrfToken = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async login(username, password) {
|
||||||
|
const r = await authApi.login(username, password);
|
||||||
|
this.user = r.data?.user || null;
|
||||||
|
await this.refreshCsrf();
|
||||||
|
return r.data;
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
try { await authApi.logout(); } catch {}
|
||||||
|
this.user = null;
|
||||||
|
this.csrfToken = '';
|
||||||
|
},
|
||||||
|
clear() { this.user = null; },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
// client/src/stores/debug.js — 调试模式 + 错误/调用日志
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
const LS_KEY = 'carwash:debug';
|
||||||
|
|
||||||
|
function loadLS() {
|
||||||
|
try { return localStorage.getItem(LS_KEY) === '1'; } catch { return false; }
|
||||||
|
}
|
||||||
|
function saveLS(v) { try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch {} }
|
||||||
|
|
||||||
|
export const useDebugStore = defineStore('debug', {
|
||||||
|
state: () => ({
|
||||||
|
enabled: loadLS(),
|
||||||
|
errors: [], // { id, ts, kind, title, detail }
|
||||||
|
calls: [], // { id, ts, method, url, status, ms, body }
|
||||||
|
collapsed: false,
|
||||||
|
tab: 'errors', // 'errors' | 'calls'
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
count: (s) => s.errors.length,
|
||||||
|
callCount: (s) => s.calls.length,
|
||||||
|
latest: (s) => s.errors[s.errors.length - 1],
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
toggle() {
|
||||||
|
this.enabled = !this.enabled;
|
||||||
|
saveLS(this.enabled);
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
this.enabled = !!v;
|
||||||
|
saveLS(this.enabled);
|
||||||
|
},
|
||||||
|
log(err) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
this.errors.push({
|
||||||
|
id,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
kind: err.kind || 'error',
|
||||||
|
title: err.title || '未知错误',
|
||||||
|
detail: err.detail || {},
|
||||||
|
});
|
||||||
|
if (this.errors.length > 50) this.errors = this.errors.slice(-50);
|
||||||
|
this.collapsed = false;
|
||||||
|
},
|
||||||
|
logCall(call) {
|
||||||
|
if (!this.enabled) return;
|
||||||
|
this.calls.push({
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
...call,
|
||||||
|
});
|
||||||
|
if (this.calls.length > 80) this.calls = this.calls.slice(-80);
|
||||||
|
},
|
||||||
|
clear() { this.errors = []; this.calls = []; },
|
||||||
|
setTab(t) { this.tab = t; },
|
||||||
|
togglePanel() { this.collapsed = !this.collapsed; },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// client/src/stores/pwa.js — PWA 状态:更新提示 / 安装提示 / 离线
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
|
||||||
|
export const usePwaStore = defineStore('pwa', () => {
|
||||||
|
/** true = 已有新版本 SW 等激活,需要刷新 */
|
||||||
|
const needRefresh = ref(false);
|
||||||
|
/** true = 资源已缓存完,可离线用 */
|
||||||
|
const offlineReady = ref(false);
|
||||||
|
/** 浏览器/桌面触发安装的事件(Android/Desktop Chrome) */
|
||||||
|
const installPromptEvent = ref(null);
|
||||||
|
/** 已安装(standalone 模式运行) */
|
||||||
|
const isInstalled = computed(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return (
|
||||||
|
window.matchMedia('(display-mode: standalone)').matches ||
|
||||||
|
window.navigator.standalone === true // iOS Safari
|
||||||
|
);
|
||||||
|
});
|
||||||
|
/** iOS 设备 + Safari + 未安装 → 引导走"分享 → 添加到主屏幕" */
|
||||||
|
const isIosSafari = computed(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
const ua = window.navigator.userAgent;
|
||||||
|
return /iPad|iPhone|iPod/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua);
|
||||||
|
});
|
||||||
|
|
||||||
|
let updateFn = null;
|
||||||
|
|
||||||
|
function bindRegisterSw(registerFn) {
|
||||||
|
updateFn = registerFn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerNeedRefresh() {
|
||||||
|
needRefresh.value = true;
|
||||||
|
}
|
||||||
|
function triggerOfflineReady() {
|
||||||
|
offlineReady.value = true;
|
||||||
|
// 5s 后自动收起
|
||||||
|
setTimeout(() => (offlineReady.value = false), 5000);
|
||||||
|
}
|
||||||
|
async function applyUpdate() {
|
||||||
|
if (updateFn) {
|
||||||
|
await updateFn(true);
|
||||||
|
needRefresh.value = false;
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function dismissNeedRefresh() {
|
||||||
|
needRefresh.value = false;
|
||||||
|
}
|
||||||
|
function dismissOfflineReady() {
|
||||||
|
offlineReady.value = false;
|
||||||
|
}
|
||||||
|
async function promptInstall() {
|
||||||
|
const e = installPromptEvent.value;
|
||||||
|
if (!e) return false;
|
||||||
|
e.prompt();
|
||||||
|
const choice = await e.userChoice;
|
||||||
|
installPromptEvent.value = null;
|
||||||
|
return choice.outcome === 'accepted';
|
||||||
|
}
|
||||||
|
function captureInstallPrompt(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
installPromptEvent.value = e;
|
||||||
|
}
|
||||||
|
// 全局监听 beforeinstallprompt
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeinstallprompt', captureInstallPrompt);
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
installPromptEvent.value = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
needRefresh,
|
||||||
|
offlineReady,
|
||||||
|
installPromptEvent,
|
||||||
|
isInstalled,
|
||||||
|
isIosSafari,
|
||||||
|
bindRegisterSw,
|
||||||
|
triggerNeedRefresh,
|
||||||
|
triggerOfflineReady,
|
||||||
|
applyUpdate,
|
||||||
|
dismissNeedRefresh,
|
||||||
|
dismissOfflineReady,
|
||||||
|
promptInstall,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/* client/src/style.css — EstateHub 设计令牌 + 全局样式 */
|
||||||
|
:root {
|
||||||
|
--bg: #E8F4F9;
|
||||||
|
--bg-soft: #F2F8FB;
|
||||||
|
--card: #FFFFFF;
|
||||||
|
--card-shadow: 0 2px 8px rgba(40, 80, 110, 0.06);
|
||||||
|
--card-shadow-hover: 0 4px 16px rgba(40, 80, 110, 0.10);
|
||||||
|
--text: #0F2233;
|
||||||
|
--text-soft: #5A6F80;
|
||||||
|
--text-mute: #8A9CAB;
|
||||||
|
--line: #E1ECF2;
|
||||||
|
--accent: #0F2233;
|
||||||
|
--accent-soft: #1A3A55;
|
||||||
|
--brand: #1E5B8A;
|
||||||
|
--brand-soft: #2C7AB0;
|
||||||
|
--green: #4DBA9A;
|
||||||
|
--green-soft: #7CD0B5;
|
||||||
|
--warn: #E8A33D;
|
||||||
|
--danger: #D9695C;
|
||||||
|
--info: #5AA8D8;
|
||||||
|
--radius: 14px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--radius-lg: 22px;
|
||||||
|
--pill: 999px;
|
||||||
|
--font: 'Outfit', system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
|
||||||
|
/* === 响应式断点(4 档)===
|
||||||
|
* --bp-sm: 小屏手机 (< 480px) → 极简布局
|
||||||
|
* --bp-md: 大屏手机/小平板 (480~767) → 紧凑布局
|
||||||
|
* --bp-lg: 平板 (768~1023) → 双列/抽屉式
|
||||||
|
* --bp-xl: 桌面 (1024~1439) → 标准布局
|
||||||
|
* --bp-2xl: 大屏桌面 (≥1440) → 宽屏布局
|
||||||
|
*/
|
||||||
|
--bp-sm: 480px;
|
||||||
|
--bp-md: 768px;
|
||||||
|
--bp-lg: 1024px;
|
||||||
|
--bp-xl: 1440px;
|
||||||
|
|
||||||
|
/* iOS 安全区 + Android 导航条适配 */
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
font-feature-settings: 'ss01', 'cv11';
|
||||||
|
}
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
button { font-family: inherit; cursor: pointer; }
|
||||||
|
|
||||||
|
/* === 工具类 === */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 8px 16px; border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px; font-weight: 500; border: 0; transition: all .15s;
|
||||||
|
}
|
||||||
|
.btn-primary { background: var(--accent); color: #fff; }
|
||||||
|
.btn-primary:hover { background: var(--accent-soft); }
|
||||||
|
.btn-ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }
|
||||||
|
.btn-ghost:hover { background: var(--bg-soft); }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
transition: box-shadow .2s;
|
||||||
|
}
|
||||||
|
.card:hover { box-shadow: var(--card-shadow-hover); }
|
||||||
|
.card-pad { padding: 24px; }
|
||||||
|
|
||||||
|
.input, .select, .textarea {
|
||||||
|
width: 100%; padding: 10px 14px;
|
||||||
|
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||||
|
font-size: 14px; font-family: inherit; background: #fff; color: var(--text);
|
||||||
|
transition: border .15s;
|
||||||
|
}
|
||||||
|
.input:focus, .select:focus, .textarea:focus { outline: 0; border-color: var(--brand-soft); }
|
||||||
|
.textarea { resize: vertical; min-height: 80px; }
|
||||||
|
.label { display: block; font-size: 13px; color: var(--text-soft); margin-bottom: 6px; font-weight: 500; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 3px 10px; border-radius: var(--pill);
|
||||||
|
font-size: 12px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.pill-blue { background: #E0F0FA; color: var(--brand); }
|
||||||
|
.pill-green { background: #DEF4EC; color: #2E8A6B; }
|
||||||
|
.pill-warn { background: #FBEED9; color: #8B6510; }
|
||||||
|
.pill-danger { background: #FBE3DF; color: #A33B30; }
|
||||||
|
.pill-gray { background: #EEF2F5; color: var(--text-soft); }
|
||||||
|
|
||||||
|
.text-soft { color: var(--text-soft); }
|
||||||
|
.text-mute { color: var(--text-mute); }
|
||||||
|
.text-green { color: var(--green); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
.text-brand { color: var(--brand); }
|
||||||
|
|
||||||
|
.flex { display: flex; }
|
||||||
|
.flex-col { display: flex; flex-direction: column; }
|
||||||
|
.items-center { align-items: center; }
|
||||||
|
.justify-between { justify-content: space-between; }
|
||||||
|
.justify-center { justify-content: center; }
|
||||||
|
.gap-2 { gap: 8px; }
|
||||||
|
.gap-3 { gap: 12px; }
|
||||||
|
.gap-4 { gap: 16px; }
|
||||||
|
.gap-6 { gap: 24px; }
|
||||||
|
.mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; } .mt-6 { margin-top: 24px; }
|
||||||
|
.mb-2 { margin-bottom: 8px; } .mb-3 { margin-bottom: 12px; } .mb-4 { margin-bottom: 16px; } .mb-6 { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
table.data { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||||
|
table.data th, table.data td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
||||||
|
table.data th { font-weight: 500; color: var(--text-soft); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
table.data tr:hover td { background: var(--bg-soft); }
|
||||||
|
table.data tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* 渐入动画 */
|
||||||
|
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
|
||||||
|
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
/* === 移动端全局响应式工具 === */
|
||||||
|
.mobile-only { display: none; }
|
||||||
|
.desktop-only { display: inline-flex; }
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-only { display: inline-flex; }
|
||||||
|
.desktop-only { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 移动端表格兜底:未转 MobileCardList 的 <table> 横向滚动 === */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card > table.data,
|
||||||
|
.card-pad > table.data {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.data th, table.data td { font-size: 13px; padding: 8px 10px; }
|
||||||
|
/* 标记移动端可滚动提示 */
|
||||||
|
.card:has(> table.data[role="scroll"]) { position: relative; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === 移动端全局输入优化(防 iOS 缩放) === */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
input, select, textarea { font-size: 16px; }
|
||||||
|
.input, .select, .textarea { padding: 10px 12px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// client/src/utils/formDraft.js — 表单草稿暂存(sessionStorage 版)
|
||||||
|
// 用途:session 过期被 401 拦截器打回登录页时,把用户填的表单数据存住,
|
||||||
|
// 登录成功回到原页后能恢复,不丢工。
|
||||||
|
//
|
||||||
|
// 用法(推荐,5 行接入):
|
||||||
|
// const draft = useFormDraft('washes/new');
|
||||||
|
// const restored = draft.load();
|
||||||
|
// if (restored) Object.assign(form, restored); // 恢复草稿
|
||||||
|
// watch(form, (v) => draft.save(v), { deep: true }); // 自动保存
|
||||||
|
// await onSubmit();
|
||||||
|
// draft.clear(); // 提交成功清掉
|
||||||
|
//
|
||||||
|
// 401 触发:window.dispatchEvent(new CustomEvent('form-draft:flush-all'))
|
||||||
|
// ↓ 监听器把每个已注册草稿的 latest 立即写盘
|
||||||
|
// ↓ 然后 axios 拦截器跳 /login
|
||||||
|
// ↓ 登录成功回到原页 → 组件 mount → load() 恢复
|
||||||
|
|
||||||
|
const PREFIX = 'formDraft:';
|
||||||
|
const DEBOUNCE_MS = 250;
|
||||||
|
|
||||||
|
function key(name) { return PREFIX + name; }
|
||||||
|
|
||||||
|
export function useFormDraft(name) {
|
||||||
|
let latest = null;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key(name));
|
||||||
|
if (!raw) return null;
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNow(value) {
|
||||||
|
try { sessionStorage.setItem(key(name), JSON.stringify(value)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(value) {
|
||||||
|
latest = value;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => saveNow(value), DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
if (latest !== null) saveNow(latest);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
if (timer) { clearTimeout(timer); timer = null; }
|
||||||
|
latest = null;
|
||||||
|
try { sessionStorage.removeItem(key(name)); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { load, save, flush, clear };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----- 全局 flush 注册(401 用)----- */
|
||||||
|
const registered = new Set();
|
||||||
|
let listenerInstalled = false;
|
||||||
|
function installListener() {
|
||||||
|
if (listenerInstalled) return;
|
||||||
|
listenerInstalled = true;
|
||||||
|
window.addEventListener('form-draft:flush-all', () => {
|
||||||
|
for (const fn of registered) {
|
||||||
|
try { fn(); } catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 让一个草稿实例参与全局 flush:返回 unregister 函数 */
|
||||||
|
export function registerDraftForFlush(flushFn) {
|
||||||
|
installListener();
|
||||||
|
registered.add(flushFn);
|
||||||
|
return () => registered.delete(flushFn);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">批量采购入库</h1>
|
||||||
|
<p class="subtitle text-soft">一次性往 Grocy 入库多个产品(采购/自制/盘点)</p>
|
||||||
|
</div>
|
||||||
|
<router-link to="/chemicals" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad">
|
||||||
|
<div class="toolbar">
|
||||||
|
<input v-model="search" type="text" class="input search-input" placeholder="搜产品名 / ID 添加到清单…" />
|
||||||
|
<span class="text-mute sm">{{ filteredProducts.length }} 个产品可选</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="picker">
|
||||||
|
<h3 class="section-title">产品列表</h3>
|
||||||
|
<div class="prod-list">
|
||||||
|
<button
|
||||||
|
v-for="p in filteredProducts"
|
||||||
|
:key="p.grocy_product_id"
|
||||||
|
class="prod-item"
|
||||||
|
:class="{ active: inCart(p.grocy_product_id) }"
|
||||||
|
@click="addToCart(p)"
|
||||||
|
:disabled="inCart(p.grocy_product_id)"
|
||||||
|
>
|
||||||
|
<span class="prod-id">#{{ p.grocy_product_id }}</span>
|
||||||
|
<span class="prod-name">{{ p.name }}</span>
|
||||||
|
<span class="prod-meta text-mute sm">{{ p.category_display || '—' }} · 当前 {{ p.current_amount }} {{ p.unit || '' }}</span>
|
||||||
|
<span v-if="inCart(p.grocy_product_id)" class="text-mute sm">已加入</span>
|
||||||
|
<span v-else class="text-brand sm">+ 加入</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="!filteredProducts.length" class="empty">没匹配的产品</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cart">
|
||||||
|
<h3 class="section-title">采购清单 ({{ cart.length }} 项)</h3>
|
||||||
|
<div v-if="!cart.length" class="empty">左侧点击产品加入清单</div>
|
||||||
|
<div v-else class="cart-list">
|
||||||
|
<div v-for="(item, idx) in cart" :key="item.grocy_product_id" class="cart-item">
|
||||||
|
<div class="ci-head">
|
||||||
|
<strong>{{ item.name }}</strong>
|
||||||
|
<button class="ci-x" @click="cart.splice(idx, 1)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid3">
|
||||||
|
<div>
|
||||||
|
<label class="label">数量 *</label>
|
||||||
|
<input v-model.number="item.amount" type="number" step="0.01" min="0.01" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">单价 ¥</label>
|
||||||
|
<input v-model.number="item.price" type="number" step="0.01" min="0" class="input" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">类型</label>
|
||||||
|
<select v-model="item.transaction_type" class="select">
|
||||||
|
<option value="purchase">采购</option>
|
||||||
|
<option value="self_production">自制</option>
|
||||||
|
<option value="inventory">盘点</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label class="label">最佳赏味期</label>
|
||||||
|
<input v-model="item.best_before_date" type="date" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<input v-model="item.note" class="input" placeholder="供应商、单号等" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="cart.length" class="actions">
|
||||||
|
<span class="text-mute">共 {{ cart.length }} 项 · 总额 ¥{{ totalCost.toFixed(2) }}</span>
|
||||||
|
<button class="btn btn-primary" :disabled="busy || !hasValid" @click="onSubmit">
|
||||||
|
{{ busy ? '提交中…' : '批量入库 → Grocy' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results" class="card card-pad mt-4">
|
||||||
|
<h3 class="section-title">执行结果</h3>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>产品</th><th>数量</th><th>Grocy</th><th>本地</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in results" :key="r.grocy_product_id">
|
||||||
|
<td>{{ r.name }}</td>
|
||||||
|
<td>{{ r.amount }} {{ r.unit || '' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="r.grocy === 'ok'" class="pill pill-green">✓ 已扣减</span>
|
||||||
|
<span v-else class="pill pill-danger">✗ {{ r.grocy_error }}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="r.local === 'queued'" class="pill pill-warn">排队中</span>
|
||||||
|
<span v-else-if="r.local === 'synced'" class="pill pill-green">已同步</span>
|
||||||
|
<span v-else-if="r.local === 'failed'" class="pill pill-danger">失败</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, reactive, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import * as chemicalsApi from '../api/chemicals';
|
||||||
|
import { asArray } from '../api/client';
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const products = ref([]);
|
||||||
|
const search = ref('');
|
||||||
|
const cart = ref([]);
|
||||||
|
const busy = ref(false);
|
||||||
|
const results = ref(null);
|
||||||
|
|
||||||
|
const filteredProducts = computed(() => {
|
||||||
|
if (!search.value) return products.value.slice(0, 30);
|
||||||
|
const q = search.value.toLowerCase();
|
||||||
|
return products.value.filter(p =>
|
||||||
|
(p.name || '').toLowerCase().includes(q) ||
|
||||||
|
String(p.grocy_product_id).includes(q)
|
||||||
|
).slice(0, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasValid = computed(() => cart.value.some(c => c.amount > 0));
|
||||||
|
const totalCost = computed(() => cart.value.reduce((a, c) => a + (Number(c.amount) || 0) * (Number(c.price) || 0), 0));
|
||||||
|
|
||||||
|
function inCart(id) { return cart.value.some(c => c.grocy_product_id === String(id)); }
|
||||||
|
|
||||||
|
function addToCart(p) {
|
||||||
|
if (inCart(p.grocy_product_id)) return;
|
||||||
|
cart.value.push({
|
||||||
|
grocy_product_id: p.grocy_product_id,
|
||||||
|
name: p.name,
|
||||||
|
unit: p.unit,
|
||||||
|
amount: 1,
|
||||||
|
price: 0,
|
||||||
|
best_before_date: '',
|
||||||
|
transaction_type: 'purchase',
|
||||||
|
note: '',
|
||||||
|
grocy: null, // 提交后状态
|
||||||
|
local: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!hasValid.value) return;
|
||||||
|
busy.value = true;
|
||||||
|
results.value = null;
|
||||||
|
const items = cart.value.filter(c => c.amount > 0);
|
||||||
|
const r = [];
|
||||||
|
for (const it of items) {
|
||||||
|
try {
|
||||||
|
await chemicalsApi.addStock(it.grocy_product_id, {
|
||||||
|
amount: it.amount,
|
||||||
|
price: it.price,
|
||||||
|
best_before_date: it.best_before_date || null,
|
||||||
|
transaction_type: it.transaction_type,
|
||||||
|
note: it.note || null,
|
||||||
|
});
|
||||||
|
r.push({ ...it, grocy: 'ok', local: 'queued', grocy_error: null });
|
||||||
|
} catch (e) {
|
||||||
|
r.push({ ...it, grocy: 'fail', local: 'failed', grocy_error: e.response?.data?.message || e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
results.value = r;
|
||||||
|
busy.value = false;
|
||||||
|
// 等几秒让后台同步跑完,刷新本地缓存
|
||||||
|
setTimeout(async () => {
|
||||||
|
try { await chemicalsApi.sync(); } catch {}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const r = await chemicalsApi.list();
|
||||||
|
products.value = asArray(r.data, 'chemicals');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; }
|
||||||
|
.search-input { max-width: 360px; }
|
||||||
|
.sm { font-size: 12px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 24px; }
|
||||||
|
.section-title { font-size: 13px; font-weight: 600; color: var(--text-soft); margin: 0 0 10px; }
|
||||||
|
.prod-list { max-height: 480px; overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||||
|
.prod-item {
|
||||||
|
width: 100%; text-align: left; background: transparent; border: 0;
|
||||||
|
border-bottom: 1px solid var(--line); padding: 8px 12px; cursor: pointer;
|
||||||
|
display: grid; grid-template-columns: 50px 1fr auto; gap: 4px 8px; align-items: center;
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
.prod-item:last-child { border-bottom: 0; }
|
||||||
|
.prod-item:hover:not(:disabled) { background: var(--bg-soft); }
|
||||||
|
.prod-item.active { background: var(--bg-soft); }
|
||||||
|
.prod-item:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||||
|
.prod-id { color: var(--text-mute); font-size: 11px; font-family: monospace; grid-row: 1 / 3; }
|
||||||
|
.prod-name { font-size: 13px; font-weight: 500; grid-column: 2 / 3; grid-row: 1; }
|
||||||
|
.prod-meta { grid-column: 2 / 3; grid-row: 2; }
|
||||||
|
.prod-item > :nth-child(4) { grid-row: 1 / 3; grid-column: 3; }
|
||||||
|
|
||||||
|
.cart-list { max-height: 480px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding-right: 4px; }
|
||||||
|
.cart-item {
|
||||||
|
background: var(--bg-soft); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.ci-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||||
|
.ci-x {
|
||||||
|
background: transparent; border: 1px solid var(--line); color: var(--text-soft);
|
||||||
|
width: 24px; height: 24px; border-radius: 4px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.ci-x:hover { background: #FBE3DF; color: var(--danger); border-color: var(--danger); }
|
||||||
|
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||||
|
.grid2 { display: grid; grid-template-columns: 1fr 1.5fr; gap: 8px; margin-top: 6px; }
|
||||||
|
.empty { padding: 24px; text-align: center; color: var(--text-mute); font-size: 13px; }
|
||||||
|
.actions {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } .grid3, .grid2 { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head .actions { display: flex; flex-direction: column; width: 100%; }
|
||||||
|
.head .actions > * { width: 100%; justify-content: center; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); flex-direction: column; }
|
||||||
|
.actions > * { width: 100%; justify-content: center; min-height: 44px; }
|
||||||
|
.summary { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">充电记录</h1>
|
||||||
|
<p class="subtitle text-soft">家充 / 快充 / 慢充,每次电量 + 电耗自动计算</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="openNew">+ 新建充电</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad filters">
|
||||||
|
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||||
|
<option value="">全部车辆</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="filters.from" type="date" class="input sm" @change="load" />
|
||||||
|
<span class="text-soft">至</span>
|
||||||
|
<input v-model="filters.to" type="date" class="input sm" @change="load" />
|
||||||
|
<div class="stats-pills">
|
||||||
|
<span class="pill pill-blue">{{ data.total || 0 }} 条</span>
|
||||||
|
<span class="pill pill-green">¥{{ (data.stats?.total_cost || 0).toFixed(2) }} 总花费</span>
|
||||||
|
<span class="pill pill-gray" v-if="avgKwh">{{ avgKwh }} kWh/100km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有充电记录</div>
|
||||||
|
<MobileCardList
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:rows="data.rows"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="还没有充电记录"
|
||||||
|
>
|
||||||
|
<template #cell-date="{ row }">{{ row.charge_date }}</template>
|
||||||
|
<template #cell-vehicle="{ row }">
|
||||||
|
<div>{{ row.vehicle_name }}</div>
|
||||||
|
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-type="{ row }">
|
||||||
|
<span class="pill pill-blue">{{ CHARGE_LABEL[row.charge_type] || row.charge_type || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-odo="{ row }">{{ row.odometer_km ? row.odometer_km + ' km' : '—' }}</template>
|
||||||
|
<template #cell-kwh="{ row }"><strong>{{ row.kwh }} kWh</strong></template>
|
||||||
|
<template #cell-soc="{ row }">
|
||||||
|
<span v-if="row.start_soc != null && row.end_soc != null">{{ row.start_soc }}% → {{ row.end_soc }}%</span>
|
||||||
|
<span v-else class="text-mute sm">—</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-price="{ row }">¥{{ row.price_per_kwh || 0 }}</template>
|
||||||
|
<template #cell-cost="{ row }">
|
||||||
|
<strong class="text-brand">¥{{ (row.total_cost || 0).toFixed(2) }}</strong>
|
||||||
|
</template>
|
||||||
|
<template #cell-kwh100="{ row }">
|
||||||
|
<span v-if="row.kwh_per_100km" class="pill pill-blue">{{ row.kwh_per_100km.toFixed(2) }} kWh/100km</span>
|
||||||
|
<span v-else class="text-mute sm" :title="row.consumption_skip_reason">{{ row.consumption_skip_reason || '需里程' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-station="{ row }">{{ row.station || '—' }}</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="showDelete"
|
||||||
|
v-model="showDelete"
|
||||||
|
title="删除充电记录"
|
||||||
|
:message="`确认删除 ${deleteTarget?.charge_date} 的充电记录?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||||
|
:busy="deleteBusy"
|
||||||
|
:error="deleteError"
|
||||||
|
@confirm="doDelete"
|
||||||
|
@cancel="showDelete = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||||
|
<div class="modal card card-pad">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 class="section-title">{{ form.id ? '编辑充电' : '新建充电' }}</h3>
|
||||||
|
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别订单' }}</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSave">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.vehicle_id" class="select" required>
|
||||||
|
<option :value="null">— 请选择 —</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">日期 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.charge_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">里程 (km)</label>
|
||||||
|
<input v-model.number="form.odometer_km" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">充电类型</label>
|
||||||
|
<select v-model="form.charge_type" class="select">
|
||||||
|
<option v-for="(label, v) in CHARGE_LABEL" :key="v" :value="v">{{ label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">度数 (kWh) <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="form.kwh" type="number" step="0.01" min="0.01" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">单价 (¥/kWh)</label>
|
||||||
|
<input v-model.number="form.price_per_kwh" type="number" step="0.01" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">总价 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="form.total_cost" type="number" step="0.01" min="0" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">起止 SOC (%)</label>
|
||||||
|
<div class="soc-row">
|
||||||
|
<input v-model.number="form.start_soc" type="number" min="0" max="100" class="input sm" placeholder="20" />
|
||||||
|
<span>→</span>
|
||||||
|
<input v-model.number="form.end_soc" type="number" min="0" max="100" class="input sm" placeholder="90" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="label">地点 / 充电站</label>
|
||||||
|
<input v-model="form.station" class="input" placeholder="如 国家电网 / 特来电 / 家" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import { chargingApi } from '../api/logs';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
|
||||||
|
const CHARGE_LABEL = { home: '家充', slow: '慢充', fast: '快充', super: '超充' };
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||||
|
{ key: 'type', label: '类型' },
|
||||||
|
{ key: 'odo', label: '里程' },
|
||||||
|
{ key: 'kwh', label: '度数', alwaysShow: true },
|
||||||
|
{ key: 'soc', label: 'SOC' },
|
||||||
|
{ key: 'price', label: '单价' },
|
||||||
|
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||||
|
{ key: 'kwh100', label: '电耗' },
|
||||||
|
{ key: 'station', label: '地点' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const data = ref({ rows: [], total: 0, stats: {} });
|
||||||
|
const loading = ref(false);
|
||||||
|
const filters = reactive({ vehicle_id: '', from: '', to: '' });
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const form = reactive({ id: null, vehicle_id: null, charge_date: today(), odometer_km: null, kwh: null, price_per_kwh: null, total_cost: null, charge_type: 'home', start_soc: null, end_soc: null, station: '', notes: '' });
|
||||||
|
const formBusy = ref(false);
|
||||||
|
const formError = ref('');
|
||||||
|
|
||||||
|
// 401 草稿
|
||||||
|
const draft = useFormDraft('charges/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) Object.assign(form, restored);
|
||||||
|
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
// AI 识别
|
||||||
|
const ai = useAiRecognize();
|
||||||
|
const aiBusy = ai.busy;
|
||||||
|
async function onAiRecognize() {
|
||||||
|
await ai.open('charge', (data) => {
|
||||||
|
if (data.charge_date) form.charge_date = data.charge_date;
|
||||||
|
if (data.kwh != null) form.kwh = data.kwh;
|
||||||
|
if (data.price_per_kwh != null) form.price_per_kwh = data.price_per_kwh;
|
||||||
|
if (data.total_cost != null) form.total_cost = data.total_cost;
|
||||||
|
if (data.charge_type) form.charge_type = data.charge_type;
|
||||||
|
if (data.start_soc != null) form.start_soc = data.start_soc;
|
||||||
|
if (data.end_soc != null) form.end_soc = data.end_soc;
|
||||||
|
if (data.station) form.station = data.station;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgKwh = computed(() => {
|
||||||
|
const xs = data.value.rows?.filter(r => r.kwh_per_100km > 0).map(r => r.kwh_per_100km) || [];
|
||||||
|
if (!xs.length) return null;
|
||||||
|
return (xs.reduce((s, x) => s + x, 0) / xs.length).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动算总价
|
||||||
|
watch(() => [form.kwh, form.price_per_kwh], ([k, p]) => {
|
||||||
|
if (k && p && !form.total_cost) form.total_cost = Math.round(k * p * 100) / 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||||
|
if (filters.from) params.from = filters.from;
|
||||||
|
if (filters.to) params.to = filters.to;
|
||||||
|
const r = await chargingApi.list(params);
|
||||||
|
data.value = r.data;
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVehicles() {
|
||||||
|
const r = await vehiclesApi.list();
|
||||||
|
vehicles.value = r.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
Object.assign(form, { id: null, vehicle_id: null, charge_date: today(), odometer_km: null, kwh: null, price_per_kwh: null, total_cost: null, charge_type: 'home', start_soc: null, end_soc: null, station: '', notes: '' });
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r) {
|
||||||
|
Object.assign(form, r);
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() { showForm.value = false; }
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
formError.value = '';
|
||||||
|
formBusy.value = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
vehicle_id: form.vehicle_id,
|
||||||
|
charge_date: form.charge_date,
|
||||||
|
odometer_km: form.odometer_km || null,
|
||||||
|
kwh: form.kwh,
|
||||||
|
price_per_kwh: form.price_per_kwh || null,
|
||||||
|
total_cost: form.total_cost,
|
||||||
|
charge_type: form.charge_type || null,
|
||||||
|
start_soc: form.start_soc ?? null,
|
||||||
|
end_soc: form.end_soc ?? null,
|
||||||
|
station: form.station || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
if (form.id) await chargingApi.update(form.id, body);
|
||||||
|
else await chargingApi.create(body);
|
||||||
|
draft.clear();
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
const errs = e.response?.data?.error?.errors;
|
||||||
|
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||||
|
} finally { formBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(r) {
|
||||||
|
deleteTarget.value = r;
|
||||||
|
deleteError.value = '';
|
||||||
|
showDelete.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDelete = ref(false);
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteBusy = ref(false);
|
||||||
|
const deleteError = ref('');
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget.value) return;
|
||||||
|
deleteBusy.value = true;
|
||||||
|
deleteError.value = '';
|
||||||
|
try {
|
||||||
|
await chargingApi.remove(deleteTarget.value.id);
|
||||||
|
showDelete.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||||
|
} finally {
|
||||||
|
deleteBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadVehicles(); load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
|
||||||
|
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||||
|
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||||
|
.filters { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.stats-pills { margin-left:auto; display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||||
|
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||||
|
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:8px; flex-wrap:wrap; }
|
||||||
|
.modal-head .section-title { margin:0; }
|
||||||
|
.grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||||
|
.col-span-2 { grid-column: span 2; }
|
||||||
|
.soc-row { display:flex; gap:6px; align-items:center; }
|
||||||
|
.soc-row span { color:var(--text-soft); }
|
||||||
|
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||||
|
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||||
|
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||||
|
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||||
|
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||||
|
.r { text-align:right; }
|
||||||
|
.mt-3 { margin-top:12px; }
|
||||||
|
.text-soft { color:var(--text-soft); }
|
||||||
|
.text-mute { color:var(--text-mute); }
|
||||||
|
.text-danger { color:var(--danger); }
|
||||||
|
.text-brand { color:var(--brand); }
|
||||||
|
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.col-span-2 { grid-column: span 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; }
|
||||||
|
.head .btn { width: 100%; justify-content: center; }
|
||||||
|
.filters { padding: 12px 16px; }
|
||||||
|
.stats-pills { width: 100%; margin-left: 0; }
|
||||||
|
.modal-mask { align-items: flex-end; padding: 0; }
|
||||||
|
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.col-span-2 { grid-column: 1; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">
|
||||||
|
{{ data.name }}
|
||||||
|
<span v-if="data.source === 'grocy'" class="pill pill-blue ml-2" title="数据来源于 Grocy">Grocy</span>
|
||||||
|
<span v-else-if="data.source === 'seed'" class="pill pill-gray ml-2">演示</span>
|
||||||
|
<span v-else class="pill pill-warn ml-2">本地</span>
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle text-soft">
|
||||||
|
<router-link to="/chemicals" class="text-soft">← 返回汽车用品</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<button v-if="data.source === 'grocy'" class="btn btn-ghost btn-sm" @click="load" :disabled="loading">从 Grocy 重拉详情</button>
|
||||||
|
<button v-if="data.source === 'grocy'" class="btn btn-primary" @click="showAddStock = true">+ 采购入库</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 采购入库弹窗 -->
|
||||||
|
<div v-if="showAddStock" class="modal-mask" @click.self="showAddStock = false">
|
||||||
|
<div class="modal card card-pad">
|
||||||
|
<h3 class="section-title">采购入库 → Grocy</h3>
|
||||||
|
<p class="text-soft sm mb-3">这会直接调用 Grocy POST /api/stock/products/{{ data.grocy_product_id }}/add</p>
|
||||||
|
<form @submit.prevent="onAddStock">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">数量 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="stockForm.amount" type="number" step="0.01" min="0.01" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">单价 (¥)</label>
|
||||||
|
<input v-model.number="stockForm.price" type="number" step="0.01" min="0" class="input" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">最佳赏味期</label>
|
||||||
|
<input v-model="stockForm.best_before_date" type="date" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">交易类型</label>
|
||||||
|
<select v-model="stockForm.transaction_type" class="select">
|
||||||
|
<option value="purchase">采购入库</option>
|
||||||
|
<option value="self_production">自制入库</option>
|
||||||
|
<option value="inventory">盘点修正</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<input v-model="stockForm.note" class="input" placeholder="可选:供应商、单号等" />
|
||||||
|
</div>
|
||||||
|
<p v-if="stockError" class="error mt-3">{{ stockError }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showAddStock = false">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="stockBusy">{{ stockBusy ? '提交中…' : '提交入库' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本信息 -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="card card-pad main-card">
|
||||||
|
<h3 class="section-title">基本信息</h3>
|
||||||
|
<table class="data">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-soft" style="width:140px">用品 ID</td><td><code>{{ data.grocy_product_id }}</code></td></tr>
|
||||||
|
<tr><td class="text-soft">分类</td><td>{{ data.category_display || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">单位</td><td>{{ data.unit || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">位置</td><td>{{ data.location || '—' }}</td></tr>
|
||||||
|
<tr v-if="data.description"><td class="text-soft">描述</td><td>{{ data.description }}</td></tr>
|
||||||
|
<tr><td class="text-soft">最低库存</td><td>{{ data.min_stock_amount || 0 }} {{ data.unit || '' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">最佳赏味期</td><td>{{ data.best_before_date || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">最后同步</td><td>{{ data.last_synced_at || '—' }}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad stock-card">
|
||||||
|
<h3 class="section-title">当前库存</h3>
|
||||||
|
<div class="big-num">{{ data.current_amount }} <span class="unit">{{ data.unit || '' }}</span></div>
|
||||||
|
<div class="text-soft" style="margin-top: 8px">价值</div>
|
||||||
|
<div class="big-amount">¥{{ (data.current_value || 0).toFixed(2) }}</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<span v-if="data.low_stock" class="pill pill-danger">⚠ 低于最低库存</span>
|
||||||
|
<span v-else-if="data.current_amount > 0" class="pill pill-green">库存正常</span>
|
||||||
|
<span v-else class="pill pill-warn">库存为空</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 本系统用量统计 -->
|
||||||
|
<div class="card mt-4 card-pad">
|
||||||
|
<h3 class="section-title">本系统使用统计</h3>
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">累计使用次数</div>
|
||||||
|
<div class="stat-val">{{ data.usage_count || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<div class="stat-label">累计使用量</div>
|
||||||
|
<div class="stat-val">{{ (data.total_amount || 0).toFixed(2) }} {{ data.unit || '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 换算设置 -->
|
||||||
|
<div class="card mt-4 card-pad">
|
||||||
|
<h3 class="section-title">洗车扣减换算 <span class="text-soft sm" style="font-weight:normal">— 例:1 {{ data.consume_unit_name || '加仑' }} = {{ data.qu_factor }} {{ data.unit }}</span></h3>
|
||||||
|
<p class="text-soft sm mb-3">本系统会把「洗车页面输入的量」乘以此系数,转换成 Grocy 库存单位(<code>{{ data.unit || '—' }}</code>)后再扣库存。Grocy 端扣多少、库存就减多少。</p>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">扣减单位 <span class="text-soft sm">(如 加仑/瓶/个)</span></label>
|
||||||
|
<select v-model="conv.consume_unit_id" class="select" @change="onConsumeUnitChange">
|
||||||
|
<option :value="null">— 与库存单位一致 —</option>
|
||||||
|
<option v-for="u in quantityUnits" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">换算系数 <span class="text-soft sm">(1 扣减单位 = ? 库存单位)</span></label>
|
||||||
|
<input v-model.number="conv.qu_factor" type="number" step="0.0001" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="convPreview" class="text-soft sm mt-2">预览:洗车页面输入 <strong>1 {{ convPreview.unitName }}</strong> → Grocy 扣 <strong>{{ convPreview.grams }} {{ data.unit }}</strong></p>
|
||||||
|
<p v-if="convError" class="error mt-2">{{ convError }}</p>
|
||||||
|
<p v-if="convOk" class="text-success sm mt-2">{{ convOk }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button class="btn btn-ghost" @click="resetConv">重置</button>
|
||||||
|
<button class="btn btn-primary" @click="saveConv" :disabled="convBusy">{{ convBusy ? '保存中…' : '保存换算设置' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grocy 高级信息(如果有) -->
|
||||||
|
<div v-if="data.grocy_details" class="card mt-4 card-pad">
|
||||||
|
<h3 class="section-title">Grocy 详细数据</h3>
|
||||||
|
<table class="data">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-soft" style="width:200px">Grocy 库存价值</td><td class="text-brand">¥{{ (data.grocy_details.stock_value || 0).toFixed(2) }}</td></tr>
|
||||||
|
<tr><td class="text-soft">最近采购</td><td>{{ data.grocy_details.last_purchased || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">最近使用</td><td>{{ data.grocy_details.last_used || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">最近进价</td><td>¥{{ formatGrocyPrice(data.grocy_details.last_price) }}</td></tr>
|
||||||
|
<tr><td class="text-soft">平均价</td><td>¥{{ formatGrocyPrice(data.grocy_details.avg_price) }}</td></tr>
|
||||||
|
<tr><td class="text-soft">下次到期</td><td>{{ data.grocy_details.next_due_date || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">已开库存</td><td>{{ data.grocy_details.stock_amount_opened || 0 }} {{ data.unit }}</td></tr>
|
||||||
|
<tr><td class="text-soft">未开库存</td><td>{{ (data.current_amount - (data.grocy_details.stock_amount_opened || 0)) }} {{ data.unit }}</td></tr>
|
||||||
|
<tr v-if="data.grocy_details.average_shelf_life_days"><td class="text-soft">平均保质期</td><td>{{ data.grocy_details.average_shelf_life_days }} 天</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 进销存记录 -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-pad list-head">
|
||||||
|
<h3 class="section-title" style="margin:0">进销存记录</h3>
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button :class="['tab', { active: tab==='local' }]" @click="tab='local'">
|
||||||
|
本系统 <span class="badge">{{ (data.usage_rows || []).length }}</span>
|
||||||
|
</button>
|
||||||
|
<button :class="['tab', { active: tab==='grocy' }]" @click="tab='grocy'" :disabled="data.source !== 'grocy'">
|
||||||
|
Grocy 入库批次
|
||||||
|
<span v-if="data.source !== 'grocy'" class="text-mute sm" style="font-weight:normal">(非 Grocy 来源)</span>
|
||||||
|
<span v-else class="badge">{{ (data.grocy_log || []).length }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 本系统使用记录 -->
|
||||||
|
<table v-if="tab === 'local'" class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>日期</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>用量</th>
|
||||||
|
<th>关联洗车</th>
|
||||||
|
<th>位置</th>
|
||||||
|
<th>同步状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="u in data.usage_rows" :key="u.id">
|
||||||
|
<td>{{ u.usage_date }}</td>
|
||||||
|
<td><span class="pill pill-blue">洗车扣减</span></td>
|
||||||
|
<td><strong>{{ u.amount }}</strong> {{ data.unit }}</td>
|
||||||
|
<td>
|
||||||
|
<router-link v-if="u.wash_id" :to="{ name: 'wash-show', params: { id: u.wash_id } }" class="text-brand">
|
||||||
|
#{{ u.wash_id }} {{ washTypeLabel(u.wash_type) }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else class="text-mute">—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-soft">{{ u.location || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['pill', u.sync_status === 'synced' ? 'pill-green' : u.sync_status === 'failed' ? 'pill-danger' : 'pill-warn']">
|
||||||
|
{{ u.sync_status }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!data.usage_rows?.length">
|
||||||
|
<td colspan="6" class="text-mute" style="text-align:center;padding:24px">本系统暂无使用记录</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Grocy 入库批次 -->
|
||||||
|
<table v-if="tab === 'grocy'" class="data">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>入库日期</th>
|
||||||
|
<th>批次</th>
|
||||||
|
<th>数量</th>
|
||||||
|
<th>价格</th>
|
||||||
|
<th>最佳赏味期</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>备注</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="data.grocy_error">
|
||||||
|
<td colspan="7" class="text-danger" style="padding:12px">拉取失败:{{ data.grocy_error }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="g in data.grocy_log" :key="g.id">
|
||||||
|
<td>{{ g.purchased_date || g.row_created_timestamp }}</td>
|
||||||
|
<td><code>#{{ g.id }}</code></td>
|
||||||
|
<td><strong>{{ g.amount }}</strong> {{ data.unit }}</td>
|
||||||
|
<td class="text-brand">¥{{ formatGrocyPrice(g.price) }}</td>
|
||||||
|
<td class="text-soft">{{ g.best_before_date || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="g.open" class="pill pill-warn">已开</span>
|
||||||
|
<span v-else class="pill pill-green">未开</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-soft sm">{{ g.note || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!data.grocy_error && !data.grocy_log?.length">
|
||||||
|
<td colspan="7" class="text-mute" style="text-align:center;padding:24px">Grocy 暂无入库批次</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, watch, computed } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import * as chemicalsApi from '../api/chemicals';
|
||||||
|
import http from '../api/client';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const data = ref({});
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const tab = ref('local');
|
||||||
|
|
||||||
|
// 采购入库弹窗
|
||||||
|
const showAddStock = ref(false);
|
||||||
|
const stockBusy = ref(false);
|
||||||
|
const stockError = ref('');
|
||||||
|
const stockForm = reactive({
|
||||||
|
amount: 1,
|
||||||
|
price: 0,
|
||||||
|
best_before_date: '',
|
||||||
|
transaction_type: 'purchase',
|
||||||
|
note: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 换算设置
|
||||||
|
const quantityUnits = ref([]);
|
||||||
|
const conv = reactive({ consume_unit_id: null, qu_factor: 1 });
|
||||||
|
const convBusy = ref(false);
|
||||||
|
const convError = ref('');
|
||||||
|
const convOk = ref('');
|
||||||
|
|
||||||
|
const convPreview = computed(() => {
|
||||||
|
const u = quantityUnits.value.find(x => x.id === conv.consume_unit_id);
|
||||||
|
if (!u || !conv.qu_factor || conv.qu_factor === 1) return null;
|
||||||
|
return { unitName: u.name, grams: (1 * Number(conv.qu_factor || 0)).toFixed(2) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const washTypeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||||
|
const formatGrocyPrice = (v) => (v == null || v === '' || v === 0) ? '—' : Number(v).toFixed(2);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.get(route.params.id);
|
||||||
|
data.value = r.data;
|
||||||
|
// 同步到 conv
|
||||||
|
conv.consume_unit_id = r.data.consume_unit_id || null;
|
||||||
|
conv.qu_factor = r.data.qu_factor || 1;
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || e.response?.data?.code || '加载失败';
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQuantityUnits() {
|
||||||
|
try {
|
||||||
|
const r = await http.get('/objects/quantity_units');
|
||||||
|
quantityUnits.value = Array.isArray(r.data) ? r.data : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('load quantity_units failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onConsumeUnitChange() {
|
||||||
|
if (!conv.consume_unit_id) {
|
||||||
|
conv.qu_factor = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 用户改了单位后建议 1,但提醒用户填系数
|
||||||
|
if (conv.qu_factor === 1) conv.qu_factor = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetConv() {
|
||||||
|
conv.consume_unit_id = data.value.consume_unit_id || null;
|
||||||
|
conv.qu_factor = data.value.qu_factor || 1;
|
||||||
|
convError.value = '';
|
||||||
|
convOk.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConv() {
|
||||||
|
convError.value = '';
|
||||||
|
convOk.value = '';
|
||||||
|
convBusy.value = true;
|
||||||
|
try {
|
||||||
|
const u = quantityUnits.value.find(x => x.id === conv.consume_unit_id);
|
||||||
|
const r = await chemicalsApi.update(data.value.grocy_product_id, {
|
||||||
|
qu_factor: conv.qu_factor,
|
||||||
|
consume_unit_id: conv.consume_unit_id,
|
||||||
|
consume_unit_name: u?.name || null,
|
||||||
|
});
|
||||||
|
data.value = r.data;
|
||||||
|
convOk.value = '已保存';
|
||||||
|
setTimeout(() => (convOk.value = ''), 2000);
|
||||||
|
} catch (e) {
|
||||||
|
convError.value = e.response?.data?.message || e.response?.data?.code || '保存失败:' + e.message;
|
||||||
|
} finally { convBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAddStock() {
|
||||||
|
stockError.value = '';
|
||||||
|
stockBusy.value = true;
|
||||||
|
try {
|
||||||
|
await chemicalsApi.addStock(data.value.grocy_product_id, {
|
||||||
|
amount: stockForm.amount,
|
||||||
|
price: stockForm.price,
|
||||||
|
best_before_date: stockForm.best_before_date || null,
|
||||||
|
transaction_type: stockForm.transaction_type,
|
||||||
|
note: stockForm.note || null,
|
||||||
|
});
|
||||||
|
showAddStock.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
stockError.value = e.response?.data?.message || e.response?.data?.code || '入库失败:' + e.message;
|
||||||
|
} finally { stockBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { load(); loadQuantityUnits(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.head-actions { display: flex; gap: 8px; }
|
||||||
|
.row { display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; }
|
||||||
|
.section-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 16px; }
|
||||||
|
.big-num { font-size: 36px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||||
|
.unit { font-size: 18px; color: var(--text-soft); font-weight: 400; }
|
||||||
|
.big-amount { font-size: 20px; font-weight: 600; color: var(--brand); }
|
||||||
|
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.stat { background: var(--bg-soft); border-radius: var(--radius-sm); padding: 14px; }
|
||||||
|
.stat-label { font-size: 12px; color: var(--text-soft); margin-bottom: 4px; }
|
||||||
|
.stat-val { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||||
|
.list-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0; }
|
||||||
|
.tab-bar { display: flex; gap: 4px; }
|
||||||
|
.tab {
|
||||||
|
background: transparent; border: 0; padding: 6px 14px; border-radius: var(--pill);
|
||||||
|
font-size: 13px; color: var(--text-soft); cursor: pointer; transition: all .15s;
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.tab:hover:not(:disabled) { background: var(--bg-soft); color: var(--text); }
|
||||||
|
.tab.active { background: var(--accent); color: #fff; }
|
||||||
|
.tab:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.badge { background: rgba(255,255,255,0.2); padding: 1px 6px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||||
|
.tab:not(.active) .badge { background: var(--bg-soft); color: var(--text-soft); }
|
||||||
|
.sm { font-size: 11px; }
|
||||||
|
.ml-2 { margin-left: 8px; }
|
||||||
|
.mb-3 { margin-bottom: 12px; }
|
||||||
|
.mt-3 { margin-top: 12px; }
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed; inset: 0; background: rgba(15, 34, 51, 0.5);
|
||||||
|
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 100%; max-width: 560px; max-height: 90vh; overflow: auto;
|
||||||
|
}
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.label { display: block; font-size: 12px; font-weight: 600; color: var(--text-soft); margin-bottom: 6px; }
|
||||||
|
.input, .select {
|
||||||
|
width: 100%; padding: 8px 10px; border: 1px solid var(--border, #E5E7EB);
|
||||||
|
border-radius: var(--radius-sm); font: inherit; background: #fff;
|
||||||
|
}
|
||||||
|
.input:focus, .select:focus { outline: 2px solid var(--accent, #00C2A0); outline-offset: -1px; }
|
||||||
|
.text-success { color: var(--accent, #00C2A0); }
|
||||||
|
.mt-2 { margin-top: 8px; }
|
||||||
|
.error { color: var(--danger); background: #FBE3DF; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; margin: 0; }
|
||||||
|
.actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||||
|
|
||||||
|
@media (max-width: 800px) { .row { grid-template-columns: 1fr; } .grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head .actions { display: flex; flex-wrap: wrap; }
|
||||||
|
.head .actions > * { flex: 1; min-width: 80px; justify-content: center; }
|
||||||
|
.stat-num { font-size: 22px; }
|
||||||
|
.modal-mask { align-items: flex-end; padding: 0; }
|
||||||
|
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<h1 class="title">新建汽车用品 → Grocy</h1>
|
||||||
|
<router-link to="/chemicals" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||||||
|
<p class="text-soft sm mb-3">这个产品会直接创建到你的 Grocy 库存系统</p>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">名称 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.name" class="input" required placeholder="如:化学小子柑橘上光洗车液" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">分类</label>
|
||||||
|
<select v-model="form.product_group_id" class="select">
|
||||||
|
<option :value="null">不选</option>
|
||||||
|
<option v-for="g in options.groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">库存单位</label>
|
||||||
|
<select v-model="form.qu_id_stock" class="select" @change="syncPurchaseUnit">
|
||||||
|
<option v-for="u in options.units" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">采购单位</label>
|
||||||
|
<select v-model="form.qu_id_purchase" class="select">
|
||||||
|
<option v-for="u in options.units" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">库位</label>
|
||||||
|
<select v-model="form.location_id" class="select">
|
||||||
|
<option v-for="l in options.locations" :key="l.id" :value="l.id">{{ l.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">最低库存</label>
|
||||||
|
<input v-model.number="form.min_stock_amount" type="number" step="0.01" min="0" class="input" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">默认最佳赏味期(天)</label>
|
||||||
|
<input v-model.number="form.default_best_before_days" type="number" min="0" class="input" placeholder="0" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">采购/库存 换算系数 <span class="text-mute sm">(4.6+)</span></label>
|
||||||
|
<input v-model.number="form.qu_factor_purchase_to_stock" type="number" step="0.01" min="0" class="input" placeholder="1" />
|
||||||
|
<div class="text-mute sm mt-1">采购 1 瓶 = 库存 {{ form.qu_factor_purchase_to_stock || 1 }} 瓶(4.5.x 不支持)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">描述</label>
|
||||||
|
<textarea v-model="form.description" class="textarea" rows="2" placeholder="可选"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||||||
|
<div class="actions mt-6">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '创建中…' : '在 Grocy 创建' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import * as chemicalsApi from '../api/chemicals';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
const router = useRouter();
|
||||||
|
const error = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const options = reactive({ groups: [], units: [], locations: [] });
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
product_group_id: null,
|
||||||
|
qu_id_stock: 1,
|
||||||
|
qu_id_purchase: 1,
|
||||||
|
qu_factor_purchase_to_stock: 1,
|
||||||
|
location_id: 1,
|
||||||
|
shopping_location_id: 1,
|
||||||
|
min_stock_amount: 0,
|
||||||
|
default_best_before_days: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 401 草稿
|
||||||
|
const draft = useFormDraft('chemicals/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) Object.assign(form, restored);
|
||||||
|
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
function syncPurchaseUnit() { form.qu_id_purchase = form.qu_id_stock; }
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// 从 chemicals 列表里提取唯一的 groups / units / locations
|
||||||
|
try {
|
||||||
|
const chs = await chemicalsApi.list();
|
||||||
|
const arr = chs.data || [];
|
||||||
|
const groupSet = new Map();
|
||||||
|
const unitSet = new Map();
|
||||||
|
const locSet = new Map();
|
||||||
|
for (const c of arr) {
|
||||||
|
if (c.product_group_id && c.category) groupSet.set(c.product_group_id, c.category);
|
||||||
|
if (c.qu_id) unitSet.set(c.qu_id, c.unit);
|
||||||
|
if (c.location_id) locSet.set(c.location_id, c.location);
|
||||||
|
}
|
||||||
|
options.groups = Array.from(groupSet, ([id, name]) => ({ id, name })).sort((a, b) => a.id - b.id);
|
||||||
|
options.units = Array.from(unitSet, ([id, name]) => ({ id, name })).sort((a, b) => a.id - b.id);
|
||||||
|
options.locations = Array.from(locSet, ([id, name]) => ({ id, name })).sort((a, b) => a.id - b.id);
|
||||||
|
// 默认选第一个真实存在的 ID(避免 Grocy 字典 id 不从 1 开始)
|
||||||
|
if (options.units.length && (!form.qu_id_stock || form.qu_id_stock === 1)) {
|
||||||
|
form.qu_id_stock = options.units[0].id;
|
||||||
|
form.qu_id_purchase = options.units[0].id;
|
||||||
|
}
|
||||||
|
if (options.locations.length && (!form.location_id || form.location_id === 1)) {
|
||||||
|
form.location_id = options.locations[0].id;
|
||||||
|
form.shopping_location_id = options.locations[0].id;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = '加载选项失败:' + e.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = '';
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.create(form);
|
||||||
|
const id = r.data?.grocy_product_id;
|
||||||
|
draft.clear();
|
||||||
|
router.push({ name: 'chemical-show', params: { id } });
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || e.response?.data?.code || '创建失败:' + e.message;
|
||||||
|
} finally { busy.value = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.form { max-width: 760px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.sm { font-size: 12px; }
|
||||||
|
.mb-3 { margin-bottom: 12px; }
|
||||||
|
.mt-1 { margin-top: 4px; }
|
||||||
|
.mt-3 { margin-top: 12px; }
|
||||||
|
.mt-6 { margin-top: 24px; }
|
||||||
|
.error { color: var(--danger); background: #FBE3DF; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||||
|
.actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head > a, .head > .actions { width: 100%; justify-content: center; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">汽车用品</h1>
|
||||||
|
<p class="subtitle text-soft">
|
||||||
|
共 {{ total }} 项 ·
|
||||||
|
<span v-if="totalValue > 0" class="text-brand">¥{{ totalValue.toFixed(2) }} 库存价值</span>
|
||||||
|
<span v-if="grocyCount" class="ml-3 source-source">
|
||||||
|
<span class="pill pill-blue">{{ grocyCount }} 来自 Grocy</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="lowCount" class="ml-3"><span class="pill pill-danger">{{ lowCount }} 低库存</span></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="load" :disabled="loading">{{ loading ? '刷新中…' : '刷新' }}</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="onSync" :disabled="syncing">
|
||||||
|
{{ syncing ? '同步中…' : '↓ 从 Grocy 同步' }}
|
||||||
|
</button>
|
||||||
|
<router-link to="/chemicals/purchase" class="btn btn-ghost btn-sm">+ 批量采购</router-link>
|
||||||
|
<router-link to="/chemicals/new" class="btn btn-primary">+ 新建</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索框:参照 Grocy 顶部 search bar -->
|
||||||
|
<div class="search-bar card">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="搜索:名称 / 描述 / 分类 / 库位 / 备注... (或粘贴 Grocy product id)"
|
||||||
|
@keydown.enter="onGrocySearch"
|
||||||
|
/>
|
||||||
|
<button v-if="query" class="search-clear" @click="clearQuery" type="button">×</button>
|
||||||
|
<span v-if="query" class="result-count">
|
||||||
|
本地匹配 <strong>{{ filteredRows.length }}</strong> / {{ rows.length }}
|
||||||
|
</span>
|
||||||
|
<button class="search-grocy" :disabled="!query || grocySearching" @click="onGrocySearch" type="button">
|
||||||
|
{{ grocySearching ? '搜索中…' : 'Grocy 全局搜' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grocy 全局搜索结果(仅在搜过之后显示) -->
|
||||||
|
<div v-if="grocyResults" class="grocy-results card mt-3">
|
||||||
|
<div class="results-head">
|
||||||
|
<strong>Grocy 全局搜索</strong> ·
|
||||||
|
<span class="text-mute sm">在 Grocy 库里搜(不依赖本地缓存)</span>
|
||||||
|
<button class="btn-mini" @click="grocyResults = null">关闭</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="grocyResults.error" class="error mt-2">{{ grocyResults.error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<p class="text-mute sm mt-2">Grocy 端匹配 <strong>{{ grocyResults.items.length }}</strong> 条</p>
|
||||||
|
<table v-if="grocyResults.items.length" class="data mt-2">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>名称</th><th>分类</th><th>本地缓存</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="g in grocyResults.items" :key="g.id">
|
||||||
|
<td><code>{{ g.id }}</code></td>
|
||||||
|
<td><strong>{{ g.name }}</strong><span v-if="g.description" class="text-soft sm"> · {{ g.description }}</span></td>
|
||||||
|
<td>{{ groupName(g.product_group_id) }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="g.cached" class="pill pill-green">已同步</span>
|
||||||
|
<span v-else class="pill pill-warn">本地无</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="!g.cached" class="btn btn-primary btn-sm" @click="importOne(g.id)" :disabled="importing === g.id">
|
||||||
|
{{ importing === g.id ? '导入中…' : '导入到本地' }}
|
||||||
|
</button>
|
||||||
|
<button v-else class="btn btn-ghost btn-sm" @click="goDetail(g.id)">查看</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="msg" :class="['msg', msgOk ? 'ok' : 'err']">{{ msg }}</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<MobileCardList
|
||||||
|
:columns="columns"
|
||||||
|
:rows="filteredRows"
|
||||||
|
row-key="grocy_product_id"
|
||||||
|
empty-text="暂无数据"
|
||||||
|
>
|
||||||
|
<template #cell-name="{ row, $index }">
|
||||||
|
<div class="card-clickable" @click="$router.push({ name: 'chemical-show', params: { id: row.grocy_product_id } })">
|
||||||
|
<strong>{{ row.name }}</strong>
|
||||||
|
<span v-if="row.description" class="text-soft desc">{{ row.description }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-source="{ row }">
|
||||||
|
<span v-if="row.source === 'grocy'" class="pill pill-blue">Grocy</span>
|
||||||
|
<span v-else-if="row.source === 'seed'" class="pill pill-gray">演示</span>
|
||||||
|
<span v-else class="pill pill-warn">本地</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-category="{ row }">
|
||||||
|
<span class="pill pill-gray">{{ row.category_display || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-amount="{ row }">
|
||||||
|
<span class="amount">{{ row.current_amount }}</span>
|
||||||
|
<span class="text-soft sm"> {{ row.unit || '' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-value="{ row }">
|
||||||
|
<span class="text-brand">{{ formatValue(row.current_value) }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-min="{ row }">
|
||||||
|
<span v-if="row.min_stock_amount > 0" class="text-mute sm">{{ row.min_stock_amount }} {{ row.unit || '' }}</span>
|
||||||
|
<span v-else class="text-mute">—</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span v-if="row.low_stock" class="pill pill-danger">低库存</span>
|
||||||
|
<span v-else-if="row.current_amount > 0" class="pill pill-green">正常</span>
|
||||||
|
<span v-else class="pill pill-warn">空</span>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import * as chemicalsApi from '../api/chemicals';
|
||||||
|
import { asArray } from '../api/client';
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: '名称', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'source', label: '来源', alwaysShow: true },
|
||||||
|
{ key: 'category', label: '分类' },
|
||||||
|
{ key: 'amount', label: '当前库存', alwaysShow: true },
|
||||||
|
{ key: 'value', label: '价值', alwaysShow: true },
|
||||||
|
{ key: 'min', label: '最低' },
|
||||||
|
{ key: 'status', label: '状态', alwaysShow: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const rows = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
const msg = ref('');
|
||||||
|
const msgOk = ref(true);
|
||||||
|
const query = ref(route.query.q || '');
|
||||||
|
|
||||||
|
// Grocy 全局搜索
|
||||||
|
const grocySearching = ref(false);
|
||||||
|
const grocyResults = ref(null); // { items, error }
|
||||||
|
const importing = ref(null);
|
||||||
|
const groupMap = ref({}); // id → name
|
||||||
|
|
||||||
|
// 注意:不要在 watch(query) 里自动 router.replace。
|
||||||
|
// App.vue 用 :key="route.fullPath",URL 变化会卸载重建整个组件,导致输入框失焦。
|
||||||
|
// 搜索词同步 URL 只在 Enter(onGrocySearch)时做一次,保留可分享/收藏能力。
|
||||||
|
|
||||||
|
const total = computed(() => rows.value.length);
|
||||||
|
const totalValue = computed(() => rows.value.reduce((a, c) => a + (c.current_value || 0), 0));
|
||||||
|
const lowCount = computed(() => rows.value.filter(c => c.low_stock).length);
|
||||||
|
const grocyCount = computed(() => rows.value.filter(c => c.source === 'grocy').length);
|
||||||
|
|
||||||
|
function isHighlight(c) {
|
||||||
|
if (!query.value) return false;
|
||||||
|
const terms = queryTokens(query.value);
|
||||||
|
if (!terms.length) return false;
|
||||||
|
const hay = `${c.name || ''} ${c.description || ''} ${c.category_display || ''} ${c.location || ''} ${c.grocy_product_id || ''}`.toLowerCase();
|
||||||
|
return terms.every(t => hay.includes(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
if (!query.value) return rows.value;
|
||||||
|
const terms = queryTokens(query.value);
|
||||||
|
if (!terms.length) return rows.value;
|
||||||
|
return rows.value.filter(c => {
|
||||||
|
const hay = `${c.name || ''} ${c.description || ''} ${c.category_display || ''} ${c.location || ''} ${c.grocy_product_id || ''}`.toLowerCase();
|
||||||
|
return terms.every(t => hay.includes(t));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 把搜索词按空白拆成多段,全部命中才算匹配(支持 "化学 玻璃" 同时搜两段) */
|
||||||
|
function queryTokens(q) {
|
||||||
|
return q.toLowerCase().split(/\s+/).map(t => t.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearQuery() {
|
||||||
|
query.value = '';
|
||||||
|
grocyResults.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(v) {
|
||||||
|
if (!v || v === 0) return '—';
|
||||||
|
return '¥' + v.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupName(id) {
|
||||||
|
if (id == null) return '—';
|
||||||
|
return groupMap.value[id] || `group-${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.list();
|
||||||
|
rows.value = asArray(r.data, 'chemicals');
|
||||||
|
// 拉 categories 用于 groupName
|
||||||
|
try {
|
||||||
|
const cr = await chemicalsApi.getCategories();
|
||||||
|
const list = Array.isArray(cr.data) ? cr.data : [];
|
||||||
|
for (const c of list) groupMap.value[c.id] = c.is_mapped ? c.name : `group-${c.id}`;
|
||||||
|
} catch {}
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSync() {
|
||||||
|
syncing.value = true;
|
||||||
|
msg.value = '';
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.sync();
|
||||||
|
const d = r.data || {};
|
||||||
|
msgOk.value = true;
|
||||||
|
msg.value = `✓ 同步完成:拉取 ${d.products_total || d.fetched} 条,新增 ${d.inserted},更新 ${d.updated},去激活 ${d.deactivated || 0},价值 ¥${(d.total_value || 0).toFixed(2)}`;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
msgOk.value = false;
|
||||||
|
msg.value = '✗ 同步失败:' + (e.response?.data?.message || e.message);
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
setTimeout(() => { msg.value = ''; }, 8000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onGrocySearch() {
|
||||||
|
if (!query.value.trim()) return;
|
||||||
|
// Enter 时同步一次 URL(不在 watch 里做,避免每次按键都重建组件)
|
||||||
|
const q = query.value.trim();
|
||||||
|
if (route.query.q !== q) {
|
||||||
|
router.replace({ query: { q } });
|
||||||
|
}
|
||||||
|
grocySearching.value = true;
|
||||||
|
grocyResults.value = null;
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.grocySearch(q);
|
||||||
|
const items = r.data?.items || [];
|
||||||
|
// 标记哪些在本地有缓存
|
||||||
|
const cachedIds = new Set(rows.value.map(c => String(c.grocy_product_id)));
|
||||||
|
for (const it of items) it.cached = cachedIds.has(String(it.id));
|
||||||
|
grocyResults.value = { items, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
grocyResults.value = { items: [], error: e.response?.data?.message || e.message };
|
||||||
|
} finally {
|
||||||
|
grocySearching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importOne(id) {
|
||||||
|
importing.value = id;
|
||||||
|
try {
|
||||||
|
await chemicalsApi.sync();
|
||||||
|
await load();
|
||||||
|
// 重新查 grocyResults 把 cached 标记刷新
|
||||||
|
if (grocyResults.value) {
|
||||||
|
const r = await chemicalsApi.grocySearch(query.value);
|
||||||
|
const items = r.data?.items || [];
|
||||||
|
const cachedIds = new Set(rows.value.map(c => String(c.grocy_product_id)));
|
||||||
|
for (const it of items) it.cached = cachedIds.has(String(it.id));
|
||||||
|
grocyResults.value = { items, error: null };
|
||||||
|
}
|
||||||
|
} finally { importing.value = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(id) {
|
||||||
|
router.push({ name: 'chemical-show', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 进入页面:先把本地缓存展示出来,再后台调一次轻量同步(Grocy 里已删的会被标 is_active=0),
|
||||||
|
// 同步成功后静默重拉一次列表,让"已删除于 Grocy"的项从 UI 上消失。失败/超时/未配置 Grocy 都静默。
|
||||||
|
async function refreshFromGrocyInBackground() {
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.refreshIds();
|
||||||
|
const d = r.data || {};
|
||||||
|
if (d.deactivated > 0) {
|
||||||
|
// 有产品被去激活,重拉列表以反映最新状态
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch { /* 静默:未配置 Grocy 或网络抖动都不应影响列表展示 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
load();
|
||||||
|
refreshFromGrocyInBackground();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.actions { display: flex; gap: 8px; }
|
||||||
|
.name-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.desc { font-size: 11px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.amount { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 15px; }
|
||||||
|
.sm { font-size: 12px; }
|
||||||
|
.mb-3 { margin-bottom: 12px; }
|
||||||
|
.mt-2 { margin-top: 8px; }
|
||||||
|
.mt-3 { margin-top: 12px; }
|
||||||
|
.ml-3 { margin-left: 12px; }
|
||||||
|
.msg { padding: 10px 14px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 16px; }
|
||||||
|
.msg.ok { background: #DEF4EC; color: #2E8A6B; }
|
||||||
|
.msg.err { background: #FBE3DF; color: #A33B30; }
|
||||||
|
.clickable tbody tr { cursor: pointer; }
|
||||||
|
.row-low { background: rgba(217, 105, 92, 0.05); }
|
||||||
|
.row-low:hover { background: rgba(217, 105, 92, 0.1) !important; }
|
||||||
|
|
||||||
|
/* 搜索框:仿 Grocy 风格 */
|
||||||
|
.search-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 8px 14px; margin-bottom: 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(40, 80, 110, 0.06);
|
||||||
|
}
|
||||||
|
.search-icon { font-size: 18px; opacity: .55; }
|
||||||
|
.search-input {
|
||||||
|
flex: 1; border: 0; outline: 0; background: transparent;
|
||||||
|
font-size: 15px; padding: 6px 4px; color: var(--text);
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.search-input::placeholder { color: var(--text-mute); }
|
||||||
|
.search-clear {
|
||||||
|
background: transparent; border: 0; cursor: pointer; color: var(--text-mute);
|
||||||
|
font-size: 18px; padding: 0 6px;
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text); }
|
||||||
|
.result-count {
|
||||||
|
color: var(--text-soft); font-size: 12px; padding: 0 8px;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.search-grocy {
|
||||||
|
background: var(--accent); color: #fff; border: 0;
|
||||||
|
padding: 6px 14px; border-radius: var(--radius-sm);
|
||||||
|
font-size: 13px; cursor: pointer; font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.search-grocy:hover:not(:disabled) { background: var(--accent-soft); }
|
||||||
|
.search-grocy:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* 高亮匹配行 */
|
||||||
|
.row-hl { background: rgba(77, 186, 154, 0.08); }
|
||||||
|
.row-hl:hover { background: rgba(77, 186, 154, 0.15) !important; }
|
||||||
|
|
||||||
|
/* Grocy 搜索结果 */
|
||||||
|
.grocy-results { padding: 0; }
|
||||||
|
.results-head {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 10px 16px; background: var(--bg-soft);
|
||||||
|
border-radius: var(--radius) var(--radius) 0 0; border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.results-head strong { font-size: 14px; }
|
||||||
|
.btn-mini {
|
||||||
|
background: transparent; border: 1px solid var(--line); color: var(--text-soft);
|
||||||
|
padding: 2px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; margin-left: auto;
|
||||||
|
}
|
||||||
|
.btn-mini:hover { background: var(--card); }
|
||||||
|
.error { color: var(--danger); font-size: 13px; }
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.actions { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.actions { flex-direction: column; }
|
||||||
|
.actions > * { width: 100%; justify-content: center; }
|
||||||
|
.search-bar { padding: 8px 12px; flex-wrap: wrap; }
|
||||||
|
.search-input { width: 100%; min-width: 0; }
|
||||||
|
.search-grocy { width: 100%; margin-top: 8px; }
|
||||||
|
.result-count { width: 100%; }
|
||||||
|
.card-clickable { cursor: pointer; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="page">
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">概览</h1>
|
||||||
|
<p class="subtitle text-soft">{{ greeting }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<router-link to="/washes/new" class="btn btn-primary">+ 新建洗车记录</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 低库存预警(仅当有产品 low stock) -->
|
||||||
|
<div v-if="lowStock.length" class="low-stock-alert">
|
||||||
|
<div class="alert-head">
|
||||||
|
<span class="alert-icon">⚠️</span>
|
||||||
|
<strong>低库存预警</strong>
|
||||||
|
<span class="alert-count">{{ lowStock.length }} 个 Grocy 产品低于最低库存</span>
|
||||||
|
<router-link to="/chemicals" class="alert-link">查看全部 →</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="alert-list">
|
||||||
|
<router-link
|
||||||
|
v-for="p in lowStock.slice(0, 5)"
|
||||||
|
:key="p.grocy_product_id"
|
||||||
|
:to="{ name: 'chemical-show', params: { id: p.grocy_product_id } }"
|
||||||
|
class="alert-item"
|
||||||
|
>
|
||||||
|
<span class="item-name">{{ p.name }}</span>
|
||||||
|
<span class="item-meta">
|
||||||
|
当前 <strong class="text-danger">{{ p.current_amount }}</strong> / 最低 {{ p.min_stock_amount }} {{ p.unit || '' }}
|
||||||
|
</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI 卡片 -->
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<StatCard title="本月洗车次数" :value="kpi.washes" :hint="kpi.washesHint" :trend="kpi.washesTrend" />
|
||||||
|
<StatCard title="本月花费" :value="'¥ ' + kpi.cost" :hint="kpi.costHint" :trend="kpi.costTrend" />
|
||||||
|
<StatCard title="平均间隔" :value="kpi.interval + ' 天'" hint="两次洗车之间" />
|
||||||
|
<StatCard title="使用车辆" :value="kpi.vehicles" :hint="kpi.vehiclesHint" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图表 -->
|
||||||
|
<div class="row mt-6">
|
||||||
|
<div class="card card-pad chart-card">
|
||||||
|
<h3 class="chart-title">最近 30 天洗车频次</h3>
|
||||||
|
<div class="chart-wrap"><ChartBlock type="bar" :data="freqData" :options="freqOptions" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad chart-card">
|
||||||
|
<h3 class="chart-title">洗车类型分布</h3>
|
||||||
|
<div class="chart-wrap"><ChartBlock type="doughnut" :data="typeData" :options="typeOptions" /></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 今日天气 -->
|
||||||
|
<div class="card card-pad mt-6">
|
||||||
|
<div class="weather-head">
|
||||||
|
<h3 class="chart-title" style="margin:0">今日天气 · {{ weather.city || cfg.app.city || 'Beijing' }}</h3>
|
||||||
|
<span class="pill pill-blue">{{ weather.provider || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!weather.fetched_at" class="text-mute mt-2" style="font-size:13px">
|
||||||
|
尚未拉取。配置天气 API key 后在「设置」页点「拉取今日天气」或运行 <code>npm run weather</code>。
|
||||||
|
</div>
|
||||||
|
<div v-else class="weather-grid">
|
||||||
|
<div class="metric"><div class="m-value">{{ weather.temp_c }}℃</div><div class="m-label">气温</div></div>
|
||||||
|
<div class="metric"><div class="m-value">{{ weather.weather_desc }}</div><div class="m-label">天气</div></div>
|
||||||
|
<div class="metric"><div class="m-value">{{ weather.humidity }}%</div><div class="m-label">湿度</div></div>
|
||||||
|
<div class="metric"><div class="m-value">{{ weather.wind_kph }}</div><div class="m-label">风速 km/h</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近洗车 -->
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="card-pad list-head">
|
||||||
|
<h3 class="chart-title" style="margin:0">最近洗车</h3>
|
||||||
|
<router-link to="/washes" class="text-brand" style="font-size:13px">查看全部 →</router-link>
|
||||||
|
</div>
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr><th>日期</th><th>类型</th><th>车辆</th><th>位置</th><th>花费</th><th>天气</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in recent" :key="r.id" class="row-link" @click="goTo(r.id)">
|
||||||
|
<td>{{ r.wash_date }}</td>
|
||||||
|
<td><span class="pill" :class="typePill(r.wash_type)">{{ washTypeLabel(r.wash_type) }}</span></td>
|
||||||
|
<td>{{ r.vehicle_name || '—' }}</td>
|
||||||
|
<td>{{ r.location || '—' }}</td>
|
||||||
|
<td>¥ {{ r.cost }}</td>
|
||||||
|
<td class="text-soft">{{ r.weather_desc || '—' }} · {{ r.temp_c ?? '—' }}℃</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!recent.length"><td colspan="6" class="text-mute" style="text-align:center; padding:24px">暂无记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 最近用车记录(保养/加油/充电) -->
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="card-pad list-head">
|
||||||
|
<h3 class="chart-title" style="margin:0">最近用车记录</h3>
|
||||||
|
<span class="text-soft sm">保养 · 加油 · 充电</span>
|
||||||
|
</div>
|
||||||
|
<table class="data">
|
||||||
|
<thead>
|
||||||
|
<tr><th>日期</th><th>类型</th><th>车辆</th><th>概要</th><th class="r">花费</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in recentLogs" :key="r.type + r.id" class="row-link" @click="goLog(r)">
|
||||||
|
<td>{{ r.date }}</td>
|
||||||
|
<td><span class="pill" :class="logPill(r.type)">{{ logTypeLabel(r.type) }}</span></td>
|
||||||
|
<td>{{ r.vehicle_name || '—' }}</td>
|
||||||
|
<td class="text-soft sm">{{ r.summary }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ (r.cost || 0).toFixed(2) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!recentLogs.length"><td colspan="5" class="text-mute" style="text-align:center; padding:24px">还没记录,去 <router-link to="/maintenances">保养</router-link> / <router-link to="/refuels">加油</router-link> / <router-link to="/chargings">充电</router-link> 添加</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, reactive, computed, nextTick } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import StatCard from '../components/StatCard.vue';
|
||||||
|
import ChartBlock from '../components/ChartBlock.vue';
|
||||||
|
import * as settingsApi from '../api/settings';
|
||||||
|
import * as washesApi from '../api/washes';
|
||||||
|
import { maintApi, refuelApi, chargingApi } from '../api/logs';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const overview = ref({});
|
||||||
|
const recent = ref([]);
|
||||||
|
const recentLogs = ref([]);
|
||||||
|
const weather = ref({});
|
||||||
|
const cfg = ref({ app: { city: 'Beijing' } });
|
||||||
|
const lowStock = ref([]);
|
||||||
|
|
||||||
|
// 图表数据:响应式,ChartBlock 内部监听更新
|
||||||
|
const freqData = ref({ labels: [], datasets: [{ data: [], backgroundColor: '#7CD0B5', borderRadius: 6, barThickness: 12 }] });
|
||||||
|
const freqOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: true } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 0 } },
|
||||||
|
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } }, grid: { color: '#E1ECF2' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const typeData = ref({ labels: ['暂无数据'], datasets: [{ data: [1], backgroundColor: ['#E1ECF2'], borderWidth: 0 }] });
|
||||||
|
const typeOptions = ref({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right', labels: { font: { size: 12 }, usePointStyle: true, boxWidth: 8 } },
|
||||||
|
tooltip: { enabled: true },
|
||||||
|
},
|
||||||
|
cutout: '65%',
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpi = reactive({
|
||||||
|
washes: 0, washesHint: '', washesTrend: 'neutral',
|
||||||
|
cost: 0, costHint: '', costTrend: 'neutral',
|
||||||
|
interval: '—',
|
||||||
|
vehicles: 0, vehiclesHint: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const greeting = computed(() => {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
const t = h < 6 ? '凌晨好' : h < 12 ? '早上好' : h < 18 ? '下午好' : '晚上好';
|
||||||
|
return `${t},${auth.user?.username || ''}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeMap = { quick: '快速', full: '标准', detail: '精洗', other: '其他' };
|
||||||
|
const washTypeLabel = (t) => typeMap[t] || t || '—';
|
||||||
|
const typePill = (t) => ({ quick: 'pill-blue', full: 'pill-green', detail: 'pill-warn' }[t] || 'pill-gray');
|
||||||
|
|
||||||
|
function goTo(id) { router.push({ name: 'wash-show', params: { id } }); }
|
||||||
|
function goLog(r) {
|
||||||
|
if (r.type === 'maint') router.push('/maintenances');
|
||||||
|
else if (r.type === 'refuel') router.push('/refuels');
|
||||||
|
else if (r.type === 'charge') router.push('/chargings');
|
||||||
|
}
|
||||||
|
const logTypeLabel = (t) => ({ maint: '保养', refuel: '加油', charge: '充电' }[t] || t);
|
||||||
|
const logPill = (t) => ({ maint: 'pill-warn', refuel: 'pill-blue', charge: 'pill-green' }[t] || 'pill-gray');
|
||||||
|
function buildLogSummary(r) {
|
||||||
|
if (r.type === 'maint') {
|
||||||
|
const items = (r.items || []).slice(0, 3).map(x => x.name).filter(Boolean).join('、');
|
||||||
|
return items ? `项目:${items}` : (r.shop ? `店:${r.shop}` : '保养');
|
||||||
|
}
|
||||||
|
if (r.type === 'refuel') {
|
||||||
|
const tag = r.is_full ? '加满' : '补油';
|
||||||
|
return `${r.liters}L ${r.fuel_type || ''} ${tag} · ${r.station || '—'}`;
|
||||||
|
}
|
||||||
|
if (r.type === 'charge') {
|
||||||
|
const soc = r.start_soc != null && r.end_soc != null ? ` ${r.start_soc}→${r.end_soc}%` : '';
|
||||||
|
return `${r.kwh} kWh${soc} · ${r.station || r.charge_type || '—'}`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(freq, type) {
|
||||||
|
const hasFreq = freq && freq.some(d => d.count > 0);
|
||||||
|
const hasType = type && type.length > 0;
|
||||||
|
freqData.value = {
|
||||||
|
labels: freq ? freq.map(d => d.date) : [],
|
||||||
|
datasets: [{ data: freq ? freq.map(d => d.count) : [], backgroundColor: '#7CD0B5', borderRadius: 6, barThickness: 12 }],
|
||||||
|
};
|
||||||
|
freqOptions.value = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: hasFreq } },
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 0 } },
|
||||||
|
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } }, grid: { color: '#E1ECF2' } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
typeData.value = {
|
||||||
|
labels: hasType ? type.map(d => washTypeLabel(d.type)) : ['暂无数据'],
|
||||||
|
datasets: [{
|
||||||
|
data: hasType ? type.map(d => d.count) : [1],
|
||||||
|
backgroundColor: hasType ? ['#1E5B8A', '#4DBA9A', '#E8A33D', '#8A9CAB'] : ['#E1ECF2'],
|
||||||
|
borderWidth: 0,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
typeOptions.value = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: 'right', labels: { font: { size: 12 }, usePointStyle: true, boxWidth: 8 } },
|
||||||
|
tooltip: { enabled: hasType },
|
||||||
|
},
|
||||||
|
cutout: '65%',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [ov, ex, list, mR, rR, cR] = await Promise.all([
|
||||||
|
settingsApi.overview(),
|
||||||
|
settingsApi.dashboardExtra(),
|
||||||
|
washesApi.list({ limit: 6 }),
|
||||||
|
maintApi.list({ limit: 3 }),
|
||||||
|
refuelApi.list({ limit: 3 }),
|
||||||
|
chargingApi.list({ limit: 3 }),
|
||||||
|
]);
|
||||||
|
overview.value = ov.data?.overview || {};
|
||||||
|
lowStock.value = ov.data?.low_stock_products || [];
|
||||||
|
weather.value = ex.data?.weather || {};
|
||||||
|
cfg.value = ex.data?.config || cfg.value;
|
||||||
|
const recentRows = list.data?.rows || [];
|
||||||
|
recent.value = recentRows;
|
||||||
|
// 合并三类最近记录
|
||||||
|
const merged = [];
|
||||||
|
for (const r of (mR.data?.rows || [])) merged.push({ type: 'maint', id: r.id, date: r.maint_date, cost: r.total_cost, vehicle_name: r.vehicle_name, items: r.items, shop: r.shop });
|
||||||
|
for (const r of (rR.data?.rows || [])) merged.push({ type: 'refuel', id: r.id, date: r.refuel_date, cost: r.total_cost, vehicle_name: r.vehicle_name, liters: r.liters, fuel_type: r.fuel_type, is_full: r.is_full, station: r.station });
|
||||||
|
for (const r of (cR.data?.rows || [])) merged.push({ type: 'charge', id: r.id, date: r.charge_date, cost: r.total_cost, vehicle_name: r.vehicle_name, kwh: r.kwh, start_soc: r.start_soc, end_soc: r.end_soc, station: r.station, charge_type: r.charge_type });
|
||||||
|
merged.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
|
||||||
|
recentLogs.value = merged.slice(0, 6).map(r => ({ ...r, summary: buildLogSummary(r) }));
|
||||||
|
// KPI
|
||||||
|
kpi.washes = overview.value.washes_this_month || 0;
|
||||||
|
kpi.washesHint = overview.value.washes_change != null
|
||||||
|
? `${overview.value.washes_change > 0 ? '+' : ''}${overview.value.washes_change}% 较上月`
|
||||||
|
: '本月初至今日';
|
||||||
|
kpi.washesTrend = (overview.value.washes_change || 0) > 0 ? 'up' : (overview.value.washes_change < 0 ? 'down' : 'neutral');
|
||||||
|
kpi.cost = (overview.value.cost_this_month || 0).toFixed(2);
|
||||||
|
kpi.costHint = overview.value.cost_change != null
|
||||||
|
? `${overview.value.cost_change > 0 ? '+' : ''}${overview.value.cost_change}% 较上月`
|
||||||
|
: '本月累计';
|
||||||
|
kpi.costTrend = (overview.value.cost_change || 0) > 0 ? 'up' : (overview.value.cost_change < 0) < 0 ? 'down' : 'neutral';
|
||||||
|
kpi.interval = overview.value.avg_interval_days || '—';
|
||||||
|
kpi.vehicles = overview.value.active_vehicles || 0;
|
||||||
|
kpi.vehiclesHint = `${overview.value.total_vehicles || 0} 辆总数`;
|
||||||
|
|
||||||
|
// 关键顺序:先 loading=false 让 chart-card 进入 DOM,再 nextTick 等挂载完成,
|
||||||
|
// 然后才能画图。之前在 loading=false 之前 await nextTick 是无效的。
|
||||||
|
loading.value = false;
|
||||||
|
await nextTick();
|
||||||
|
buildChartData(ov.data?.freq_30d || [], ov.data?.type_dist || []);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { display: flex; flex-direction: column; gap: 0; }
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||||
|
.row { display: grid; grid-template-columns: 1.4fr 1fr; gap: 18px; }
|
||||||
|
.chart-card { display: flex; flex-direction: column; }
|
||||||
|
.chart-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 16px; }
|
||||||
|
.chart-wrap { position: relative; height: 180px; width: 100%; }
|
||||||
|
.list-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0; }
|
||||||
|
.row-link { cursor: pointer; }
|
||||||
|
.weather-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.weather-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 16px; }
|
||||||
|
.metric { background: var(--bg-soft); border-radius: 10px; padding: 14px; text-align: center; }
|
||||||
|
.m-value { font-size: 22px; font-weight: 600; }
|
||||||
|
.m-label { font-size: 12px; color: var(--text-soft); margin-top: 4px; }
|
||||||
|
|
||||||
|
/* 低库存预警红条 */
|
||||||
|
.low-stock-alert {
|
||||||
|
background: #FEF2F2;
|
||||||
|
border: 1px solid #FECACA;
|
||||||
|
border-left: 4px solid var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 14px 18px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.alert-head { display: flex; align-items: center; gap: 10px; font-size: 14px; }
|
||||||
|
.alert-icon { font-size: 18px; }
|
||||||
|
.alert-count { color: var(--text-soft); font-size: 13px; }
|
||||||
|
.alert-link { margin-left: auto; color: var(--danger); font-size: 13px; font-weight: 500; }
|
||||||
|
.alert-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 10px; }
|
||||||
|
.alert-item {
|
||||||
|
background: #fff; border: 1px solid #FEE2E2; border-radius: var(--radius-sm);
|
||||||
|
padding: 8px 12px; display: flex; justify-content: space-between; align-items: center;
|
||||||
|
font-size: 13px; transition: all .15s;
|
||||||
|
}
|
||||||
|
.alert-item:hover { background: #FFF1F2; border-color: #FCA5A5; }
|
||||||
|
.item-name { font-weight: 500; }
|
||||||
|
.item-meta { color: var(--text-soft); font-size: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
.weather-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.alert-list { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head .btn { width: 100%; justify-content: center; }
|
||||||
|
.kpi-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.chart-wrap { height: 160px; }
|
||||||
|
.low-stock-alert { padding: 12px 16px; }
|
||||||
|
.alert-head { flex-wrap: wrap; gap: 6px; }
|
||||||
|
.alert-count { width: 100%; }
|
||||||
|
.alert-link { margin-left: 0; }
|
||||||
|
.list-head { flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.kpi-grid { grid-template-columns: 1fr; }
|
||||||
|
.weather-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">保险记录</h1>
|
||||||
|
<p class="subtitle text-soft">交强 / 商业 / 三责 / 车损… 保单附件图片/PDF 直接在线看</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="openNew">+ 新建保单</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- KPI 卡片 -->
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<div class="kpi-card kpi-blue">
|
||||||
|
<div class="kpi-label">总保单</div>
|
||||||
|
<div class="kpi-val">{{ data.stats?.total || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card kpi-green">
|
||||||
|
<div class="kpi-label">有效中</div>
|
||||||
|
<div class="kpi-val">{{ data.stats?.active_count || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card kpi-warn">
|
||||||
|
<div class="kpi-label">30 天内到期</div>
|
||||||
|
<div class="kpi-val">{{ data.stats?.expiring_count || 0 }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card kpi-gray">
|
||||||
|
<div class="kpi-label">累计保费</div>
|
||||||
|
<div class="kpi-val">¥{{ (data.stats?.total_premium || 0).toFixed(0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过滤 -->
|
||||||
|
<div class="card card-pad filters mt-3">
|
||||||
|
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||||
|
<option value="">全部车辆</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
<select v-model="filters.status" class="select sm" @change="load">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="active">有效</option>
|
||||||
|
<option value="expiring">30 天内到期</option>
|
||||||
|
<option value="expired">已过期</option>
|
||||||
|
</select>
|
||||||
|
<div class="stats-pills">
|
||||||
|
<span class="pill pill-blue">展示 {{ data.rows?.length || 0 }} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有保单记录</div>
|
||||||
|
<MobileCardList
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:rows="data.rows"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="还没有保单记录"
|
||||||
|
>
|
||||||
|
<template #cell-status="{ row }">
|
||||||
|
<span :class="['pill', statusPill(row.status)]">
|
||||||
|
{{ statusLabel(row.status) }}
|
||||||
|
<span v-if="row.status === 'expiring'" class="sm">· {{ row.days_to_expire }}d</span>
|
||||||
|
<span v-else-if="row.status === 'expired'" class="sm">· {{ -row.days_to_expire }}d</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-type="{ row }"><strong>{{ row.insurance_type }}</strong></template>
|
||||||
|
<template #cell-vehicle="{ row }">
|
||||||
|
<div>{{ row.vehicle_name }}</div>
|
||||||
|
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-company="{ row }">{{ row.company || '—' }}</template>
|
||||||
|
<template #cell-policy="{ row }" class="sm">{{ row.policy_no || '—' }}</template>
|
||||||
|
<template #cell-period="{ row }">
|
||||||
|
<div>{{ row.start_date }}</div>
|
||||||
|
<div class="text-soft sm">→ {{ row.end_date }}</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-premium="{ row }">
|
||||||
|
<strong class="text-brand">¥{{ (row.premium || 0).toFixed(0) }}</strong>
|
||||||
|
</template>
|
||||||
|
<template #cell-attachment="{ row }">
|
||||||
|
<span v-if="row.attachment_path">
|
||||||
|
<a :href="`/api/${row.attachment_path}`" target="_blank" class="btn btn-ghost btn-sm" @click.stop>查看</a>
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-mute sm">无</span>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" @click.stop="openUpload(row)">上传</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="showDelete"
|
||||||
|
v-model="showDelete"
|
||||||
|
title="删除保单"
|
||||||
|
:message="`确认删除「${deleteTarget?.insurance_type}」保单(${deleteTarget?.start_date} → ${deleteTarget?.end_date})?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
:tips="['已删除保单可在「操作日志」中恢复']"
|
||||||
|
:busy="deleteBusy"
|
||||||
|
:error="deleteError"
|
||||||
|
@confirm="doDelete"
|
||||||
|
@cancel="showDelete = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 表单弹窗 -->
|
||||||
|
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||||
|
<div class="modal card card-pad">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 class="section-title">{{ form.id ? '编辑保单' : '新建保单' }}</h3>
|
||||||
|
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别保单' }}</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSave">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.vehicle_id" class="select" required>
|
||||||
|
<option :value="null">— 请选择 —</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">险种 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.insurance_type" class="select" required>
|
||||||
|
<option v-for="t in data.types || TYPES" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">保险公司</label>
|
||||||
|
<input v-model="form.company" class="input" placeholder="人保 / 平安 / 太保 / 中华 / …" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">保单号</label>
|
||||||
|
<input v-model="form.policy_no" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">生效日 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.start_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">到期日 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.end_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">保费 (¥)</label>
|
||||||
|
<input v-model.number="form.premium" type="number" step="0.01" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">保额 (¥)</label>
|
||||||
|
<input v-model.number="form.coverage_amount" type="number" step="0.01" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传弹窗 -->
|
||||||
|
<div v-if="showUpload" class="modal-mask" @click.self="showUpload = false">
|
||||||
|
<div class="modal card card-pad">
|
||||||
|
<h3 class="section-title">上传保单附件</h3>
|
||||||
|
<p class="text-soft sm mb-3">支持图片 (jpg/png/webp/heic) 和 PDF,最大 10MB</p>
|
||||||
|
<form @submit.prevent="onUpload">
|
||||||
|
<div class="upload-zone" :class="{ dragover: dragOver }" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="onDrop">
|
||||||
|
<input ref="fileInput" type="file" accept="image/*,application/pdf" @change="onFileChange" style="display:none" />
|
||||||
|
<div v-if="!formFile" class="upload-empty" @click="fileInput.click()">
|
||||||
|
<div class="upload-icon">📎</div>
|
||||||
|
<div>点击或拖拽文件到此处</div>
|
||||||
|
<div class="text-soft sm">jpg / png / webp / heic / pdf</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-filled">
|
||||||
|
<div class="upload-filename">{{ formFile.name }}</div>
|
||||||
|
<div class="text-soft sm">{{ formatSize(formFile.size) }} · {{ formFile.type || 'unknown' }}</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm mt-2" @click="formFile = null; fileInput.value = ''">换文件</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadError" class="error mt-3">{{ uploadError }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="showUpload = false">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="uploadBusy || !formFile">{{ uploadBusy ? '上传中…' : '上传' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import * as insuranceApi from '../api/insurance';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
|
||||||
|
const TYPES = ['交强险', '商业险', '车损险', '三责险', '座位险', '不计免赔', '玻璃险', '划痕险', '自燃险', '涉水险'];
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'status', label: '状态', alwaysShow: true },
|
||||||
|
{ key: 'type', label: '险种', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||||
|
{ key: 'company', label: '保险公司' },
|
||||||
|
{ key: 'policy', label: '保单号' },
|
||||||
|
{ key: 'period', label: '生效 → 到期', alwaysShow: true },
|
||||||
|
{ key: 'premium', label: '保费', alwaysShow: true },
|
||||||
|
{ key: 'attachment', label: '附件' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const data = ref({ rows: [], total: 0, stats: {}, types: TYPES });
|
||||||
|
const loading = ref(false);
|
||||||
|
const filters = reactive({ vehicle_id: '', status: '' });
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const form = reactive({ id: null, vehicle_id: null, insurance_type: '交强险', company: '', policy_no: '', start_date: '', end_date: '', premium: null, coverage_amount: null, notes: '' });
|
||||||
|
const formBusy = ref(false);
|
||||||
|
const formError = ref('');
|
||||||
|
|
||||||
|
// 401 草稿
|
||||||
|
const draft = useFormDraft('insurances/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) Object.assign(form, restored);
|
||||||
|
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
// AI 识别
|
||||||
|
const ai = useAiRecognize();
|
||||||
|
const aiBusy = ai.busy;
|
||||||
|
async function onAiRecognize() {
|
||||||
|
await ai.open('insurance', (data) => {
|
||||||
|
if (data.insurance_type) form.insurance_type = data.insurance_type;
|
||||||
|
if (data.company) form.company = data.company;
|
||||||
|
if (data.policy_no) form.policy_no = data.policy_no;
|
||||||
|
if (data.start_date) form.start_date = data.start_date;
|
||||||
|
if (data.end_date) form.end_date = data.end_date;
|
||||||
|
if (data.premium != null) form.premium = data.premium;
|
||||||
|
if (data.coverage_amount != null) form.coverage_amount = data.coverage_amount;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const showUpload = ref(false);
|
||||||
|
const formFile = ref(null);
|
||||||
|
const fileInput = ref(null);
|
||||||
|
const uploadTargetId = ref(null);
|
||||||
|
const uploadBusy = ref(false);
|
||||||
|
const uploadError = ref('');
|
||||||
|
const dragOver = ref(false);
|
||||||
|
|
||||||
|
const statusLabel = (s) => ({ active: '有效', expiring: '即将到期', expired: '已过期' }[s] || s);
|
||||||
|
const statusPill = (s) => ({ active: 'pill-green', expiring: 'pill-warn', expired: 'pill-gray' }[s] || 'pill-gray');
|
||||||
|
const formatSize = (b) => {
|
||||||
|
if (!b) return '';
|
||||||
|
if (b < 1024) return b + ' B';
|
||||||
|
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
||||||
|
return (b / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||||
|
if (filters.status) params.status = filters.status;
|
||||||
|
const r = await insuranceApi.list(params);
|
||||||
|
data.value = r.data;
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVehicles() {
|
||||||
|
const r = await vehiclesApi.list();
|
||||||
|
vehicles.value = r.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
Object.assign(form, { id: null, vehicle_id: null, insurance_type: '交强险', company: '', policy_no: '', start_date: today(), end_date: '', premium: null, coverage_amount: null, notes: '' });
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r) {
|
||||||
|
Object.assign(form, r);
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() { showForm.value = false; }
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
formError.value = '';
|
||||||
|
formBusy.value = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
vehicle_id: form.vehicle_id,
|
||||||
|
insurance_type: form.insurance_type,
|
||||||
|
company: form.company || null,
|
||||||
|
policy_no: form.policy_no || null,
|
||||||
|
start_date: form.start_date,
|
||||||
|
end_date: form.end_date,
|
||||||
|
premium: form.premium ?? null,
|
||||||
|
coverage_amount: form.coverage_amount ?? null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
if (form.id) await insuranceApi.update(form.id, body);
|
||||||
|
else await insuranceApi.create(body);
|
||||||
|
draft.clear();
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
const errs = e.response?.data?.error?.errors;
|
||||||
|
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||||
|
} finally { formBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUpload(r) {
|
||||||
|
uploadTargetId.value = r.id;
|
||||||
|
formFile.value = null;
|
||||||
|
uploadError.value = '';
|
||||||
|
showUpload.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e) { formFile.value = e.target.files[0] || null; }
|
||||||
|
function onDrop(e) {
|
||||||
|
dragOver.value = false;
|
||||||
|
const f = e.dataTransfer.files[0];
|
||||||
|
if (f) formFile.value = f;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload() {
|
||||||
|
if (!formFile.value) return;
|
||||||
|
uploadError.value = '';
|
||||||
|
uploadBusy.value = true;
|
||||||
|
try {
|
||||||
|
await insuranceApi.upload(uploadTargetId.value, formFile.value);
|
||||||
|
showUpload.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
uploadError.value = e.response?.data?.error?.message || e.message || '上传失败';
|
||||||
|
} finally { uploadBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(r) {
|
||||||
|
deleteTarget.value = r;
|
||||||
|
deleteError.value = '';
|
||||||
|
showDelete.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDelete = ref(false);
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteBusy = ref(false);
|
||||||
|
const deleteError = ref('');
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget.value) return;
|
||||||
|
deleteBusy.value = true;
|
||||||
|
deleteError.value = '';
|
||||||
|
try {
|
||||||
|
await insuranceApi.remove(deleteTarget.value.id);
|
||||||
|
showDelete.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||||
|
} finally {
|
||||||
|
deleteBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
|
||||||
|
onMounted(() => { loadVehicles(); load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; }
|
||||||
|
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||||
|
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||||
|
|
||||||
|
.kpi-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
||||||
|
.kpi-card { background:var(--card); border-radius:var(--radius); padding:16px 20px; }
|
||||||
|
.kpi-label { font-size:12px; color:var(--text-soft); }
|
||||||
|
.kpi-val { font-size:28px; font-weight:700; letter-spacing:-0.02em; font-variant-numeric:tabular-nums; margin-top:4px; }
|
||||||
|
.kpi-blue .kpi-val { color:#1E5B8A; }
|
||||||
|
.kpi-green .kpi-val { color:#10B981; }
|
||||||
|
.kpi-warn .kpi-val { color:#E8A33D; }
|
||||||
|
.kpi-gray .kpi-val { color:var(--text-soft); }
|
||||||
|
|
||||||
|
.filters { display:flex; gap:10px; align-items:center; }
|
||||||
|
.stats-pills { margin-left:auto; display:flex; gap:8px; }
|
||||||
|
|
||||||
|
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||||
|
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||||
|
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||||
|
.modal-head .section-title { margin:0; }
|
||||||
|
.grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||||
|
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||||
|
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||||
|
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||||
|
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||||
|
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||||
|
.r { text-align:right; }
|
||||||
|
.mt-2 { margin-top:8px; }
|
||||||
|
.mt-3 { margin-top:12px; }
|
||||||
|
.text-soft { color:var(--text-soft); }
|
||||||
|
.text-mute { color:var(--text-mute); }
|
||||||
|
.text-danger { color:var(--danger); }
|
||||||
|
.text-brand { color:var(--brand); }
|
||||||
|
.sm { font-size:11px; }
|
||||||
|
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||||
|
|
||||||
|
.upload-zone { border:2px dashed var(--border,#E5E7EB); border-radius:var(--radius); padding:32px; text-align:center; transition:all .15s; cursor:pointer; }
|
||||||
|
.upload-zone.dragover { border-color:var(--accent); background:rgba(0,194,160,0.05); }
|
||||||
|
.upload-empty { display:flex; flex-direction:column; gap:6px; align-items:center; color:var(--text-soft); }
|
||||||
|
.upload-icon { font-size:36px; }
|
||||||
|
.upload-filled { display:flex; flex-direction:column; gap:4px; align-items:center; }
|
||||||
|
.upload-filename { font-weight:600; word-break:break-all; }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.kpi-grid { grid-template-columns:repeat(2,1fr); }
|
||||||
|
.grid { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.head { flex-direction: column; align-items: stretch; }
|
||||||
|
.head .btn { width: 100%; justify-content: center; }
|
||||||
|
.filters { padding: 12px 16px; }
|
||||||
|
.stats-pills { width: 100%; margin-left: 0; }
|
||||||
|
.modal-mask { align-items: flex-end; padding: 0; }
|
||||||
|
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.kpi-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.kpi-val { font-size: 22px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="card login-card">
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo">CL</span>
|
||||||
|
<span class="brand-name">CarLog</span>
|
||||||
|
<span class="brand-sub">车记</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="title">欢迎回来</h1>
|
||||||
|
<p class="subtitle">{{ subtitleText }}</p>
|
||||||
|
|
||||||
|
<form @submit.prevent="onSubmit" class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="login-username">用户名</label>
|
||||||
|
<input
|
||||||
|
id="login-username"
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
class="input"
|
||||||
|
autocomplete="username"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label class="label" for="login-password">密码</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
class="input"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error">
|
||||||
|
<span class="error-msg">{{ error.message }}</span>
|
||||||
|
<span v-if="error.locked" class="error-meta">
|
||||||
|
⏱ 锁定至 <strong>{{ formatTime(error.lockedUntil) }}</strong>,还有 <strong>{{ formatRemain(error.retryAfter) }}</strong> 解锁
|
||||||
|
</span>
|
||||||
|
<span v-else-if="error.failCount > 0" class="error-meta">
|
||||||
|
⚠️ 已错 <strong>{{ error.failCount }}</strong> 次(上限 {{ error.failMax }}),
|
||||||
|
还剩 <strong>{{ error.failRemaining }}</strong> 次,再错将锁定 <strong>{{ error.lockMinutes }}</strong> 分钟
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-primary submit" :disabled="busy">
|
||||||
|
{{ busy ? '登录中…' : '登录' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="hint">首次使用?默认账号 <code>admin</code> / 密码 <code>carwash2026</code>,登录后请到「设置 → 账户」修改。</p>
|
||||||
|
</div>
|
||||||
|
<div class="deco">
|
||||||
|
<div class="bubble b1"></div>
|
||||||
|
<div class="bubble b2"></div>
|
||||||
|
<div class="bubble b3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRouter, useRoute } from 'vue-router';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
|
const route = useRoute();
|
||||||
|
const username = ref('admin');
|
||||||
|
const password = ref('');
|
||||||
|
const error = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
// 401 重定向过来会带 ?reason=expired&redirect=/原页
|
||||||
|
const subtitleText = computed(() => {
|
||||||
|
if (route.query.reason === 'expired') return '登录已过期,重新登录后会自动回到原页面';
|
||||||
|
return '登录管理你的爱车';
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = null;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await auth.login(username.value, password.value);
|
||||||
|
const next = route.query.redirect || '/';
|
||||||
|
router.push(next);
|
||||||
|
} catch (e) {
|
||||||
|
const r = e.response?.data?.error || {};
|
||||||
|
const code = r.code;
|
||||||
|
if (code === 'LOCKED') {
|
||||||
|
error.value = {
|
||||||
|
message: r.message || '登录失败次数过多,账号已锁定',
|
||||||
|
locked: true,
|
||||||
|
lockedUntil: r.locked_until,
|
||||||
|
retryAfter: r.retry_after,
|
||||||
|
};
|
||||||
|
} else if (code === 'BAD_CREDENTIALS') {
|
||||||
|
error.value = {
|
||||||
|
message: r.message || '用户名或密码错误',
|
||||||
|
locked: false,
|
||||||
|
failCount: r.fail_count || 0,
|
||||||
|
failMax: r.fail_max || 5,
|
||||||
|
failRemaining: r.fail_remaining || 0,
|
||||||
|
lockMinutes: r.lock_minutes || 10,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
error.value = { message: r.message || e.message || '登录失败', locked: false };
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d.getTime())) return iso;
|
||||||
|
const pad = n => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRemain(seconds) {
|
||||||
|
if (!seconds || seconds <= 0) return '0 秒';
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
if (m === 0) return `${s} 秒`;
|
||||||
|
if (s === 0) return `${m} 分钟`;
|
||||||
|
return `${m} 分 ${s} 秒`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg); position: relative; overflow: hidden; padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%; max-width: 420px; padding: 40px 36px;
|
||||||
|
box-shadow: 0 12px 40px rgba(40, 80, 110, 0.10);
|
||||||
|
position: relative; z-index: 2;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
|
||||||
|
.logo {
|
||||||
|
width: 40px; height: 40px; border-radius: 10px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 16px; font-weight: 700; letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.brand-name { font-size: 18px; font-weight: 600; }
|
||||||
|
.brand-sub { font-size: 14px; color: var(--text-soft); font-weight: 500; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { color: var(--text-soft); margin: 0 0 28px; font-size: 14px; }
|
||||||
|
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.field { display: flex; flex-direction: column; }
|
||||||
|
.submit { margin-top: 8px; height: 44px; font-size: 15px; }
|
||||||
|
.error {
|
||||||
|
color: var(--danger); font-size: 13px;
|
||||||
|
background: #FBE3DF; padding: 10px 14px; border-radius: var(--radius-sm);
|
||||||
|
margin: 0;
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.error-msg { font-weight: 500; }
|
||||||
|
.error-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8B3530;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.error-meta strong { font-weight: 700; color: var(--danger); }
|
||||||
|
.hint {
|
||||||
|
color: var(--text-soft); font-size: 12px; margin-top: 22px; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.hint code {
|
||||||
|
background: var(--bg-soft); padding: 1px 6px; border-radius: 4px;
|
||||||
|
font-size: 12px; color: var(--text);
|
||||||
|
}
|
||||||
|
.deco { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
|
||||||
|
.bubble { position: absolute; border-radius: 50%; opacity: .35; }
|
||||||
|
.b1 { width: 320px; height: 320px; background: var(--brand-soft); top: -80px; right: -80px; }
|
||||||
|
.b2 { width: 200px; height: 200px; background: var(--green-soft); bottom: 40px; left: -40px; }
|
||||||
|
.b3 { width: 140px; height: 140px; background: #B8E0D4; bottom: 200px; right: 200px; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.login-page { padding: 16px; }
|
||||||
|
.login-card { padding: 28px 22px; border-radius: 14px; }
|
||||||
|
.login-title { font-size: 22px; }
|
||||||
|
.login-sub { font-size: 13px; }
|
||||||
|
.input { font-size: 16px; } /* 防止 iOS 自动缩放 */
|
||||||
|
.login-btn { min-height: 48px; font-size: 15px; }
|
||||||
|
.b1 { width: 220px; height: 220px; }
|
||||||
|
.b2 { width: 140px; height: 140px; }
|
||||||
|
.b3 { width: 100px; height: 100px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.login-card { padding: 24px 18px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">保养记录</h1>
|
||||||
|
<p class="subtitle text-soft">机油、机滤、刹车油、轮胎… 每次保养 + 下次保养里程</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<button class="btn btn-primary" @click="openNew">+ 新建保养</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 过滤条 -->
|
||||||
|
<div class="card card-pad filters">
|
||||||
|
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||||
|
<option value="">全部车辆</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="filters.from" type="date" class="input sm" @change="load" />
|
||||||
|
<span class="text-soft">至</span>
|
||||||
|
<input v-model="filters.to" type="date" class="input sm" @change="load" />
|
||||||
|
<div class="stats-pills">
|
||||||
|
<span class="pill pill-blue">{{ data.total || 0 }} 条</span>
|
||||||
|
<span class="pill pill-green">¥{{ (data.stats?.total_cost || 0).toFixed(2) }} 总花费</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 列表 -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有保养记录,点右上「+ 新建保养」开始</div>
|
||||||
|
<MobileCardList
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:rows="data.rows"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="还没有保养记录"
|
||||||
|
>
|
||||||
|
<template #cell-date="{ row }">{{ row.maint_date }}</template>
|
||||||
|
<template #cell-vehicle="{ row }">
|
||||||
|
<div>{{ row.vehicle_name || '—' }}</div>
|
||||||
|
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-items="{ row }">
|
||||||
|
<span v-for="(it, i) in row.items" :key="i" class="pill pill-gray sm mr-1">{{ it.name }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-odo="{ row }">{{ row.odometer_km ? row.odometer_km + ' km' : '—' }}</template>
|
||||||
|
<template #cell-evhev="{ row }">
|
||||||
|
<span v-if="row.ev_km != null" class="pill pill-green sm">EV {{ row.ev_km }}</span>
|
||||||
|
<span v-if="row.hev_km != null" class="pill pill-blue sm">HEV {{ row.hev_km }}</span>
|
||||||
|
<span v-if="row.ev_km == null && row.hev_km == null" class="text-mute sm">—</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-shop="{ row }">{{ row.shop || '—' }}</template>
|
||||||
|
<template #cell-next="{ row }">{{ row.next_due_km ? row.next_due_km + ' km' : '—' }}</template>
|
||||||
|
<template #cell-cost="{ row }">
|
||||||
|
<strong class="text-brand">¥{{ (row.total_cost || 0).toFixed(2) }}</strong>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="showDelete"
|
||||||
|
v-model="showDelete"
|
||||||
|
title="删除保养记录"
|
||||||
|
:message="`确认删除 ${deleteTarget?.maint_date} 的保养记录?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||||
|
:busy="deleteBusy"
|
||||||
|
:error="deleteError"
|
||||||
|
@confirm="doDelete"
|
||||||
|
@cancel="showDelete = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 弹窗 -->
|
||||||
|
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||||
|
<div class="modal card card-pad big-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 class="section-title">{{ form.id ? '编辑保养' : '新建保养' }}</h3>
|
||||||
|
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别小票' }}</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSave">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.vehicle_id" class="select" required>
|
||||||
|
<option :value="null">— 请选择 —</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">日期 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.maint_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">总里程 (km)</label>
|
||||||
|
<input v-model.number="form.odometer_km" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">EV 里程 (km) <span class="text-soft sm">纯电</span></label>
|
||||||
|
<input v-model.number="form.ev_km" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">HEV 里程 (km) <span class="text-soft sm">混动</span></label>
|
||||||
|
<input v-model.number="form.hev_km" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">店名</label>
|
||||||
|
<input v-model="form.shop" class="input" placeholder="如 途虎养车 / 4S店" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">下次保养里程 (km)</label>
|
||||||
|
<input v-model.number="form.next_due_km" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">下次保养日期</label>
|
||||||
|
<input v-model="form.next_due_date" type="date" class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h4 class="section-title mt-3">保养项目</h4>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th style="width:50%">项目</th><th>费用 ¥</th><th>间隔 km</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(it, i) in form.items" :key="i">
|
||||||
|
<td>
|
||||||
|
<input v-model="it.name" class="input sm" placeholder="如 机油 5W-30" :list="`maint-presets`" />
|
||||||
|
<datalist id="maint-presets">
|
||||||
|
<option v-for="p in MAINT_PRESETS" :key="p" :value="p"></option>
|
||||||
|
</datalist>
|
||||||
|
</td>
|
||||||
|
<td><input v-model.number="it.cost" type="number" step="0.01" min="0" class="input sm" /></td>
|
||||||
|
<td><input v-model.number="it.interval_km" type="number" min="0" class="input sm" placeholder="5000" /></td>
|
||||||
|
<td><button type="button" class="btn btn-ghost btn-sm" @click="form.items.splice(i,1)">×</button></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm mt-2" @click="form.items.push({name:'',cost:0,interval_km:null})">+ 加项目</button>
|
||||||
|
<div class="text-soft sm mt-2">合计 ¥{{ itemsTotal.toFixed(2) }}</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import { maintApi } from '../api/logs';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||||
|
{ key: 'items', label: '项目' },
|
||||||
|
{ key: 'odo', label: '总里程' },
|
||||||
|
{ key: 'evhev', label: 'EV/HEV' },
|
||||||
|
{ key: 'shop', label: '店名' },
|
||||||
|
{ key: 'next', label: '下次里程' },
|
||||||
|
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAINT_PRESETS = [
|
||||||
|
'机油 5W-30','机油 5W-40','机滤','空滤','空调滤','汽油滤','火花塞',
|
||||||
|
'刹车油','变速箱油','防冻液','电瓶','刹车片','刹车盘','轮胎',
|
||||||
|
'四轮定位','动平衡','更换雨刮','添加玻璃水','底盘装甲'
|
||||||
|
];
|
||||||
|
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const data = ref({ rows: [], total: 0, stats: {} });
|
||||||
|
const loading = ref(false);
|
||||||
|
const filters = reactive({ vehicle_id: '', from: '', to: '' });
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const form = reactive({ id: null, vehicle_id: null, maint_date: today(), odometer_km: null, shop: '', next_due_km: null, next_due_date: '', items: [], notes: '' });
|
||||||
|
const formBusy = ref(false);
|
||||||
|
const formError = ref('');
|
||||||
|
|
||||||
|
// 401 草稿
|
||||||
|
const draft = useFormDraft('maints/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) {
|
||||||
|
Object.assign(form, restored);
|
||||||
|
if (!Array.isArray(form.items)) form.items = [];
|
||||||
|
}
|
||||||
|
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
// AI 识别
|
||||||
|
const ai = useAiRecognize();
|
||||||
|
const aiBusy = ai.busy;
|
||||||
|
async function onAiRecognize() {
|
||||||
|
await ai.open('maint', (data) => {
|
||||||
|
if (data.maint_date) form.maint_date = data.maint_date;
|
||||||
|
if (data.total_cost != null) form.total_cost = data.total_cost;
|
||||||
|
if (data.shop) form.shop = data.shop;
|
||||||
|
if (data.odometer_km) form.odometer_km = data.odometer_km;
|
||||||
|
if (data.next_due_km) form.next_due_km = data.next_due_km;
|
||||||
|
if (Array.isArray(data.items) && data.items.length) {
|
||||||
|
form.items = data.items.filter(x => x.name).map(x => ({ name: x.name, cost: Number(x.cost || 0), interval_km: 5000 }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemsTotal = computed(() => form.items.reduce((s, x) => s + Number(x.cost || 0), 0));
|
||||||
|
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||||
|
if (filters.from) params.from = filters.from;
|
||||||
|
if (filters.to) params.to = filters.to;
|
||||||
|
const r = await maintApi.list(params);
|
||||||
|
data.value = r.data;
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVehicles() {
|
||||||
|
const r = await vehiclesApi.list();
|
||||||
|
vehicles.value = r.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
Object.assign(form, { id: null, vehicle_id: null, maint_date: today(), odometer_km: null, ev_km: null, hev_km: null, shop: '', next_due_km: null, next_due_date: '', items: [{name:'',cost:0,interval_km:5000}], notes: '' });
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r) {
|
||||||
|
Object.assign(form, { ...r, items: r.items?.length ? r.items.map(x => ({...x})) : [{name:'',cost:0,interval_km:5000}] });
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() { showForm.value = false; }
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
formError.value = '';
|
||||||
|
if (!form.vehicle_id) { formError.value = '请选择车辆'; return; }
|
||||||
|
if (form.items.length) {
|
||||||
|
const sum = form.items.reduce((s, x) => s + Number(x.cost || 0), 0);
|
||||||
|
form.total_cost = Math.round(sum * 100) / 100;
|
||||||
|
}
|
||||||
|
formBusy.value = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
vehicle_id: form.vehicle_id,
|
||||||
|
maint_date: form.maint_date,
|
||||||
|
odometer_km: form.odometer_km || null,
|
||||||
|
ev_km: form.ev_km || null,
|
||||||
|
hev_km: form.hev_km || null,
|
||||||
|
total_cost: form.total_cost || 0,
|
||||||
|
shop: form.shop || null,
|
||||||
|
items_json: JSON.stringify(form.items.filter(x => x.name)),
|
||||||
|
next_due_date: form.next_due_date || null,
|
||||||
|
next_due_km: form.next_due_km || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
if (form.id) await maintApi.update(form.id, body);
|
||||||
|
else await maintApi.create(body);
|
||||||
|
draft.clear();
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
const errs = e.response?.data?.error?.errors;
|
||||||
|
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||||
|
} finally { formBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(r) {
|
||||||
|
deleteTarget.value = r;
|
||||||
|
deleteError.value = '';
|
||||||
|
showDelete.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDelete = ref(false);
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteBusy = ref(false);
|
||||||
|
const deleteError = ref('');
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget.value) return;
|
||||||
|
deleteBusy.value = true;
|
||||||
|
deleteError.value = '';
|
||||||
|
try {
|
||||||
|
await maintApi.remove(deleteTarget.value.id);
|
||||||
|
showDelete.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||||
|
} finally {
|
||||||
|
deleteBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadVehicles(); load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; }
|
||||||
|
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||||
|
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||||
|
.filters { display:flex; gap:10px; align-items:center; }
|
||||||
|
.stats-pills { margin-left:auto; display:flex; gap:8px; }
|
||||||
|
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||||
|
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||||
|
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||||
|
.modal-head .section-title { margin:0; }
|
||||||
|
.big-modal { max-width:800px; }
|
||||||
|
.grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||||
|
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||||
|
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||||
|
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||||
|
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||||
|
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||||
|
.r { text-align:right; }
|
||||||
|
.mr-1 { margin-right:4px; }
|
||||||
|
.mt-2 { margin-top:8px; }
|
||||||
|
.mt-3 { margin-top:12px; }
|
||||||
|
.text-soft { color:var(--text-soft); }
|
||||||
|
.text-mute { color:var(--text-mute); }
|
||||||
|
.text-danger { color:var(--danger); }
|
||||||
|
.text-brand { color:var(--brand); }
|
||||||
|
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; }
|
||||||
|
.head .btn { width: 100%; justify-content: center; }
|
||||||
|
.filters { padding: 12px 16px; }
|
||||||
|
.stats-pills { width: 100%; margin-left: 0; }
|
||||||
|
.modal-mask { align-items: flex-end; padding: 0; }
|
||||||
|
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div class="offline">
|
||||||
|
<div class="icon">📡</div>
|
||||||
|
<h1>暂时无法连接到网络</h1>
|
||||||
|
<p class="text-soft">离线模式下,已缓存的页面仍可继续浏览。恢复网络后可使用完整功能。</p>
|
||||||
|
<button class="btn btn-primary" @click="onRetry">重新连接</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const onRetry = () => {
|
||||||
|
if (navigator.onLine) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('设备仍处于离线状态,请检查网络后重试。');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.offline {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg, #f5f8fc);
|
||||||
|
color: var(--text, #1f2937);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
font-size: 80px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
filter: grayscale(0.3);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0 0 28px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 12px 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #1b6ef3;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">操作日志</h1>
|
||||||
|
<p class="subtitle text-soft">记录所有"会改变数据"的操作 · 共 {{ total }} 条</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 顶部统计 -->
|
||||||
|
<div class="stats-row" v-if="stats && stats.by_action.length">
|
||||||
|
<div v-for="s in stats.by_action" :key="s.action" class="stat-card">
|
||||||
|
<div class="stat-num">{{ s.c }}</div>
|
||||||
|
<div class="stat-label">{{ actionLabel(s.action) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card filter">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div>
|
||||||
|
<label class="label">操作类型</label>
|
||||||
|
<select v-model="filters.action" class="select" @change="reload">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="a in actionOptions" :key="a.value" :value="a.value">{{ a.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">对象类型</label>
|
||||||
|
<select v-model="filters.target_type" class="select" @change="reload">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="t in targetOptions" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">操作人</label>
|
||||||
|
<input v-model="filters.username" class="input" placeholder="用户名" @change="reload" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">开始</label>
|
||||||
|
<input v-model="filters.from" type="date" class="input" @change="reload" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">结束</label>
|
||||||
|
<input v-model="filters.to" type="date" class="input" @change="reload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<MobileCardList
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
row-key="id"
|
||||||
|
:clickable="false"
|
||||||
|
empty-text="暂无日志"
|
||||||
|
>
|
||||||
|
<template #cell-time="{ row }">
|
||||||
|
<span class="text-soft sm">{{ row.created_at }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-user="{ row }">{{ row.username || '—' }}</template>
|
||||||
|
<template #cell-action="{ row }">
|
||||||
|
<span class="pill" :class="actionPill(row.action)">{{ row.action_label }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-target="{ row }">{{ row.target_label }}</template>
|
||||||
|
<template #cell-summary="{ row }">
|
||||||
|
<div>{{ row.target_summary || '—' }}</div>
|
||||||
|
<button v-if="row.detail" class="btn-link" @click="toggleDetail(row.id)" style="font-size:12px; margin-top:4px">
|
||||||
|
{{ expanded.has(row.id) ? '收起详情' : '查看详情' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="expanded.has(row.id) && row.detail" class="detail-box">
|
||||||
|
<div class="detail-meta">
|
||||||
|
<span>IP: {{ row.ip || '—' }}</span>
|
||||||
|
<span>UA: {{ row.user_agent || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<pre>{{ formatDetail(row.detail) }}</pre>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button
|
||||||
|
v-if="row.recoverable"
|
||||||
|
class="btn btn-ghost btn-sm text-recover"
|
||||||
|
@click.stop="askRecover(row)"
|
||||||
|
>恢复</button>
|
||||||
|
<span v-else-if="row.recovered_at" class="badge badge-green">已恢复</span>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="total > limit" class="pager">
|
||||||
|
<button class="btn btn-ghost" :disabled="page <= 1" @click="page--; reload()">‹ 上一页</button>
|
||||||
|
<span class="text-soft">{{ page }} / {{ total_pages }}</span>
|
||||||
|
<button class="btn btn-ghost" :disabled="page >= total_pages" @click="page++; reload()">下一页 ›</button>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
|
||||||
|
<!-- 恢复确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="showRecover"
|
||||||
|
v-model="showRecover"
|
||||||
|
title="恢复记录"
|
||||||
|
:message="`确认要恢复这条「${recoverTarget?.action_label}」操作吗?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认恢复"
|
||||||
|
confirm-word="恢复"
|
||||||
|
danger-type="recover"
|
||||||
|
:tips="[
|
||||||
|
'恢复后记录将重新出现在对应列表中',
|
||||||
|
'仅恢复本次操作,不影响其他数据'
|
||||||
|
]"
|
||||||
|
:busy="recoverBusy"
|
||||||
|
:error="recoverError"
|
||||||
|
@confirm="doRecover"
|
||||||
|
@cancel="showRecover = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import * as oplogApi from '../api/operationLogs';
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'time', label: '时间', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'user', label: '操作人' },
|
||||||
|
{ key: 'action', label: '操作', alwaysShow: true },
|
||||||
|
{ key: 'target', label: '对象' },
|
||||||
|
{ key: 'summary', label: '摘要' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const limit = 50;
|
||||||
|
const total_pages = ref(1);
|
||||||
|
const stats = ref(null);
|
||||||
|
const actionOptions = ref([]);
|
||||||
|
const targetOptions = ref([]);
|
||||||
|
const expanded = ref(new Set());
|
||||||
|
|
||||||
|
const filters = reactive({ action: '', target_type: '', username: '', from: '', to: '' });
|
||||||
|
|
||||||
|
// 恢复
|
||||||
|
const showRecover = ref(false);
|
||||||
|
const recoverTarget = ref(null);
|
||||||
|
const recoverBusy = ref(false);
|
||||||
|
const recoverError = ref('');
|
||||||
|
|
||||||
|
function askRecover(r) {
|
||||||
|
recoverTarget.value = r;
|
||||||
|
recoverError.value = '';
|
||||||
|
showRecover.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRecover() {
|
||||||
|
if (!recoverTarget.value) return;
|
||||||
|
recoverBusy.value = true;
|
||||||
|
recoverError.value = '';
|
||||||
|
try {
|
||||||
|
await oplogApi.recover(recoverTarget.value.id);
|
||||||
|
showRecover.value = false;
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
recoverError.value = e.response?.data?.error?.message || e.message || '恢复失败';
|
||||||
|
} finally {
|
||||||
|
recoverBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionLabel = (a) => ({ delete: '删除', batch_delete: '批量删除', create: '新建', update: '更新', recover: '恢复' }[a] || a);
|
||||||
|
const actionPill = (a) => ({
|
||||||
|
delete: 'pill-warn',
|
||||||
|
batch_delete: 'pill-warn',
|
||||||
|
create: 'pill-green',
|
||||||
|
update: 'pill-blue',
|
||||||
|
recover: 'pill-green',
|
||||||
|
}[a] || 'pill-gray');
|
||||||
|
|
||||||
|
function toggleDetail(id) {
|
||||||
|
const s = new Set(expanded.value);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
expanded.value = s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(d) {
|
||||||
|
return JSON.stringify(d, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload() {
|
||||||
|
const params = { ...filters, page: page.value, limit };
|
||||||
|
Object.keys(params).forEach(k => !params[k] && delete params[k]);
|
||||||
|
const r = await oplogApi.list(params);
|
||||||
|
const d = r.data;
|
||||||
|
rows.value = d.rows || [];
|
||||||
|
total.value = d.total || 0;
|
||||||
|
total_pages.value = d.total_pages || 1;
|
||||||
|
stats.value = d.stats || null;
|
||||||
|
expanded.value = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await oplogApi.options();
|
||||||
|
actionOptions.value = r.data.actions || [];
|
||||||
|
targetOptions.value = r.data.target_types || [];
|
||||||
|
} catch {}
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||||
|
.stat-card {
|
||||||
|
background: var(--card); border-radius: 10px; padding: 12px 18px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.stat-num { font-size: 22px; font-weight: 700; color: var(--accent); }
|
||||||
|
.stat-label { font-size: 13px; color: var(--text-soft); margin-top: 2px; }
|
||||||
|
.filter { padding: 16px 20px; }
|
||||||
|
.filter-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
||||||
|
.log-row td { vertical-align: top; }
|
||||||
|
.detail-row td { background: var(--bg-soft); padding: 0 16px 12px; }
|
||||||
|
.detail-box { font-size: 12px; }
|
||||||
|
.detail-meta {
|
||||||
|
color: var(--text-soft);
|
||||||
|
margin: 6px 0;
|
||||||
|
display: flex; gap: 16px;
|
||||||
|
}
|
||||||
|
.detail-box pre {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.btn-link {
|
||||||
|
background: none; border: 0; padding: 2px 4px; cursor: pointer;
|
||||||
|
color: var(--brand); font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-link:hover { text-decoration: underline; }
|
||||||
|
.text-recover { color: #10B981; }
|
||||||
|
.badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 12px;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-green { background: #D1FAE5; color: #065F46; }
|
||||||
|
.pager {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) { .filter-row { grid-template-columns: repeat(2, 1fr); } }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.stats-row { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.stat-card { padding: 10px 12px; }
|
||||||
|
.stat-num { font-size: 20px; }
|
||||||
|
.filter { padding: 12px 16px; }
|
||||||
|
.filter-row { grid-template-columns: 1fr; }
|
||||||
|
.pager .btn { flex: 1; }
|
||||||
|
.detail-box { padding: 8px; }
|
||||||
|
.detail-box pre { font-size: 11px; max-height: 200px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">加油记录</h1>
|
||||||
|
<p class="subtitle text-soft">每次加油 + 油耗自动计算(需勾「加满」两次)</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" @click="openNew">+ 新建加油</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad filters">
|
||||||
|
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||||
|
<option value="">全部车辆</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
<input v-model="filters.from" type="date" class="input sm" @change="load" />
|
||||||
|
<span class="text-soft">至</span>
|
||||||
|
<input v-model="filters.to" type="date" class="input sm" @change="load" />
|
||||||
|
<div class="stats-pills">
|
||||||
|
<span class="pill pill-blue">{{ data.total || 0 }} 条</span>
|
||||||
|
<span class="pill pill-green">¥{{ (data.stats?.total_cost || 0).toFixed(2) }} 总花费</span>
|
||||||
|
<span class="pill pill-gray" v-if="avgConsumption">{{ avgConsumption }} L/100km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有加油记录</div>
|
||||||
|
<MobileCardList
|
||||||
|
v-else
|
||||||
|
:columns="columns"
|
||||||
|
:rows="data.rows"
|
||||||
|
row-key="id"
|
||||||
|
empty-text="还没有加油记录"
|
||||||
|
>
|
||||||
|
<template #cell-date="{ row }">
|
||||||
|
{{ row.refuel_date }}
|
||||||
|
</template>
|
||||||
|
<template #cell-vehicle="{ row }">
|
||||||
|
<div>{{ row.vehicle_name }}</div>
|
||||||
|
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||||
|
</template>
|
||||||
|
<template #cell-fuel="{ row }">
|
||||||
|
<span class="pill pill-gray">{{ row.fuel_type || '—' }}</span>
|
||||||
|
<span v-if="row.is_full" class="pill pill-green sm">加满</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-odo="{ row }">
|
||||||
|
{{ row.odometer_km ? row.odometer_km + ' km' : '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-liters="{ row }">
|
||||||
|
<strong>{{ row.liters }} L</strong>
|
||||||
|
</template>
|
||||||
|
<template #cell-price="{ row }">
|
||||||
|
¥{{ row.price_per_liter || 0 }}
|
||||||
|
</template>
|
||||||
|
<template #cell-cost="{ row }">
|
||||||
|
<strong class="text-brand">¥{{ (row.total_cost || 0).toFixed(2) }}</strong>
|
||||||
|
</template>
|
||||||
|
<template #cell-consumption="{ row }">
|
||||||
|
<span v-if="row.consumption_100km" class="pill pill-blue">{{ row.consumption_100km.toFixed(2) }} L/100km</span>
|
||||||
|
<span v-else class="text-mute sm" :title="row.consumption_skip_reason">{{ row.consumption_skip_reason || '需加满+里程' }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-station="{ row }">
|
||||||
|
{{ row.station || '—' }}
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||||
|
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 删除确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="showDelete"
|
||||||
|
v-model="showDelete"
|
||||||
|
title="删除加油记录"
|
||||||
|
:message="`确认删除 ${deleteTarget?.refuel_date} 的加油记录?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||||
|
:busy="deleteBusy"
|
||||||
|
:error="deleteError"
|
||||||
|
@confirm="doDelete"
|
||||||
|
@cancel="showDelete = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 弹窗 -->
|
||||||
|
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||||
|
<div class="modal card card-pad">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h3 class="section-title">{{ form.id ? '编辑加油' : '新建加油' }}</h3>
|
||||||
|
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别小票' }}</button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSave">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.vehicle_id" class="select" required>
|
||||||
|
<option :value="null">— 请选择 —</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">日期 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.refuel_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">里程 (km)</label>
|
||||||
|
<input v-model.number="form.odometer_km" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">油号</label>
|
||||||
|
<select v-model="form.fuel_type" class="select">
|
||||||
|
<option v-for="t in FUEL_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">升数 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="form.liters" type="number" step="0.01" min="0.01" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">单价 (¥/L)</label>
|
||||||
|
<input v-model.number="form.price_per_liter" type="number" step="0.01" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">总价 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="form.total_cost" type="number" step="0.01" min="0" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div class="flex-center">
|
||||||
|
<label class="check"><input v-model="form.is_full" type="checkbox" :true-value="1" :false-value="0" /> <span>加满(用于油耗计算)</span></label>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<label class="label">加油站</label>
|
||||||
|
<input v-model="form.station" class="input" placeholder="如 中石化朝阳门店" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||||
|
<div class="actions mt-3">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import { refuelApi } from '../api/logs';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
|
||||||
|
const FUEL_TYPES = ['92#', '95#', '98#', '0#柴油', '-10#柴油', 'E92乙醇', 'E95乙醇', 'LPG'];
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||||
|
{ key: 'fuel', label: '油号' },
|
||||||
|
{ key: 'odo', label: '里程' },
|
||||||
|
{ key: 'liters', label: '升数', alwaysShow: true },
|
||||||
|
{ key: 'price', label: '单价' },
|
||||||
|
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||||
|
{ key: 'consumption', label: '油耗' },
|
||||||
|
{ key: 'station', label: '加油站' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const data = ref({ rows: [], total: 0, stats: {} });
|
||||||
|
const loading = ref(false);
|
||||||
|
const filters = reactive({ vehicle_id: '', from: '', to: '' });
|
||||||
|
|
||||||
|
const showForm = ref(false);
|
||||||
|
const form = reactive({ id: null, vehicle_id: null, refuel_date: today(), odometer_km: null, liters: null, price_per_liter: null, total_cost: null, fuel_type: '95#', is_full: 1, station: '', notes: '' });
|
||||||
|
const formBusy = ref(false);
|
||||||
|
const formError = ref('');
|
||||||
|
|
||||||
|
// 表单草稿:401 跳转前自动 flush
|
||||||
|
const draft = useFormDraft('refuels/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) Object.assign(form, restored);
|
||||||
|
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
// AI 识别
|
||||||
|
const ai = useAiRecognize();
|
||||||
|
const aiBusy = ai.busy;
|
||||||
|
async function onAiRecognize() {
|
||||||
|
await ai.open('refuel', (data) => {
|
||||||
|
if (data.refuel_date) form.refuel_date = data.refuel_date;
|
||||||
|
if (data.liters != null) form.liters = data.liters;
|
||||||
|
if (data.price_per_liter != null) form.price_per_liter = data.price_per_liter;
|
||||||
|
if (data.total_cost != null) form.total_cost = data.total_cost;
|
||||||
|
if (data.fuel_type) form.fuel_type = data.fuel_type;
|
||||||
|
if (data.is_full != null) form.is_full = data.is_full ? 1 : 0;
|
||||||
|
if (data.station) form.station = data.station;
|
||||||
|
if (data.odometer_km) form.odometer_km = data.odometer_km;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgConsumption = computed(() => {
|
||||||
|
const xs = data.value.rows?.filter(r => r.consumption_100km > 0).map(r => r.consumption_100km) || [];
|
||||||
|
if (!xs.length) return null;
|
||||||
|
return (xs.reduce((s, x) => s + x, 0) / xs.length).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 自动算总价 = 升数 × 单价
|
||||||
|
watch(() => [form.liters, form.price_per_liter], ([l, p]) => {
|
||||||
|
if (l && p && !form.total_cost) form.total_cost = Math.round(l * p * 100) / 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
function today() { return new Date().toISOString().slice(0, 10); }
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||||
|
if (filters.from) params.from = filters.from;
|
||||||
|
if (filters.to) params.to = filters.to;
|
||||||
|
const r = await refuelApi.list(params);
|
||||||
|
data.value = r.data;
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVehicles() {
|
||||||
|
const r = await vehiclesApi.list();
|
||||||
|
vehicles.value = r.data || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
Object.assign(form, { id: null, vehicle_id: null, refuel_date: today(), odometer_km: null, liters: null, price_per_liter: null, total_cost: null, fuel_type: '95#', is_full: 1, station: '', notes: '' });
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(r) {
|
||||||
|
Object.assign(form, r);
|
||||||
|
formError.value = '';
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() { showForm.value = false; }
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
formError.value = '';
|
||||||
|
formBusy.value = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
vehicle_id: form.vehicle_id,
|
||||||
|
refuel_date: form.refuel_date,
|
||||||
|
odometer_km: form.odometer_km || null,
|
||||||
|
liters: form.liters,
|
||||||
|
price_per_liter: form.price_per_liter || null,
|
||||||
|
total_cost: form.total_cost,
|
||||||
|
fuel_type: form.fuel_type || null,
|
||||||
|
is_full: form.is_full ? 1 : 0,
|
||||||
|
station: form.station || null,
|
||||||
|
notes: form.notes || null,
|
||||||
|
};
|
||||||
|
if (form.id) await refuelApi.update(form.id, body);
|
||||||
|
else await refuelApi.create(body);
|
||||||
|
draft.clear();
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
const errs = e.response?.data?.error?.errors;
|
||||||
|
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||||
|
} finally { formBusy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(r) {
|
||||||
|
deleteTarget.value = r;
|
||||||
|
deleteError.value = '';
|
||||||
|
showDelete.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDelete = ref(false);
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteBusy = ref(false);
|
||||||
|
const deleteError = ref('');
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget.value) return;
|
||||||
|
deleteBusy.value = true;
|
||||||
|
deleteError.value = '';
|
||||||
|
try {
|
||||||
|
await refuelApi.remove(deleteTarget.value.id);
|
||||||
|
showDelete.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||||
|
} finally {
|
||||||
|
deleteBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { loadVehicles(); load(); });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
|
||||||
|
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||||
|
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||||
|
.filters { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.stats-pills { margin-left:auto; display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||||
|
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||||
|
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:8px; flex-wrap:wrap; }
|
||||||
|
.modal-head .section-title { margin:0; }
|
||||||
|
.grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||||
|
.col-span-2 { grid-column: span 2; }
|
||||||
|
.flex-center { display:flex; align-items:center; }
|
||||||
|
.check { display:flex; align-items:center; gap:6px; font-size:14px; cursor:pointer; }
|
||||||
|
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||||
|
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||||
|
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||||
|
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||||
|
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||||
|
.r { text-align:right; }
|
||||||
|
.mt-3 { margin-top:12px; }
|
||||||
|
.text-soft { color:var(--text-soft); }
|
||||||
|
.text-mute { color:var(--text-mute); }
|
||||||
|
.text-danger { color:var(--danger); }
|
||||||
|
.text-brand { color:var(--brand); }
|
||||||
|
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.col-span-2 { grid-column: span 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; }
|
||||||
|
.head .btn { width: 100%; justify-content: center; }
|
||||||
|
|
||||||
|
.filters { padding: 12px 16px; }
|
||||||
|
.stats-pills { width: 100%; margin-left: 0; }
|
||||||
|
|
||||||
|
/* modal 改底部 sheet */
|
||||||
|
.modal-mask { align-items: flex-end; padding: 0; }
|
||||||
|
.modal {
|
||||||
|
max-width: none;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
max-height: 92vh;
|
||||||
|
animation: sheetUp .25s ease;
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
}
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.modal-head { padding: 4px 0 8px; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.col-span-2 { grid-column: 1; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.filters .input, .filters .select { font-size: 14px; }
|
||||||
|
.filters .sm { font-size: 13px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,663 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<h1 class="title">设置</h1>
|
||||||
|
<p class="text-soft" style="margin: 4px 0 24px; font-size:14px">配置账户、天气、Grocy、CSV 导出</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||||
|
<div v-else class="settings-grid">
|
||||||
|
<!-- 账户 -->
|
||||||
|
<section class="card card-pad">
|
||||||
|
<h2 class="section-title">账户</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">修改当前用户 <strong>{{ auth.user?.username }}</strong> 的密码</p>
|
||||||
|
<form @submit.prevent="onChangePass" class="form">
|
||||||
|
<div>
|
||||||
|
<label class="label">当前密码</label>
|
||||||
|
<input v-model="pass.current" type="password" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">新密码(至少 6 位)</label>
|
||||||
|
<input v-model="pass.next" type="password" class="input" minlength="6" required />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">确认新密码</label>
|
||||||
|
<input v-model="pass.confirm" type="password" class="input" minlength="6" required />
|
||||||
|
</div>
|
||||||
|
<p v-if="passMsg" class="msg" :class="passOk ? 'ok' : 'err'">{{ passMsg }}</p>
|
||||||
|
<button class="btn btn-primary mt-3" :disabled="busy.pass">{{ busy.pass ? '保存中…' : '更新密码' }}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 天气 -->
|
||||||
|
<section class="card card-pad">
|
||||||
|
<h2 class="section-title">天气</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">基于 wttr.in,每天最多请求一次(当天缓存优先)</p>
|
||||||
|
<form @submit.prevent="onSaveSettings('weather', weather)" class="form">
|
||||||
|
<div>
|
||||||
|
<label class="label">默认城市 <span class="text-soft sm">— 永久生效,优先于 IP 定位</span></label>
|
||||||
|
<input v-model="weather.cityDefault" class="input" placeholder="如:库尔勒" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">今天手动定位 <span class="text-soft sm">— 仅今天有效,优先级最高</span></label>
|
||||||
|
<input v-model="weather.city" class="input" placeholder="留空使用默认城市" />
|
||||||
|
<p class="hint" v-if="cityHint">{{ cityHint }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="msgs.weather" class="msg ok">{{ msgs.weather }}</p>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<button class="btn btn-primary" :disabled="busy.weather">{{ busy.weather ? '保存中…' : '保存' }}</button>
|
||||||
|
<button type="button" class="btn btn-ghost" @click="onTestWeather" :disabled="busy.testWx">
|
||||||
|
{{ busy.testWx ? '拉取中…' : '拉取今日天气' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Grocy -->
|
||||||
|
<section class="card card-pad">
|
||||||
|
<h2 class="section-title">Grocy</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">化学品同步到 Grocy 库存(同步在「化学品」页面操作)</p>
|
||||||
|
<form @submit.prevent="onSaveSettings('grocy', grocy)" class="form">
|
||||||
|
<div>
|
||||||
|
<label class="label">GROCY_URL</label>
|
||||||
|
<input v-model="grocy.url" class="input" placeholder="https://grocy.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">用户名</label>
|
||||||
|
<input v-model="grocy.username" type="text" class="input" autocomplete="off" placeholder="Grocy 登录用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">密码</label>
|
||||||
|
<input v-model="grocy.password" type="password" class="input" :placeholder="grocy.has_password ? '•••• 已配置(留空保持)' : 'Grocy 登录密码'" />
|
||||||
|
</div>
|
||||||
|
<p v-if="msgs.grocy" class="msg" :class="msgsGrocyOk ? 'ok' : 'err'">{{ msgs.grocy }}</p>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<button class="btn btn-primary" :disabled="busy.grocy">{{ busy.grocy ? '保存中…' : '保存' }}</button>
|
||||||
|
<button class="btn btn-danger-outline btn-sm" @click="onClearGrocy" :disabled="busy.grocy">清空配置</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- 同步历史 -->
|
||||||
|
<div class="mt-4" v-if="grocyLogList.length > 0 || busy.grocyLog">
|
||||||
|
<h3 class="text-soft" style="font-size:13px; margin: 0 0 10px; font-weight:600">同步历史</h3>
|
||||||
|
<div v-if="busy.grocyLog" class="text-soft" style="font-size:13px">加载中…</div>
|
||||||
|
<table v-else class="log-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间</th><th>操作</th><th>状态</th><th>结果</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="log in grocyLogList" :key="log.id">
|
||||||
|
<td>{{ log.started_at.replace('T',' ').slice(0,16) }}</td>
|
||||||
|
<td>{{ log.action === 'pull_products' ? '拉取产品' : log.action }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="log.status === 'success' ? 'text-ok' : log.status === 'failed' ? 'text-err' : 'text-soft'">
|
||||||
|
{{ log.status === 'success' ? '✓ 成功' : log.status === 'failed' ? '✗ 失败' : '…' }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-soft" style="font-size:12px">
|
||||||
|
<template v-if="log.detail">
|
||||||
|
+{{ log.detail.inserted }} / ~{{ log.detail.updated }} / -{{ log.detail.deactivated }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="log.detail?.error">{{ log.detail.error }}</template>
|
||||||
|
<template v-else>—</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- AI 截图识别 -->
|
||||||
|
<section class="card card-pad">
|
||||||
|
<h2 class="section-title">📷 AI 截图识别</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">上传小票/订单截图,自动提取日期/金额/油号/度数/保单号等填入表单。</p>
|
||||||
|
<form @submit.prevent="onSaveAi" class="form">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" v-model="ai.enabled" />
|
||||||
|
<span>启用 AI 截图识别</span>
|
||||||
|
</label>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">Provider <span class="text-soft sm">— 选择 AI 服务</span></label>
|
||||||
|
<select v-model="ai.provider" class="select" @change="onProviderChange">
|
||||||
|
<option v-for="p in ai.providers" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">Provider URL <span class="text-soft sm">— API base</span></label>
|
||||||
|
<input v-model="ai.provider_url" class="input" :placeholder="ai.provider === 'minimax_vl' ? 'https://api.minimaxi.com/v1' : 'https://api.openai.com/v1'" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">API Key <span class="text-soft sm">— 加密保存</span></label>
|
||||||
|
<input v-model="ai.api_key" type="password" class="input" :placeholder="ai.has_api_key ? '•••• 已配置(留空保持)' : 'sk-...'" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">模型 <span class="text-soft sm">— 必须支持多模态</span></label>
|
||||||
|
<input v-model="ai.model" class="input" :placeholder="ai.provider === 'minimax_vl' ? 'MiniMax-M3' : 'gpt-4o-mini / glm-4v-plus / ...'" />
|
||||||
|
</div>
|
||||||
|
<p v-if="msgs.ai" class="msg" :class="msgsAiOk ? 'ok' : 'err'">{{ msgs.ai }}</p>
|
||||||
|
<p v-if="msgs.test" class="msg" :class="msgsTestOk ? 'ok' : 'err'">{{ msgs.test }}</p>
|
||||||
|
<div class="row mt-3">
|
||||||
|
<button class="btn btn-primary" :disabled="busy.ai">{{ busy.ai ? '保存中…' : '保存配置' }}</button>
|
||||||
|
<button type="button" class="btn btn-ghost" @click="onTestAi" :disabled="busy.test">
|
||||||
|
{{ busy.test ? '测试中…' : '测试连接' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<details class="mt-3 ai-help" open>
|
||||||
|
<summary class="text-soft sm" style="cursor:pointer">Provider 说明</summary>
|
||||||
|
<div class="text-soft sm" style="margin:8px 0 0; line-height:1.7">
|
||||||
|
<p v-if="ai.provider === 'minimax_vl'">
|
||||||
|
<strong>MiniMax M3 多模态:</strong>原生多模态旗舰,支持图片/视频/桌面操作。Plus 套餐自带额度。
|
||||||
|
<br>· API base: <code>https://api.minimaxi.com/v1</code>
|
||||||
|
<br>· 模型: <code>MiniMax-M3</code>
|
||||||
|
<br>· 端点: <code>/chat/completions</code>(OpenAI 兼容协议)
|
||||||
|
<br>· 鉴权: <code>Bearer</code> 头(同 OpenAI)
|
||||||
|
<br>· API key: <a href="https://platform.minimaxi.com/user-center/basic-information/interface-key" target="_blank">platform.minimaxi.com</a> 获取(按量 / Token Plan 都可)
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
<strong>OpenAI 兼容:</strong>支持 OpenAI / 月之暗面 Kimi / 智谱 GLM-4V / DeepSeek-VL2 / Qwen-VL / 本地 Ollama 等。
|
||||||
|
<br>· 模型名: <code>gpt-4o-mini</code> / <code>glm-4v-plus</code> / <code>moonshot-v1-8k-vision</code> 等
|
||||||
|
<br>· 端点: <code>/chat/completions</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 系统信息 -->
|
||||||
|
<section class="card card-pad">
|
||||||
|
<h2 class="section-title">系统信息</h2>
|
||||||
|
<table class="data">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-soft">当前账号</td><td>{{ auth.user?.username }}</td></tr>
|
||||||
|
<tr><td class="text-soft">登录时间</td><td>{{ auth.user?.last_login_at || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">登录 IP</td><td>{{ auth.user?.last_login_ip || '—' }}</td></tr>
|
||||||
|
<tr><td class="text-soft">服务地址</td><td>http://{{ host }}</td></tr>
|
||||||
|
<tr><td class="text-soft">数据目录</td><td><code>MySQL: carlog</code></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Grocy 分类映射 -->
|
||||||
|
<section v-if="categories.length" class="card card-pad">
|
||||||
|
<h2 class="section-title">Grocy 分类映射</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">
|
||||||
|
你 Grocy 4.5.x 没开放分类 API,化学品列表暂时显示 group-ID。给每个 ID 配个真实名字就能正常显示。
|
||||||
|
</p>
|
||||||
|
<p v-if="msgs.category" class="msg ok">{{ msgs.category }}</p>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th style="width:80px">Grocy ID</th><th>显示名</th><th style="width:160px">操作</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in categories" :key="c.id">
|
||||||
|
<td><code>group-{{ c.id }}</code></td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
v-model="categoryDrafts[c.id]"
|
||||||
|
class="input"
|
||||||
|
:placeholder="c.is_mapped ? c.name : `给 group-${c.id} 起个名字`"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button class="btn btn-primary btn-sm" @click="saveCategoryMapping(c.id)" :disabled="busy.category || !(categoryDrafts[c.id]||'').trim()">保存</button>
|
||||||
|
<button v-if="c.is_mapped" class="btn btn-ghost btn-sm" @click="deleteCategoryMapping(c.id)">清空</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 调试 -->
|
||||||
|
<section class="card card-pad">
|
||||||
|
<h2 class="section-title">调试</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">调试模式开启时,所有 API 错误、Vue 组件错误、Promise 异常会显示在右下角浮层,可一键复制</p>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" :checked="debug.enabled" @change="debug.toggle()" />
|
||||||
|
<span>启用调试模式</span>
|
||||||
|
<span v-if="debug.enabled" class="pill pill-warn" style="margin-left:8px">{{ debug.count }} 条错误</span>
|
||||||
|
</label>
|
||||||
|
<div v-if="debug.enabled" class="mt-3 flex gap-2">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="debug.clear()">清空错误日志</button>
|
||||||
|
<span class="text-mute" style="font-size:12px; align-self:center">设置会保存到 localStorage</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 危险操作 -->
|
||||||
|
<section class="card card-pad danger-card">
|
||||||
|
<h2 class="section-title danger-title">⚠️ 危险操作</h2>
|
||||||
|
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">
|
||||||
|
一键清空所有业务数据(车辆、洗车、保养、加油、充电、保险),并可选择重新灌入演示数据。
|
||||||
|
<strong>管理员账户不会被删除。</strong>
|
||||||
|
</p>
|
||||||
|
<div class="danger-row">
|
||||||
|
<div class="danger-btns">
|
||||||
|
<button class="btn btn-danger-outline" @click="openResetModal('clear')" :disabled="busy.reset">
|
||||||
|
{{ busy.reset ? '处理中…' : '🗑 清空数据' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" @click="openResetModal('reset')" :disabled="busy.reset">
|
||||||
|
{{ busy.reset ? '处理中…' : '🔄 重置 + 灌演示数据' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="resetMsg" class="msg" :class="resetOk ? 'ok' : 'err'">{{ resetMsg }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 二次确认弹窗 -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="showResetModal" class="modal-overlay" @click.self="closeResetModal">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="modal-title">⚠️ 确认要{{ resetMode === 'reset' ? '重置所有数据' : '清空所有数据' }}吗?</h3>
|
||||||
|
<p class="modal-body-text">
|
||||||
|
<template v-if="resetMode === 'reset'">
|
||||||
|
这将<strong>删除所有车辆、洗车记录、保养、加油、充电、保险数据</strong>,并重新灌入演示数据。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
这将<strong>删除所有车辆、洗车记录、保养、加油、充电、保险数据</strong>(不可恢复)。
|
||||||
|
</template>
|
||||||
|
<br/>管理员账户 <code>admin2</code> 会保留。
|
||||||
|
</p>
|
||||||
|
<p class="modal-hint">请在下框输入 <strong>RESET-ALL-DATA</strong> 确认:</p>
|
||||||
|
<input
|
||||||
|
v-model="resetConfirmInput"
|
||||||
|
class="input modal-input"
|
||||||
|
:class="{ 'input-error': resetConfirmInput && resetConfirmInput !== 'RESET-ALL-DATA' }"
|
||||||
|
placeholder="RESET-ALL-DATA"
|
||||||
|
@keyup.enter="confirmReset"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn btn-ghost" @click="closeResetModal" :disabled="busy.reset">取消</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
@click="confirmReset"
|
||||||
|
:disabled="busy.reset || resetConfirmInput !== 'RESET-ALL-DATA'"
|
||||||
|
>
|
||||||
|
{{ busy.reset ? '处理中…' : '确认执行' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, onMounted, computed } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useDebugStore } from '../stores/debug';
|
||||||
|
import * as settingsApi from '../api/settings';
|
||||||
|
import * as authApi from '../api/auth';
|
||||||
|
import * as chemicalsApi from '../api/chemicals';
|
||||||
|
import * as aiApi from '../api/ai';
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const debug = useDebugStore();
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const weather = reactive({ cityDefault: '', city: '' });
|
||||||
|
const cityHint = ref('');
|
||||||
|
const grocy = reactive({ url: '', username: '', password: '', has_password: false });
|
||||||
|
const pass = reactive({ current: '', next: '', confirm: '' });
|
||||||
|
const ai = reactive({
|
||||||
|
provider: 'openai_compat',
|
||||||
|
providers: [
|
||||||
|
{ id: 'openai_compat', name: 'OpenAI 兼容(OpenAI / Kimi / DeepSeek / 硅基流动)' },
|
||||||
|
{ id: 'minimax_vl', name: 'MiniMax M3 多模态' },
|
||||||
|
],
|
||||||
|
provider_url: 'https://api.openai.com/v1',
|
||||||
|
api_key: '',
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
enabled: false,
|
||||||
|
has_api_key: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onProviderChange() {
|
||||||
|
// 切换 provider 时,自动填默认 URL 和模型(用户没手动改过的话)
|
||||||
|
const defaults = {
|
||||||
|
openai_compat: { url: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||||||
|
minimax_vl: { url: 'https://api.minimaxi.com/v1', model: 'MiniMax-M3' },
|
||||||
|
};
|
||||||
|
const d = defaults[ai.provider];
|
||||||
|
if (d) {
|
||||||
|
// 只有当前是某个 provider 的默认值时才覆盖,避免覆盖用户改过的
|
||||||
|
const isCurrentDefault =
|
||||||
|
ai.provider_url === 'https://api.openai.com/v1' ||
|
||||||
|
ai.provider_url === 'https://api.minimaxi.com/v1' ||
|
||||||
|
ai.model === 'gpt-4o-mini' ||
|
||||||
|
ai.model === 'MiniMax-M3';
|
||||||
|
if (isCurrentDefault) {
|
||||||
|
ai.provider_url = d.url;
|
||||||
|
ai.model = d.model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const busy = reactive({ weather: false, grocy: false, pass: false, testWx: false, category: false, ai: false, test: false, grocyLog: false, reset: false });
|
||||||
|
const msgs = reactive({ weather: '', grocy: '', category: '', ai: '', test: '' });
|
||||||
|
const msgsAiOk = ref(false);
|
||||||
|
const msgsTestOk = ref(false);
|
||||||
|
const msgsGrocyOk = ref(false);
|
||||||
|
const passMsg = ref('');
|
||||||
|
const passOk = ref(false);
|
||||||
|
const grocyLogList = ref([]);
|
||||||
|
|
||||||
|
// 重置相关
|
||||||
|
const showResetModal = ref(false);
|
||||||
|
const resetMode = ref('reset'); // 'reset' = 清空+灌数据, 'clear' = 只清空
|
||||||
|
const resetConfirmInput = ref('');
|
||||||
|
const resetMsg = ref('');
|
||||||
|
const resetOk = ref(false);
|
||||||
|
|
||||||
|
// 分类映射
|
||||||
|
const categories = ref([]); // [{id, name, is_mapped}]
|
||||||
|
const categoryDrafts = ref({}); // id -> 输入框内容
|
||||||
|
const location_ = window.location;
|
||||||
|
const host = location_.host;
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await settingsApi.get();
|
||||||
|
const s = r.data || {};
|
||||||
|
// 扁平 {key: value, ...} → 按 group 拆出子对象
|
||||||
|
Object.assign(weather, {
|
||||||
|
cityDefault: s.app_city_default || '',
|
||||||
|
city: s.app_city || '',
|
||||||
|
});
|
||||||
|
// 加载城市状态
|
||||||
|
try {
|
||||||
|
const cr = await settingsApi.getCity();
|
||||||
|
const cd = cr.data || {};
|
||||||
|
if (cd.is_auto_today) {
|
||||||
|
cityHint.value = cd.default_city
|
||||||
|
? `默认城市「${cd.default_city}」生效`
|
||||||
|
: '将根据 IP 自动定位';
|
||||||
|
} else {
|
||||||
|
cityHint.value = `已保存「${cd.saved_city}」,今日 24:00 前有效`;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
Object.assign(grocy, {
|
||||||
|
url: s.grocy_url || '',
|
||||||
|
username: s.grocy_username || '',
|
||||||
|
password: '',
|
||||||
|
has_password: !!s.grocy_username,
|
||||||
|
});
|
||||||
|
// 加载 Grocy 同步历史
|
||||||
|
try {
|
||||||
|
grocyLogList.value = (await settingsApi.grocyLogs(20)).data || [];
|
||||||
|
} catch {}
|
||||||
|
// 加载 AI 配置
|
||||||
|
try {
|
||||||
|
const aiR = await aiApi.getConfig();
|
||||||
|
const ac = aiR.data || {};
|
||||||
|
Object.assign(ai, {
|
||||||
|
provider: ac.provider || 'openai_compat',
|
||||||
|
providers: ac.providers || ai.providers,
|
||||||
|
provider_url: ac.provider_url || 'https://api.openai.com/v1',
|
||||||
|
model: ac.model || 'gpt-4o-mini',
|
||||||
|
enabled: !!ac.enabled,
|
||||||
|
has_api_key: !!ac.has_api_key,
|
||||||
|
api_key: '', // 不回显,已配置的话让用户留空保持
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
} catch (e) { error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败'; }
|
||||||
|
finally { loading.value = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSaveSettings(group, data) {
|
||||||
|
busy[group] = true;
|
||||||
|
msgs[group] = '';
|
||||||
|
try {
|
||||||
|
// client 字段 → server settings key
|
||||||
|
let settings = {};
|
||||||
|
if (group === 'weather') {
|
||||||
|
settings = {
|
||||||
|
app_city_default: data.cityDefault || '',
|
||||||
|
app_city: data.city || 'auto',
|
||||||
|
};
|
||||||
|
// 保存后刷新提示
|
||||||
|
if (data.city) {
|
||||||
|
cityHint.value = `已保存「${data.city}」,今日 24:00 前有效`;
|
||||||
|
} else if (data.cityDefault) {
|
||||||
|
cityHint.value = `默认城市「${data.cityDefault}」生效`;
|
||||||
|
} else {
|
||||||
|
cityHint.value = '将根据 IP 自动定位';
|
||||||
|
}
|
||||||
|
} else if (group === 'grocy') {
|
||||||
|
settings = {
|
||||||
|
grocy_url: data.url,
|
||||||
|
grocy_username: data.username,
|
||||||
|
};
|
||||||
|
if (data.password) {
|
||||||
|
settings.grocy_password = data.password;
|
||||||
|
grocy.has_password = true;
|
||||||
|
}
|
||||||
|
// 保存后刷新同步历史
|
||||||
|
try { grocyLogList.value = (await settingsApi.grocyLogs(20)).data || []; } catch {}
|
||||||
|
}
|
||||||
|
await settingsApi.update({ group, settings });
|
||||||
|
msgs[group] = `✓ 已保存(${new Date().toLocaleTimeString()})`;
|
||||||
|
setTimeout(() => msgs[group] = '', 3000);
|
||||||
|
} catch (e) { msgs[group] = '✗ ' + (e.response?.data?.message || e.response?.data?.code || e.message || '保存失败'); }
|
||||||
|
finally { busy[group] = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChangePass() {
|
||||||
|
passMsg.value = '';
|
||||||
|
passOk.value = false;
|
||||||
|
if (pass.next !== pass.confirm) { passMsg.value = '两次输入的新密码不一致'; return; }
|
||||||
|
if (pass.next.length < 6) { passMsg.value = '新密码至少 6 位'; return; }
|
||||||
|
busy.pass = true;
|
||||||
|
try {
|
||||||
|
await authApi.changeAccount({ current_password: pass.current, new_password: pass.next });
|
||||||
|
passOk.value = true;
|
||||||
|
passMsg.value = '✓ 密码已更新';
|
||||||
|
pass.current = pass.next = pass.confirm = '';
|
||||||
|
} catch (e) {
|
||||||
|
passMsg.value = e.response?.data?.message || e.response?.data?.code || e.message || '修改失败';
|
||||||
|
} finally { busy.pass = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTestWeather() {
|
||||||
|
busy.testWx = true;
|
||||||
|
msgs.weather = '';
|
||||||
|
try {
|
||||||
|
// 先保存城市设置
|
||||||
|
await settingsApi.update({ group: 'weather', settings: { app_city: weather.city || 'auto' } });
|
||||||
|
// 再请求天气(后端有当天缓存,不会重复请求 wttr)
|
||||||
|
const r = await settingsApi.getWeather();
|
||||||
|
const w = r.data;
|
||||||
|
if (w) {
|
||||||
|
cityHint.value = `已拉取「${w.city}」今日天气:${w.weather_desc} ${w.temp_c}℃(${w.from_cache ? '来自缓存' : '实时获取'})`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msgs.weather = '✗ 拉取失败:' + (e.response?.data?.message || e.message);
|
||||||
|
} finally { busy.testWx = false; }
|
||||||
|
}
|
||||||
|
async function saveCategoryMapping(id) {
|
||||||
|
const name = (categoryDrafts.value[id] || '').trim();
|
||||||
|
if (!name) return;
|
||||||
|
busy.category = true;
|
||||||
|
msgs.category = '';
|
||||||
|
try {
|
||||||
|
// 合并所有 mappings(避免覆盖其他)
|
||||||
|
const all = categories.value.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.id === id ? name : (categoryDrafts.value[c.id] || c.name || '').trim(),
|
||||||
|
})).filter(x => x.name);
|
||||||
|
await chemicalsApi.saveCategoryMappings(all);
|
||||||
|
msgs.category = `✓ 已保存「${name}」`;
|
||||||
|
// 刷新
|
||||||
|
const r = await chemicalsApi.getCategories();
|
||||||
|
const list = Array.isArray(r.data) ? r.data : [];
|
||||||
|
categories.value = list;
|
||||||
|
for (const c of list) categoryDrafts.value[c.id] = c.is_mapped ? c.name : '';
|
||||||
|
setTimeout(() => { msgs.category = ''; }, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
msgs.category = '✗ 保存失败:' + (e.response?.data?.message || e.message);
|
||||||
|
} finally { busy.category = false; }
|
||||||
|
}
|
||||||
|
async function deleteCategoryMapping(id) {
|
||||||
|
busy.category = true;
|
||||||
|
try {
|
||||||
|
await chemicalsApi.deleteCategoryMapping(id);
|
||||||
|
const r = await chemicalsApi.getCategories();
|
||||||
|
const list = Array.isArray(r.data) ? r.data : [];
|
||||||
|
categories.value = list;
|
||||||
|
for (const c of list) categoryDrafts.value[c.id] = c.is_mapped ? c.name : '';
|
||||||
|
} finally { busy.category = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveAi() {
|
||||||
|
busy.ai = true;
|
||||||
|
msgs.ai = '';
|
||||||
|
msgsAiOk.value = false;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
provider: ai.provider,
|
||||||
|
provider_url: ai.provider_url,
|
||||||
|
model: ai.model,
|
||||||
|
enabled: ai.enabled,
|
||||||
|
};
|
||||||
|
if (ai.api_key) body.api_key = ai.api_key; // 用户填了才更新
|
||||||
|
await aiApi.saveConfig(body);
|
||||||
|
msgsAiOk.value = true;
|
||||||
|
msgs.ai = '✓ 已保存(' + new Date().toLocaleTimeString() + ')';
|
||||||
|
ai.has_api_key = ai.has_api_key || !!ai.api_key;
|
||||||
|
ai.api_key = '';
|
||||||
|
setTimeout(() => msgs.ai = '', 3000);
|
||||||
|
} catch (e) {
|
||||||
|
msgs.ai = '✗ ' + (e.response?.data?.error?.message || e.message || '保存失败');
|
||||||
|
} finally { busy.ai = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onTestAi() {
|
||||||
|
busy.test = true;
|
||||||
|
msgs.test = '';
|
||||||
|
msgsTestOk.value = false;
|
||||||
|
try {
|
||||||
|
const body = { provider: ai.provider, provider_url: ai.provider_url, model: ai.model };
|
||||||
|
if (ai.api_key) body.api_key = ai.api_key;
|
||||||
|
const r = await aiApi.test(body);
|
||||||
|
msgsTestOk.value = true;
|
||||||
|
msgs.test = `✓ 连通 (provider: ${r.data.provider}, model: ${r.data.model}) · 返「${r.data.reply}」`;
|
||||||
|
} catch (e) {
|
||||||
|
msgs.test = '✗ ' + (e.response?.data?.error?.message || e.message || '测试失败');
|
||||||
|
} finally { busy.test = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onClearGrocy() {
|
||||||
|
if (!confirm('确定要清空 Grocy 配置吗?化学品页的同步按钮将不可用。')) return;
|
||||||
|
busy.grocy = true;
|
||||||
|
msgs.grocy = '';
|
||||||
|
msgsGrocyOk.value = false;
|
||||||
|
try {
|
||||||
|
await settingsApi.update({ group: 'grocy', settings: { grocy_url: '', grocy_username: '' } });
|
||||||
|
grocy.url = '';
|
||||||
|
grocy.username = '';
|
||||||
|
grocy.has_password = false;
|
||||||
|
grocy.password = '';
|
||||||
|
msgsGrocyOk.value = true;
|
||||||
|
msgs.grocy = '✓ 已清空,化学品同步已禁用';
|
||||||
|
setTimeout(() => { msgs.grocy = ''; }, 4000);
|
||||||
|
} catch (e) {
|
||||||
|
msgs.grocy = '✗ 清空失败:' + (e.response?.data?.message || e.message);
|
||||||
|
} finally { busy.grocy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openResetModal(mode) {
|
||||||
|
resetMode.value = mode;
|
||||||
|
resetConfirmInput.value = '';
|
||||||
|
resetMsg.value = '';
|
||||||
|
resetOk.value = false;
|
||||||
|
showResetModal.value = true;
|
||||||
|
}
|
||||||
|
function closeResetModal() {
|
||||||
|
if (busy.reset) return;
|
||||||
|
showResetModal.value = false;
|
||||||
|
resetConfirmInput.value = '';
|
||||||
|
}
|
||||||
|
async function confirmReset() {
|
||||||
|
if (resetConfirmInput.value !== 'RESET-ALL-DATA' || busy.reset) return;
|
||||||
|
busy.reset = true;
|
||||||
|
resetMsg.value = '';
|
||||||
|
resetOk.value = false;
|
||||||
|
try {
|
||||||
|
const r = await settingsApi.resetAll('RESET-ALL-DATA', resetMode.value === 'reset');
|
||||||
|
resetOk.value = true;
|
||||||
|
resetMsg.value = r.data.message || '✅ 完成,请刷新页面';
|
||||||
|
setTimeout(() => {
|
||||||
|
showResetModal.value = false;
|
||||||
|
resetConfirmInput.value = '';
|
||||||
|
}, 1500);
|
||||||
|
} catch (e) {
|
||||||
|
resetOk.value = false;
|
||||||
|
resetMsg.value = '✗ ' + (e.response?.data?.error?.message || e.response?.data?.code || e.message || '失败');
|
||||||
|
} finally { busy.reset = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||||
|
.section-title { font-size: 16px; font-weight: 600; margin: 0 0 16px; }
|
||||||
|
.row { display: flex; gap: 8px; }
|
||||||
|
.msg { font-size: 13px; margin: 8px 0 0; }
|
||||||
|
.msg.ok { color: #2E8A6B; }
|
||||||
|
.msg.err { color: var(--danger); }
|
||||||
|
.hint { font-size: 12px; color: var(--text-soft); margin: 6px 0 0; }
|
||||||
|
.text-ok { color: #2E8A6B; }
|
||||||
|
.text-err { color: var(--danger); }
|
||||||
|
.log-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; }
|
||||||
|
.log-table th { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); color: var(--text-soft); font-weight: 500; }
|
||||||
|
.log-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); }
|
||||||
|
.log-table tr:last-child td { border-bottom: none; }
|
||||||
|
code { font-size: 12px; background: var(--bg-soft); padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.ai-help { background: var(--bg-soft); border-radius: var(--radius-sm); padding: 10px 14px; }
|
||||||
|
.ai-help summary { user-select: none; }
|
||||||
|
.ai-help ul { list-style: disc; }
|
||||||
|
@media (max-width: 900px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.settings-grid { gap: 12px; }
|
||||||
|
.settings-card { padding: 14px 16px; }
|
||||||
|
.form-row { grid-template-columns: 1fr; gap: 8px; }
|
||||||
|
.tabs { flex-wrap: wrap; overflow-x: auto; }
|
||||||
|
.tab { white-space: nowrap; }
|
||||||
|
.modal-mask { align-items: flex-end; padding: 0; }
|
||||||
|
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||||
|
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||||
|
.modal-actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.modal-actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style scoped>
|
||||||
|
/* 危险操作区 */
|
||||||
|
.danger-card { border-color: #C0392B33; }
|
||||||
|
.danger-title { color: #C0392B; }
|
||||||
|
.danger-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
.danger-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.btn-danger { background: #C0392B; color: #fff; border: 1px solid #C0392B; padding: 8px 16px; border-radius: var(--radius); font-size: 14px; cursor: pointer; font-weight: 500; transition: background .15s; }
|
||||||
|
.btn-danger:hover:not(:disabled) { background: #A93226; }
|
||||||
|
.btn-danger:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn-danger-outline { background: transparent; color: #C0392B; border: 1px solid #C0392B55; padding: 8px 16px; border-radius: var(--radius); font-size: 14px; cursor: pointer; font-weight: 500; transition: all .15s; }
|
||||||
|
.btn-danger-outline:hover:not(:disabled) { background: #C0392B15; border-color: #C0392B; }
|
||||||
|
.btn-danger-outline:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* 二次确认弹窗 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 9999; backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.modal-box {
|
||||||
|
background: var(--card-bg); border-radius: 12px; padding: 28px;
|
||||||
|
max-width: 440px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-title { font-size: 18px; font-weight: 600; margin: 0 0 12px; color: #C0392B; }
|
||||||
|
.modal-body-text { font-size: 14px; line-height: 1.6; color: var(--text-soft); margin: 0 0 12px; }
|
||||||
|
.modal-hint { font-size: 13px; color: var(--text-soft); margin: 0 0 8px; }
|
||||||
|
.modal-input { width: 100%; font-size: 16px; letter-spacing: .05em; box-sizing: border-box; }
|
||||||
|
.input-error { border-color: var(--danger) !important; }
|
||||||
|
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<h1 class="title">统计</h1>
|
||||||
|
<p class="text-soft" style="margin: 4px 0 24px; font-size:14px">历史数据汇总</p>
|
||||||
|
|
||||||
|
<!-- 月度报表下载 -->
|
||||||
|
<div class="card card-pad mb-4">
|
||||||
|
<div class="report-head">
|
||||||
|
<div>
|
||||||
|
<h3 class="report-title">月度报表</h3>
|
||||||
|
<p class="text-mute sm" style="margin: 4px 0 0">按月生成 Excel / PDF 报表:洗车、加油、充电、保养、保险 + 化学品 Top</p>
|
||||||
|
</div>
|
||||||
|
<div class="report-actions">
|
||||||
|
<select v-model="reportMonth" class="select" style="min-width: 140px">
|
||||||
|
<option v-for="m in months" :key="m" :value="m">{{ m }}</option>
|
||||||
|
</select>
|
||||||
|
<a :href="excelUrl" class="btn btn-primary" :download="`carwash-${reportMonth}.xlsx`">
|
||||||
|
📊 下载 Excel
|
||||||
|
</a>
|
||||||
|
<a :href="pdfUrl" class="btn btn-ghost" :download="`carwash-${reportMonth}.pdf`">
|
||||||
|
📄 下载 PDF
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<StatCard title="总洗车次数" :value="stats.total_washes || 0" :hint="`累计 ${stats.total_washes || 0} 次`" />
|
||||||
|
<StatCard title="总花费" :value="'¥ ' + (stats.total_cost || 0).toFixed(2)" :hint="`日均 ¥ ${(stats.total_cost / Math.max(stats.days, 1)).toFixed(2)}`" />
|
||||||
|
<StatCard title="平均间隔" :value="(stats.avg_interval || 0) + ' 天'" hint="两次洗车之间" />
|
||||||
|
<StatCard title="启用车辆" :value="stats.active_vehicles || 0" :hint="`共 ${stats.total_vehicles || 0} 辆`" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-6">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">近 12 月洗车频次</h3>
|
||||||
|
<div class="chart-wrap"><canvas ref="monthCanvas"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">花费趋势</h3>
|
||||||
|
<div class="chart-wrap"><canvas ref="costCanvas"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-6">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">车辆花费 Top</h3>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>车辆</th><th>次数</th><th>花费</th><th>占比</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in stats.vehicle_breakdown || []" :key="r.id">
|
||||||
|
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||||
|
<td>{{ r.count }}</td>
|
||||||
|
<td>¥ {{ (r.cost || 0).toFixed(2) }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="bar-wrap">
|
||||||
|
<div class="bar" :style="{ width: (r.pct || 0) + '%' }"></div>
|
||||||
|
<span class="bar-text">{{ (r.pct || 0).toFixed(1) }}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!stats.vehicle_breakdown?.length"><td colspan="4" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">化学品 Top 5</h3>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>名称</th><th>累计用量</th><th>次数</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in stats.chemical_top || []" :key="c.grocy_product_id">
|
||||||
|
<td><strong>{{ c.name }}</strong></td>
|
||||||
|
<td>{{ c.total_amount }} {{ c.unit || '' }}</td>
|
||||||
|
<td>{{ c.count }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!stats.chemical_top?.length"><td colspan="3" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3 个真正有用的图:油价 / 年均养护 / 季节 -->
|
||||||
|
<div class="row mt-6">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">油价趋势 <span class="text-mute sm">(按月)</span></h3>
|
||||||
|
<div class="chart-wrap"><canvas ref="fuelCanvas"></canvas></div>
|
||||||
|
<p class="text-mute sm" style="margin: 8px 0 0">看你是越加越贵还是赶上了降价;连续 3 个月 +5% 就要考虑改加油时机</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">年均养护成本 <span class="text-mute sm">(按车辆)</span></h3>
|
||||||
|
<div class="chart-wrap"><canvas ref="annualCanvas"></canvas></div>
|
||||||
|
<p class="text-mute sm" style="margin: 8px 0 0">洗车+加油+充电+保养+保险 / 持有天数 × 365,看哪台车最费钱</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-6">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">洗车频率 vs 季节 <span class="text-mute sm">(按月)</span></h3>
|
||||||
|
<div class="chart-wrap"><canvas ref="seasonCanvas"></canvas></div>
|
||||||
|
<p class="text-mute sm" style="margin: 8px 0 0">看你什么时候最勤快:雨季前扎堆 vs 冬天摆烂?</p>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">各车成本明细</h3>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>车辆</th><th>持有</th><th class="r">终身</th><th class="r">年化</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in extraData.costPerVehicle || []" :key="r.id">
|
||||||
|
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||||
|
<td>{{ Math.round(r.days_owned) }} 天</td>
|
||||||
|
<td class="r">¥{{ Number(r.lifetime_cost).toFixed(0) }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ Number(r.annual_cost).toFixed(0) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!extraData.costPerVehicle?.length"><td colspan="4" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 用车成本(保养/加油/充电)-->
|
||||||
|
<div class="row mt-6">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">用车成本构成(按车辆)</h3>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>车辆</th><th class="r">洗车</th><th class="r">保养</th><th class="r">加油</th><th class="r">充电</th><th class="r">合计</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in usageCost" :key="r.id">
|
||||||
|
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||||
|
<td class="r">¥{{ r.wash.toFixed(0) }}</td>
|
||||||
|
<td class="r">¥{{ r.maint.toFixed(0) }}</td>
|
||||||
|
<td class="r">¥{{ r.refuel.toFixed(0) }}</td>
|
||||||
|
<td class="r">¥{{ r.charge.toFixed(0) }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ r.total.toFixed(0) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!usageCost.length"><td colspan="6" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="chart-title">油耗/电耗(按车辆)</h3>
|
||||||
|
<table class="data">
|
||||||
|
<thead><tr><th>车辆</th><th>加油</th><th>充电</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in consumption" :key="r.id">
|
||||||
|
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||||
|
<td>
|
||||||
|
<span v-if="r.l_per_100km">平均 <strong>{{ r.l_per_100km }}</strong> L/100km<br /><span class="text-mute sm">总 {{ r.total_liters }}L</span></span>
|
||||||
|
<span v-else class="text-mute sm">需加满+里程</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="r.kwh_per_100km">平均 <strong>{{ r.kwh_per_100km }}</strong> kWh/100km<br /><span class="text-mute sm">总 {{ r.total_kwh }} kWh</span></span>
|
||||||
|
<span v-else class="text-mute sm">需里程</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!consumption.length"><td colspan="3" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import StatCard from '../components/StatCard.vue';
|
||||||
|
import * as settingsApi from '../api/settings';
|
||||||
|
import { reportMonths, reportExcelUrl, reportPdfUrl } from '../api/settings';
|
||||||
|
import { maintApi, refuelApi, chargingApi } from '../api/logs';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
const stats = ref({});
|
||||||
|
const usageCost = ref([]);
|
||||||
|
const consumption = ref([]);
|
||||||
|
const extraData = ref({ fuelTrend: [], costPerVehicle: [], washSeason: [] });
|
||||||
|
const loading = ref(true);
|
||||||
|
const monthCanvas = ref(null);
|
||||||
|
const costCanvas = ref(null);
|
||||||
|
const fuelCanvas = ref(null);
|
||||||
|
const annualCanvas = ref(null);
|
||||||
|
const seasonCanvas = ref(null);
|
||||||
|
let monthChart = null, costChart = null, fuelChart = null, annualChart = null, seasonChart = null;
|
||||||
|
|
||||||
|
// 月度报表
|
||||||
|
const months = ref([]);
|
||||||
|
const reportMonth = ref(new Date().toISOString().slice(0, 7));
|
||||||
|
const excelUrl = computed(() => reportExcelUrl(reportMonth.value));
|
||||||
|
const pdfUrl = computed(() => reportPdfUrl(reportMonth.value));
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// 拉可用月份
|
||||||
|
try {
|
||||||
|
const mR = await reportMonths(12);
|
||||||
|
months.value = mR.data?.months || [];
|
||||||
|
if (months.value.length) reportMonth.value = months.value[0];
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const [r, vR, mR, rR, cR, extraR] = await Promise.all([
|
||||||
|
settingsApi.overview(),
|
||||||
|
vehiclesApi.list(),
|
||||||
|
maintApi.list({ limit: 100 }),
|
||||||
|
refuelApi.list({ limit: 100 }),
|
||||||
|
chargingApi.list({ limit: 100 }),
|
||||||
|
// 3 个真正有用的图
|
||||||
|
fetch('/api/stats/extra', { credentials: 'same-origin' }).then(x => x.json()).catch(() => ({ data: { fuelTrend: [], costPerVehicle: [], washSeason: [] } })),
|
||||||
|
]);
|
||||||
|
const d = r.data || {};
|
||||||
|
stats.value = {
|
||||||
|
...(d.overview || {}),
|
||||||
|
monthly_freq: d.monthly_freq || [],
|
||||||
|
monthly_cost: d.monthly_cost || [],
|
||||||
|
vehicle_breakdown: d.vehicle_breakdown || [],
|
||||||
|
chemical_top: d.chemical_top || [],
|
||||||
|
};
|
||||||
|
// 算按车辆成本
|
||||||
|
const vehicles = vR.data || [];
|
||||||
|
const maints = mR.data?.rows || [];
|
||||||
|
const refuels = rR.data?.rows || [];
|
||||||
|
const charges = cR.data?.rows || [];
|
||||||
|
usageCost.value = vehicles.map(v => {
|
||||||
|
const wash = (stats.value.vehicle_breakdown || []).find(x => x.id === v.id)?.cost || 0;
|
||||||
|
const maint = maints.filter(x => x.vehicle_id === v.id).reduce((s, x) => s + (x.total_cost || 0), 0);
|
||||||
|
const refuel = refuels.filter(x => x.vehicle_id === v.id).reduce((s, x) => s + (x.total_cost || 0), 0);
|
||||||
|
const charge = charges.filter(x => x.vehicle_id === v.id).reduce((s, x) => s + (x.total_cost || 0), 0);
|
||||||
|
return { ...v, wash, maint, refuel, charge, total: wash + maint + refuel + charge };
|
||||||
|
}).sort((a, b) => b.total - a.total);
|
||||||
|
// 算油耗/电耗(只算有有效数据的车辆)
|
||||||
|
consumption.value = vehicles.map(v => {
|
||||||
|
const myRefuels = refuels.filter(x => x.vehicle_id === v.id && x.consumption_100km > 0);
|
||||||
|
const myCharges = charges.filter(x => x.vehicle_id === v.id && x.kwh_per_100km > 0);
|
||||||
|
return {
|
||||||
|
id: v.id, name: v.name, plate: v.plate,
|
||||||
|
l_per_100km: myRefuels.length ? (myRefuels.reduce((s,x) => s + x.consumption_100km, 0) / myRefuels.length).toFixed(2) : null,
|
||||||
|
total_liters: refuels.filter(x => x.vehicle_id === v.id).reduce((s,x) => s + (x.liters || 0), 0).toFixed(1),
|
||||||
|
kwh_per_100km: myCharges.length ? (myCharges.reduce((s,x) => s + x.kwh_per_100km, 0) / myCharges.length).toFixed(2) : null,
|
||||||
|
total_kwh: charges.filter(x => x.vehicle_id === v.id).reduce((s,x) => s + (x.kwh || 0), 0).toFixed(1),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// 关键顺序:先 loading=false 让 chart-card 进入 DOM,再 nextTick 等挂载完成,
|
||||||
|
// 然后才能拿到 canvas ref。
|
||||||
|
loading.value = false;
|
||||||
|
await nextTick();
|
||||||
|
drawMonth(stats.value.monthly_freq || []);
|
||||||
|
drawCost(stats.value.monthly_cost || []);
|
||||||
|
// 3 个新图
|
||||||
|
extraData.value = extraR.data || { fuelTrend: [], costPerVehicle: [], washSeason: [] };
|
||||||
|
drawFuel(extraData.value.fuelTrend || []);
|
||||||
|
drawAnnual(extraData.value.costPerVehicle || []);
|
||||||
|
drawSeason(extraData.value.washSeason || []);
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawMonth(data) {
|
||||||
|
if (!monthCanvas.value) return;
|
||||||
|
if (monthChart) monthChart.destroy();
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
monthChart = new Chart(monthCanvas.value, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: hasData ? data.map(d => d.month) : ['暂无数据'],
|
||||||
|
datasets: [{
|
||||||
|
data: hasData ? data.map(d => d.count) : [0],
|
||||||
|
borderColor: '#4DBA9A', backgroundColor: 'rgba(77, 186, 154, 0.12)',
|
||||||
|
tension: 0.3, fill: true, pointRadius: 4, pointBackgroundColor: '#4DBA9A',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: hasData } },
|
||||||
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function drawCost(data) {
|
||||||
|
if (!costCanvas.value) return;
|
||||||
|
if (costChart) costChart.destroy();
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
costChart = new Chart(costCanvas.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: hasData ? data.map(d => d.month) : ['暂无数据'],
|
||||||
|
datasets: [{
|
||||||
|
data: hasData ? data.map(d => d.cost) : [0],
|
||||||
|
backgroundColor: '#1E5B8A', borderRadius: 6,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: hasData } },
|
||||||
|
scales: { y: { beginAtZero: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 油价趋势(按月)— 优先用 unit_price,没记单价时用 amount/liters 兜底
|
||||||
|
function drawFuel(data) {
|
||||||
|
if (!fuelCanvas.value) return;
|
||||||
|
if (fuelChart) fuelChart.destroy();
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
const price = hasData ? data.map(d => d.derived_unit_price || 0) : [0];
|
||||||
|
fuelChart = new Chart(fuelCanvas.value, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: hasData ? data.map(d => d.ym) : ['暂无数据'],
|
||||||
|
datasets: [{
|
||||||
|
label: '元/升',
|
||||||
|
data: price,
|
||||||
|
borderColor: '#E89653', backgroundColor: 'rgba(232, 150, 83, 0.15)',
|
||||||
|
tension: 0.3, fill: true, pointRadius: 4, pointBackgroundColor: '#E89653',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: hasData, callbacks: { label: (ctx) => `¥${ctx.parsed.y.toFixed(2)}/L` } } },
|
||||||
|
scales: { y: { beginAtZero: false, ticks: { callback: (v) => `¥${v}` } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 年均养护成本(按车辆,水平条形图)
|
||||||
|
function drawAnnual(data) {
|
||||||
|
if (!annualCanvas.value) return;
|
||||||
|
if (annualChart) annualChart.destroy();
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
// 画前 8 名,避免标签拥挤
|
||||||
|
const top = hasData ? data.slice(0, 8).reverse() : [];
|
||||||
|
annualChart = new Chart(annualCanvas.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: hasData ? top.map(d => `${d.name}${d.plate ? ' (' + d.plate + ')' : ''}`) : ['暂无数据'],
|
||||||
|
datasets: [{
|
||||||
|
label: '年化 ¥',
|
||||||
|
data: hasData ? top.map(d => Number(d.annual_cost) || 0) : [0],
|
||||||
|
backgroundColor: '#4DBA9A', borderRadius: 6,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: hasData, callbacks: { label: (ctx) => `¥${ctx.parsed.x.toFixed(0)} / 年` } } },
|
||||||
|
scales: { x: { beginAtZero: true, ticks: { callback: (v) => `¥${v}` } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 洗车频率 vs 季节 — 柱状图 + 季度背景色
|
||||||
|
function drawSeason(data) {
|
||||||
|
if (!seasonCanvas.value) return;
|
||||||
|
if (seasonChart) seasonChart.destroy();
|
||||||
|
const hasData = data && data.length > 0;
|
||||||
|
const monthColor = ['#1E5B8A','#1E5B8A','#4DBA9A','#4DBA9A','#4DBA9A','#E89653','#E89653','#E89653','#D17A3A','#D17A3A','#1E5B8A','#1E5B8A'];
|
||||||
|
seasonChart = new Chart(seasonCanvas.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: hasData ? data.map(d => d.ym) : ['暂无数据'],
|
||||||
|
datasets: [{
|
||||||
|
label: '洗车次数',
|
||||||
|
data: hasData ? data.map(d => d.cnt) : [0],
|
||||||
|
backgroundColor: hasData ? data.map(d => monthColor[(d.month || 1) - 1] || '#4DBA9A') : ['#ccc'],
|
||||||
|
borderRadius: 6,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { display: false }, tooltip: { enabled: hasData } },
|
||||||
|
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap { position: relative; height: 200px; width: 100%; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||||
|
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||||
|
.mb-4 { margin-bottom: 18px; }
|
||||||
|
.report-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||||
|
.report-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0; }
|
||||||
|
.report-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.report-actions .select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; background: var(--bg); }
|
||||||
|
.chart-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 16px; }
|
||||||
|
.bar-wrap { position: relative; height: 18px; background: var(--bg-soft); border-radius: 4px; overflow: hidden; min-width: 100px; }
|
||||||
|
.bar { background: var(--green); height: 100%; border-radius: 4px; }
|
||||||
|
.bar-text { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--text-soft); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head .btn { width: 100%; justify-content: center; }
|
||||||
|
.kpi-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.kpi-val { font-size: 22px; }
|
||||||
|
.filter-row { grid-template-columns: 1fr; }
|
||||||
|
.chart-wrap { height: 160px; }
|
||||||
|
.breakdown { grid-template-columns: 1fr; }
|
||||||
|
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.kpi-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">
|
||||||
|
{{ vehicle.name }}
|
||||||
|
<span v-if="!vehicle.is_active" class="pill pill-danger ml-2">停用</span>
|
||||||
|
</h1>
|
||||||
|
<p class="subtitle text-soft">
|
||||||
|
<router-link to="/vehicles" class="text-soft">← 返回车辆列表</router-link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<router-link :to="`/vehicles/${vehicle.id}/edit`" class="btn btn-ghost">编辑车辆</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 车辆基本信息 + 累计 -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">车辆信息</h3>
|
||||||
|
<table class="data">
|
||||||
|
<tbody>
|
||||||
|
<tr><td class="text-soft" style="width:120px">车牌</td><td><strong>{{ vehicle.plate }}</strong></td></tr>
|
||||||
|
<tr><td class="text-soft">类型</td><td>{{ typeLabel(vehicle.type) }}</td></tr>
|
||||||
|
<tr>
|
||||||
|
<td class="text-soft">动力</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['pill', powertrainPill(vehicle.powertrain)]">
|
||||||
|
{{ powertrainIcon(vehicle.powertrain) }} {{ powertrainLabel(vehicle.powertrain) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="vehicle.color"><td class="text-soft">颜色</td><td>{{ vehicle.color }}</td></tr>
|
||||||
|
<tr v-if="vehicle.notes"><td class="text-soft">备注</td><td>{{ vehicle.notes }}</td></tr>
|
||||||
|
<tr v-if="health?.current_km">
|
||||||
|
<td class="text-soft">当前里程</td>
|
||||||
|
<td><strong>{{ health.current_km.toLocaleString() }} km</strong></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">累计数据</h3>
|
||||||
|
<div class="big-stats">
|
||||||
|
<div><span class="text-soft sm">洗车</span><strong>{{ health?.totals?.wash_count || 0 }}</strong><span class="text-mute sm">次 · ¥{{ (health?.totals?.wash_cost || 0).toFixed(0) }}</span></div>
|
||||||
|
<div><span class="text-soft sm">保养</span><strong>{{ health?.totals?.maint_count || 0 }}</strong><span class="text-mute sm">次 · ¥{{ (health?.totals?.maint_cost || 0).toFixed(0) }}</span></div>
|
||||||
|
<div><span class="text-soft sm">加油</span><strong>{{ (health?.totals?.refuel_liters || 0).toFixed(0) }}L</strong><span class="text-mute sm">¥{{ (health?.totals?.refuel_cost || 0).toFixed(0) }}</span></div>
|
||||||
|
<div><span class="text-soft sm">充电</span><strong>{{ (health?.totals?.charge_kwh || 0).toFixed(0) }}kWh</strong><span class="text-mute sm">¥{{ (health?.totals?.charge_cost || 0).toFixed(0) }}</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="grand-total mt-3">
|
||||||
|
<span class="text-soft">持有总成本</span>
|
||||||
|
<strong class="text-brand">¥{{ (health?.totals?.grand || 0).toFixed(2) }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 健康卡片:油耗/电耗 + 洗车新鲜度 + 保养预测 -->
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">能耗 / 效率</h3>
|
||||||
|
<div v-if="health?.avg_consumption?.l_per_100km || health?.avg_consumption?.kwh_per_100km" class="eff-grid">
|
||||||
|
<div v-if="health?.avg_consumption?.l_per_100km" class="eff-item">
|
||||||
|
<div class="eff-label">平均油耗</div>
|
||||||
|
<div class="eff-value">{{ health.avg_consumption.l_per_100km }} <small>L/100km</small></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="health?.avg_consumption?.kwh_per_100km" class="eff-item">
|
||||||
|
<div class="eff-label">平均电耗</div>
|
||||||
|
<div class="eff-value">{{ health.avg_consumption.kwh_per_100km }} <small>kWh/100km</small></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-mute sm">需要至少一次「加满+里程」的加油或充电记录才能计算</div>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">洗车新鲜度</h3>
|
||||||
|
<div v-if="health?.wash_recency">
|
||||||
|
<div class="big">{{ health.wash_recency.last_date }}</div>
|
||||||
|
<div :class="['pill', health.wash_recency.overdue ? 'pill-warn' : 'pill-green']" class="mt-1">
|
||||||
|
{{ health.wash_recency.days_since }} 天没洗
|
||||||
|
<span v-if="health.wash_recency.overdue">· 该洗了</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-mute sm">暂无洗车记录</div>
|
||||||
|
</div>
|
||||||
|
<div class="card card-pad">
|
||||||
|
<h3 class="section-title">下次保养预测</h3>
|
||||||
|
<div v-if="health?.next_maintenance">
|
||||||
|
<div v-if="health.next_maintenance.urgent" class="danger-banner">
|
||||||
|
⚠️ 已超过保养里程,建议尽快保养
|
||||||
|
</div>
|
||||||
|
<div class="big">距 {{ health.next_maintenance.next_due_km }} km</div>
|
||||||
|
<div class="bar-wrap mt-2">
|
||||||
|
<div class="bar" :style="{ width: health.next_maintenance.km_remaining_pct + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="text-mute sm mt-1">还剩 {{ health.next_maintenance.km_remaining }} km · 上次 {{ health.next_maintenance.last_date }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-mute sm">尚未设置下次保养里程(在保养记录里设置「下次里程」即可启用预测)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 月度成本趋势 -->
|
||||||
|
<div class="row mt-4" v-if="health?.monthly?.length">
|
||||||
|
<div class="card card-pad" style="grid-column: 1 / -1">
|
||||||
|
<h3 class="section-title">近 6 月月度成本</h3>
|
||||||
|
<div class="chart-wrap"><canvas ref="monthCanvas"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 记录 tabs -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-pad tabs-head">
|
||||||
|
<h3 class="section-title" style="margin:0">所有记录</h3>
|
||||||
|
<div class="tab-bar">
|
||||||
|
<button :class="['tab', { active: tab === 'wash' }]" @click="tab = 'wash'">洗车 <span class="badge">{{ washes.length }}</span></button>
|
||||||
|
<button :class="['tab', { active: tab === 'maint' }]" @click="tab = 'maint'">保养 <span class="badge">{{ maints.length }}</span></button>
|
||||||
|
<button :class="['tab', { active: tab === 'refuel' }]" @click="tab = 'refuel'">加油 <span class="badge">{{ refuels.length }}</span></button>
|
||||||
|
<button :class="['tab', { active: tab === 'charge' }]" @click="tab = 'charge'">充电 <span class="badge">{{ charges.length }}</span></button>
|
||||||
|
<button :class="['tab', { active: tab === 'ins' }]" @click="tab = 'ins'">保险 <span class="badge">{{ insurances.length }}</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-if="tab === 'wash'" class="data">
|
||||||
|
<thead><tr><th>日期</th><th>类型</th><th class="r">花费</th><th>用品</th><th>位置</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="w in washes" :key="w.id">
|
||||||
|
<td>{{ w.wash_date }}</td>
|
||||||
|
<td><span class="pill pill-blue">{{ washTypeLabel(w.wash_type) }}</span></td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ (w.cost || 0).toFixed(2) }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<span v-for="(c, i) in (w.chemicals || [])" :key="i" class="pill pill-gray sm mr-1">{{ c.chemical_name }} {{ c.amount }}{{ c.unit || '' }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-soft">{{ w.location || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!washes.length"><td colspan="5" class="text-mute" style="text-align:center;padding:24px">暂无洗车记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table v-if="tab === 'maint'" class="data">
|
||||||
|
<thead><tr><th>日期</th><th>项目</th><th>总里程</th><th>下次里程</th><th>店名</th><th class="r">花费</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in maints" :key="r.id">
|
||||||
|
<td>{{ r.maint_date }}</td>
|
||||||
|
<td><span v-for="(it, i) in r.items" :key="i" class="pill pill-gray sm mr-1">{{ it.name }}</span></td>
|
||||||
|
<td>{{ r.odometer_km || '—' }} km</td>
|
||||||
|
<td>{{ r.next_due_km || '—' }} km</td>
|
||||||
|
<td>{{ r.shop || '—' }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ (r.total_cost || 0).toFixed(2) }}</strong></td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!maints.length"><td colspan="6" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table v-if="tab === 'refuel'" class="data">
|
||||||
|
<thead><tr><th>日期</th><th>油号</th><th>里程</th><th>升数</th><th>单价</th><th class="r">花费</th><th>油耗</th><th>加油站</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in refuels" :key="r.id">
|
||||||
|
<td>{{ r.refuel_date }}</td>
|
||||||
|
<td><span class="pill pill-gray">{{ r.fuel_type || '—' }}</span></td>
|
||||||
|
<td>{{ r.odometer_km || '—' }} km</td>
|
||||||
|
<td><strong>{{ r.liters }}L</strong></td>
|
||||||
|
<td>¥{{ r.price_per_liter || 0 }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ (r.total_cost || 0).toFixed(2) }}</strong></td>
|
||||||
|
<td><span v-if="r.consumption_100km" class="pill pill-blue">{{ r.consumption_100km.toFixed(2) }} L/100km</span><span v-else class="text-mute sm">{{ r.consumption_skip_reason || '需加满+里程' }}</span></td>
|
||||||
|
<td>{{ r.station || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!refuels.length"><td colspan="8" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table v-if="tab === 'charge'" class="data">
|
||||||
|
<thead><tr><th>日期</th><th>类型</th><th>里程</th><th>度数</th><th>SOC</th><th>单价</th><th class="r">花费</th><th>电耗</th><th>地点</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in charges" :key="r.id">
|
||||||
|
<td>{{ r.charge_date }}</td>
|
||||||
|
<td><span class="pill pill-blue">{{ CHARGE_LABEL[r.charge_type] || r.charge_type || '—' }}</span></td>
|
||||||
|
<td>{{ r.odometer_km || '—' }} km</td>
|
||||||
|
<td><strong>{{ r.kwh }} kWh</strong></td>
|
||||||
|
<td><span v-if="r.start_soc != null && r.end_soc != null">{{ r.start_soc }}→{{ r.end_soc }}%</span><span v-else class="text-mute">—</span></td>
|
||||||
|
<td>¥{{ r.price_per_kwh || 0 }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ (r.total_cost || 0).toFixed(2) }}</strong></td>
|
||||||
|
<td><span v-if="r.kwh_per_100km" class="pill pill-blue">{{ r.kwh_per_100km.toFixed(2) }} kWh/100km</span><span v-else class="text-mute sm">{{ r.consumption_skip_reason || '需里程' }}</span></td>
|
||||||
|
<td>{{ r.station || '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!charges.length"><td colspan="9" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table v-if="tab === 'ins'" class="data">
|
||||||
|
<thead><tr><th>状态</th><th>险种</th><th>公司</th><th>保单号</th><th>生效日</th><th>到期日</th><th class="r">保费</th><th>附件</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in insurances" :key="r.id">
|
||||||
|
<td>
|
||||||
|
<span :class="['pill', insStatusPill(r.status)]">
|
||||||
|
{{ insStatusLabel(r.status) }}
|
||||||
|
<span v-if="r.status === 'expiring'" class="sm">· {{ r.days_to_expire }}d</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><strong>{{ r.insurance_type }}</strong></td>
|
||||||
|
<td>{{ r.company || '—' }}</td>
|
||||||
|
<td class="text-soft sm">{{ r.policy_no || '—' }}</td>
|
||||||
|
<td>{{ r.start_date }}</td>
|
||||||
|
<td>{{ r.end_date }}</td>
|
||||||
|
<td class="r text-brand"><strong>¥{{ (r.premium || 0).toFixed(0) }}</strong></td>
|
||||||
|
<td>
|
||||||
|
<a v-if="r.attachment_path" :href="`/api/${r.attachment_path}`" target="_blank" class="btn btn-ghost btn-sm">查看</a>
|
||||||
|
<span v-else class="text-mute sm">无</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!insurances.length"><td colspan="8" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import * as washesApi from '../api/washes';
|
||||||
|
import { maintApi, refuelApi, chargingApi } from '../api/logs';
|
||||||
|
import * as insuranceApi from '../api/insurance';
|
||||||
|
import { asArray } from '../api/client';
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
|
||||||
|
const CHARGE_LABEL = { home: '家充', slow: '慢充(交流)', fast: '快充(直流)', public: '公共桩' };
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const vehicle = ref({});
|
||||||
|
const health = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const tab = ref('wash');
|
||||||
|
const monthCanvas = ref(null);
|
||||||
|
let monthChart = null;
|
||||||
|
|
||||||
|
const washes = ref([]);
|
||||||
|
const maints = ref([]);
|
||||||
|
const refuels = ref([]);
|
||||||
|
const charges = ref([]);
|
||||||
|
const insurances = ref([]);
|
||||||
|
|
||||||
|
const typeLabel = (t) => ({ car: '轿车', suv: 'SUV', mpv: 'MPV', truck: '货车', other: '其他' }[t] || t || '其他');
|
||||||
|
const washTypeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||||
|
const insStatusLabel = (s) => ({ active: '有效', expiring: '即将到期', expired: '已过期' }[s] || s);
|
||||||
|
const insStatusPill = (s) => ({ active: 'pill-green', expiring: 'pill-warn', expired: 'pill-gray' }[s] || 'pill-gray');
|
||||||
|
const powertrainLabel = (p) => ({ ice: '纯油', hev: '混动', ev: '纯电', erev: '增程' }[p] || p || '纯油');
|
||||||
|
const powertrainIcon = (p) => ({ ice: '🛢️', hev: '⚡🛢️', ev: '⚡', erev: '🔋🛢️' }[p] || '🛢️');
|
||||||
|
const powertrainPill = (p) => ({ ice: 'pill-gray', hev: 'pill-blue', ev: 'pill-green', erev: 'pill-warn' }[p] || 'pill-gray');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const id = route.params.id;
|
||||||
|
const [vR, hR, wR, mR, rR, cR, iR] = await Promise.all([
|
||||||
|
vehiclesApi.list(),
|
||||||
|
vehiclesApi.health(id).catch(() => ({ data: null })),
|
||||||
|
washesApi.list({ vehicle_id: id, limit: 50 }),
|
||||||
|
maintApi.list({ vehicle_id: id, limit: 50 }),
|
||||||
|
refuelApi.list({ vehicle_id: id, limit: 50 }),
|
||||||
|
chargingApi.list({ vehicle_id: id, limit: 50 }),
|
||||||
|
insuranceApi.list({ vehicle_id: id }),
|
||||||
|
]);
|
||||||
|
vehicle.value = asArray(vR.data, 'vehicles').find(x => String(x.id) === String(id)) || {};
|
||||||
|
health.value = hR.data;
|
||||||
|
washes.value = wR.data?.rows || [];
|
||||||
|
maints.value = mR.data?.rows || [];
|
||||||
|
refuels.value = rR.data?.rows || [];
|
||||||
|
charges.value = cR.data?.rows || [];
|
||||||
|
insurances.value = iR.data?.rows || [];
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
drawMonth();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function drawMonth() {
|
||||||
|
if (!monthCanvas.value || !health.value?.monthly?.length) return;
|
||||||
|
if (monthChart) monthChart.destroy();
|
||||||
|
const labels = health.value.monthly.map(m => m.month);
|
||||||
|
monthChart = new Chart(monthCanvas.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{ label: '洗车', data: health.value.monthly.map(m => Number(m.wash) || 0), backgroundColor: '#4DBA9A' },
|
||||||
|
{ label: '加油', data: health.value.monthly.map(m => Number(m.refuel) || 0), backgroundColor: '#E89B5B' },
|
||||||
|
{ label: '充电', data: health.value.monthly.map(m => Number(m.charge) || 0), backgroundColor: '#5DA5DA' },
|
||||||
|
{ label: '保养', data: health.value.monthly.map(m => Number(m.maint) || 0), backgroundColor: '#9B7FD4' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: { legend: { position: 'top' } },
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
y: { stacked: true, beginAtZero: true, ticks: { callback: v => '¥' + v } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; }
|
||||||
|
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; display:flex; align-items:center; gap:8px; }
|
||||||
|
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||||
|
.head-actions { display:flex; gap:8px; }
|
||||||
|
.row { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
|
||||||
|
.row.mt-4 { margin-top: 18px; }
|
||||||
|
.row-3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:18px; }
|
||||||
|
.section-title { font-size:14px; font-weight:600; color:var(--text-soft); margin:0 0 16px; }
|
||||||
|
.big-stats { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
||||||
|
.big-stats > div { background:var(--bg-soft); border-radius:var(--radius-sm); padding:12px; display:flex; flex-direction:column; gap:2px; }
|
||||||
|
.big-stats strong { font-size:24px; font-weight:700; letter-spacing:-0.02em; font-variant-numeric:tabular-nums; }
|
||||||
|
.grand-total { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; background:#f6f8fa; border-radius:8px; }
|
||||||
|
.grand-total strong { font-size:20px; font-variant-numeric:tabular-nums; }
|
||||||
|
.eff-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||||
|
.eff-item { background: var(--bg-soft); border-radius: 8px; padding: 12px; }
|
||||||
|
.eff-label { font-size: 12px; color: var(--text-soft); }
|
||||||
|
.eff-value { font-size: 22px; font-weight: 700; margin-top: 4px; }
|
||||||
|
.eff-value small { font-size: 13px; font-weight: 500; color: var(--text-soft); }
|
||||||
|
.big { font-size: 20px; font-weight: 600; margin-top: 2px; font-variant-numeric:tabular-nums; }
|
||||||
|
.danger-banner { padding: 8px 12px; background: #FBE3DF; color: #C0392B; border-radius: 6px; font-size: 13px; margin-bottom: 10px; }
|
||||||
|
.bar-wrap { position: relative; height: 14px; background: var(--bg-soft); border-radius: 7px; overflow: hidden; }
|
||||||
|
.bar { background: var(--green); height: 100%; border-radius: 7px; transition: width .4s; }
|
||||||
|
.chart-wrap { position: relative; height: 240px; width: 100%; }
|
||||||
|
.tabs-head { display:flex; justify-content:space-between; align-items:center; padding-bottom:0; }
|
||||||
|
.tab-bar { display:flex; gap:4px; }
|
||||||
|
.tab { background:transparent; border:0; padding:6px 14px; border-radius:var(--pill); font-size:13px; color:var(--text-soft); cursor:pointer; transition:all .15s; display:flex; align-items:center; gap:6px; }
|
||||||
|
.tab:hover { background:var(--bg-soft); color:var(--text); }
|
||||||
|
.tab.active { background:var(--accent); color:#fff; }
|
||||||
|
.badge { background:rgba(255,255,255,0.2); padding:1px 6px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||||
|
.tab:not(.active) .badge { background:var(--bg-soft); color:var(--text-soft); }
|
||||||
|
.ml-2 { margin-left:8px; }
|
||||||
|
.mt-3 { margin-top:14px; }
|
||||||
|
.mt-4 { margin-top:18px; }
|
||||||
|
.mt-1 { margin-top:4px; }
|
||||||
|
.mt-2 { margin-top:8px; }
|
||||||
|
.r { text-align:right; }
|
||||||
|
.mr-1 { margin-right:4px; }
|
||||||
|
.text-soft { color:var(--text-soft); }
|
||||||
|
.text-mute { color:var(--text-mute); }
|
||||||
|
.text-danger { color:var(--danger); }
|
||||||
|
.text-brand { color:var(--brand); }
|
||||||
|
.sm { font-size:11px; }
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
.row-3 { grid-template-columns: 1fr; }
|
||||||
|
.eff-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.head-actions > * { flex: 1; min-width: 80px; justify-content: center; }
|
||||||
|
.headline { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
.plate { font-size: 22px; }
|
||||||
|
.stat-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.stat-num { font-size: 20px; }
|
||||||
|
.row-3 { grid-template-columns: 1fr; }
|
||||||
|
.eff-grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.card.card-pad { padding: 14px 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.stat-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<h1 class="title">{{ isEdit ? '编辑车辆' : '新建车辆' }}</h1>
|
||||||
|
<router-link to="/vehicles" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">名称 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.name" class="input" required placeholder="如:我的小车" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">车牌号 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.plate" class="input" required placeholder="如:京A·12345" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">车型</label>
|
||||||
|
<select v-model="form.type" class="select">
|
||||||
|
<option value="car">轿车</option>
|
||||||
|
<option value="suv">SUV</option>
|
||||||
|
<option value="mpv">MPV</option>
|
||||||
|
<option value="truck">货车</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">动力类型 <span class="text-soft sm">— 决定油耗/电耗是否计算</span></label>
|
||||||
|
<select v-model="form.powertrain" class="select">
|
||||||
|
<option value="ice">🛢️ 纯油 (ice)</option>
|
||||||
|
<option value="hev">⚡🛢️ 混动 (hev)</option>
|
||||||
|
<option value="ev">⚡ 纯电 (ev)</option>
|
||||||
|
<option value="erev">🔋🛢️ 增程 (erev)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">颜色</label>
|
||||||
|
<input v-model="form.color" class="input" placeholder="如:白色" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="textarea" rows="3" placeholder="可选"></textarea>
|
||||||
|
</div>
|
||||||
|
<div v-if="isEdit" class="mt-3">
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" v-model="form.is_active" />
|
||||||
|
<span>启用</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||||||
|
<div class="actions mt-6">
|
||||||
|
<button v-if="isEdit" type="button" class="btn btn-danger" @click="onRemove" :disabled="busy">删除</button>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const isEdit = computed(() => !!route.params.id);
|
||||||
|
const error = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
const form = reactive({ name: '', plate: '', type: 'car', powertrain: 'ice', color: '', notes: '', is_active: true });
|
||||||
|
|
||||||
|
// 401 草稿(编辑模式用 id 区分 key,避免与新建冲突)
|
||||||
|
const draft = useFormDraft(isEdit.value ? `vehicles/${route.params.id}` : 'vehicles/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) Object.assign(form, restored);
|
||||||
|
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!isEdit.value) return;
|
||||||
|
try {
|
||||||
|
const r = await vehiclesApi.get(route.params.id);
|
||||||
|
Object.assign(form, r.data);
|
||||||
|
} catch (e) { error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败'; }
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = '';
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await vehiclesApi.update(route.params.id, form);
|
||||||
|
} else {
|
||||||
|
await vehiclesApi.create(form);
|
||||||
|
}
|
||||||
|
draft.clear();
|
||||||
|
router.push({ name: 'vehicles' });
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || e.response?.data?.code || e.message || '保存失败';
|
||||||
|
} finally { busy.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove() {
|
||||||
|
if (!confirm(`确认删除「${form.name}」?该车辆的洗车记录会保留但失去车辆关联。`)) return;
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await vehiclesApi.remove(route.params.id);
|
||||||
|
router.push({ name: 'vehicles' });
|
||||||
|
} catch (e) { error.value = e.response?.data?.message || e.response?.data?.code || e.message || '删除失败'; }
|
||||||
|
finally { busy.value = false; }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.form { max-width: 720px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||||
|
.check { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
|
||||||
|
.error { color: var(--danger); background: #FBE3DF; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||||
|
.actions { display: flex; align-items: center; gap: 12px; }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head > a, .head > .actions { width: 100%; justify-content: center; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">车辆</h1>
|
||||||
|
<p class="subtitle text-soft">共 {{ vehicles.length }} 辆</p>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<router-link to="/vehicles/new" class="btn btn-primary">+ 新建</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="!vehicles.length" class="card card-pad empty">
|
||||||
|
<p class="text-soft" style="margin: 0 0 12px">还没有车辆</p>
|
||||||
|
<router-link to="/vehicles/new" class="btn btn-primary">+ 添加第一辆车</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else class="v-grid">
|
||||||
|
<div v-for="v in vehicles" :key="v.id" class="card v-card">
|
||||||
|
<div class="v-icon">{{ typeIcon(v.type) }}</div>
|
||||||
|
<div class="v-info">
|
||||||
|
<div class="v-name">{{ v.name }}</div>
|
||||||
|
<div class="v-plate">{{ v.plate }}</div>
|
||||||
|
<div class="v-meta">
|
||||||
|
<span class="pill pill-blue">{{ typeLabel(v.type) }}</span>
|
||||||
|
<span :class="['pill', powertrainPill(v.powertrain)]">{{ powertrainIcon(v.powertrain) }} {{ powertrainLabel(v.powertrain) }}</span>
|
||||||
|
<span v-if="v.color" class="pill pill-gray">{{ v.color }}</span>
|
||||||
|
<span v-if="!v.is_active" class="pill pill-danger">停用</span>
|
||||||
|
</div>
|
||||||
|
<div class="v-stats">
|
||||||
|
<div><span class="text-mute">洗车次数</span><strong>{{ v.wash_count || 0 }}</strong></div>
|
||||||
|
<div><span class="text-mute">累计花费</span><strong>¥ {{ (v.total_cost || 0).toFixed(2) }}</strong></div>
|
||||||
|
<div><span class="text-mute">最近</span><strong>{{ v.last_wash_date || '—' }}</strong></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="v.notes" class="v-notes">{{ v.notes }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="v-actions">
|
||||||
|
<router-link :to="`/vehicles/${v.id}`" class="btn btn-ghost btn-sm">详情</router-link>
|
||||||
|
<router-link :to="`/vehicles/${v.id}/edit`" class="btn btn-ghost btn-sm">编辑</router-link>
|
||||||
|
<button class="btn btn-ghost btn-sm text-danger" @click="askDelete(v)">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-model="showDelete"
|
||||||
|
title="确认删除车辆"
|
||||||
|
:message="`确定要删除「${deleteTarget?.name}」吗?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
confirm-word="删除"
|
||||||
|
:tips="['已删除车辆可在「操作日志」中恢复']"
|
||||||
|
:busy="deleteBusy"
|
||||||
|
:error="deleteError"
|
||||||
|
@confirm="doDelete"
|
||||||
|
@cancel="showDelete = false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { asArray } from '../api/client';
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const typeLabel = (t) => ({ car: '轿车', suv: 'SUV', mpv: 'MPV', truck: '货车', other: '其他' }[t] || t || '其他');
|
||||||
|
const typeIcon = (t) => ({ car: '🚗', suv: '🚙', mpv: '🚐', truck: '🛻', other: '🚘' }[t] || '🚘');
|
||||||
|
const POWERTRAINS = { ice: '纯油', hev: '混动', ev: '纯电', erev: '增程' };
|
||||||
|
const POWERTRAIN_ICON = { ice: '🛢️', hev: '⚡🛢️', ev: '⚡', erev: '🔋🛢️' };
|
||||||
|
const powertrainLabel = (p) => POWERTRAINS[p] || p || '纯油';
|
||||||
|
const powertrainIcon = (p) => POWERTRAIN_ICON[p] || '🛢️';
|
||||||
|
const powertrainPill = (p) => ({ ice: 'pill-gray', hev: 'pill-blue', ev: 'pill-green', erev: 'pill-warn' }[p] || 'pill-gray');
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await vehiclesApi.list();
|
||||||
|
vehicles.value = asArray(r.data, 'vehicles');
|
||||||
|
} finally { loading.value = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
const showDelete = ref(false);
|
||||||
|
const deleteTarget = ref(null);
|
||||||
|
const deleteBusy = ref(false);
|
||||||
|
const deleteError = ref('');
|
||||||
|
|
||||||
|
function askDelete(v) {
|
||||||
|
deleteTarget.value = v;
|
||||||
|
deleteError.value = '';
|
||||||
|
showDelete.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDelete() {
|
||||||
|
if (!deleteTarget.value) return;
|
||||||
|
deleteBusy.value = true;
|
||||||
|
deleteError.value = '';
|
||||||
|
try {
|
||||||
|
await vehiclesApi.remove(deleteTarget.value.id);
|
||||||
|
vehicles.value = vehicles.value.filter(v => v.id !== deleteTarget.value.id);
|
||||||
|
showDelete.value = false;
|
||||||
|
deleteTarget.value = null;
|
||||||
|
} catch (err) {
|
||||||
|
deleteError.value = err.message || '删除失败';
|
||||||
|
} finally {
|
||||||
|
deleteBusy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.v-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
|
||||||
|
.v-card { padding: 20px; display: grid; grid-template-columns: 56px 1fr auto; gap: 14px; align-items: start; }
|
||||||
|
.v-icon {
|
||||||
|
width: 56px; height: 56px; border-radius: 12px;
|
||||||
|
background: var(--bg-soft); display: flex; align-items: center; justify-content: center;
|
||||||
|
font-size: 30px;
|
||||||
|
}
|
||||||
|
.v-name { font-size: 16px; font-weight: 600; }
|
||||||
|
.v-plate { font-family: monospace; font-size: 12px; color: var(--text-soft); margin-top: 2px; }
|
||||||
|
.v-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||||
|
.v-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||||
|
.v-stats > div { display: flex; flex-direction: column; gap: 2px; font-size: 12px; }
|
||||||
|
.v-stats strong { font-size: 14px; color: var(--text); }
|
||||||
|
.v-notes { margin-top: 8px; font-size: 12px; color: var(--text-soft); }
|
||||||
|
.v-actions { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.empty { text-align: center; padding: 48px 24px; }
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.v-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||||
|
.v-card { padding: 16px; gap: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head .actions { width: 100%; }
|
||||||
|
.head .actions .btn { width: 100%; justify-content: center; }
|
||||||
|
.v-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.v-card {
|
||||||
|
grid-template-columns: 48px 1fr;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.v-icon { width: 48px; height: 48px; font-size: 26px; }
|
||||||
|
.v-info { grid-column: 1 / -1; }
|
||||||
|
.v-actions {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.v-actions .btn { flex: 1; justify-content: center; }
|
||||||
|
.v-stats { grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.v-stats { grid-template-columns: 1fr 1fr; }
|
||||||
|
.v-stats > div:nth-child(3) { grid-column: 1 / -1; flex-direction: row; justify-content: space-between; align-items: center; }
|
||||||
|
.v-stats > div:nth-child(3) strong { font-size: 13px; }
|
||||||
|
.v-actions .btn { padding: 6px 8px; font-size: 12px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<h1 class="title">新建洗车记录</h1>
|
||||||
|
<div class="head-actions">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="onAiRecognize" :disabled="aiBusy">
|
||||||
|
<span v-if="aiBusy">识别中…</span>
|
||||||
|
<span v-else>📷 AI 识别</span>
|
||||||
|
</button>
|
||||||
|
<router-link to="/washes" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AiFallbackModal :show="ai.showFallback" :image-url="ai.fallback?.preview_url" @cancel="ai.cancelFallback()" @confirm="onManualConfirm">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">洗车日期 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.wash_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">类型 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.wash_type" class="select" required>
|
||||||
|
<option value="quick">快速(15-30 分钟)</option>
|
||||||
|
<option value="full">标准(30-60 分钟)</option>
|
||||||
|
<option value="detail">精洗(1-2 小时)</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆</label>
|
||||||
|
<select v-model="form.vehicle_id" class="select">
|
||||||
|
<option value="">不指定</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">位置</label>
|
||||||
|
<input v-model="form.location" class="input" placeholder="家 / 公司 / 店名" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">花费 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="form.cost" type="number" step="0.01" min="0" class="input" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="input" rows="2" placeholder="看着图填,看不清的留空"></textarea>
|
||||||
|
</div>
|
||||||
|
</AiFallbackModal>
|
||||||
|
|
||||||
|
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label class="label">洗车日期 <span class="text-danger">*</span></label>
|
||||||
|
<input v-model="form.wash_date" type="date" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">类型 <span class="text-danger">*</span></label>
|
||||||
|
<select v-model="form.wash_type" class="select" required>
|
||||||
|
<option value="quick">快速(15-30 分钟)</option>
|
||||||
|
<option value="full">标准(30-60 分钟)</option>
|
||||||
|
<option value="detail">精洗(1-2 小时)</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆</label>
|
||||||
|
<select v-model="form.vehicle_id" class="select">
|
||||||
|
<option value="">不指定</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">位置</label>
|
||||||
|
<input v-model="form.location" class="input" placeholder="家 / 公司 / 店名" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">花费 (¥) <span class="text-danger">*</span></label>
|
||||||
|
<input v-model.number="form.cost" type="number" step="0.01" min="0" class="input" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">耗时 (分钟)</label>
|
||||||
|
<input v-model.number="form.duration_min" type="number" min="0" class="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">备注</label>
|
||||||
|
<textarea v-model="form.notes" class="textarea" rows="2" placeholder="可选"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="label">化学品使用(可选)</label>
|
||||||
|
<div class="chem-list">
|
||||||
|
<div v-for="(c, i) in chemRows" :key="i" class="chem-row">
|
||||||
|
<div class="chem-picker-col">
|
||||||
|
<ChemPicker
|
||||||
|
v-model="c.chemical_id"
|
||||||
|
:chemicals="chemicals"
|
||||||
|
:placeholder="availableUnits.length === 0 ? '化学品搜索…' : '搜索化学品(名称/分类)…'"
|
||||||
|
@change="onChemChange(i, $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="chem-amount-col">
|
||||||
|
<select v-model="c.unit" class="select chem-unit" :disabled="!c.chemical_id">
|
||||||
|
<option v-for="u in availableUnits" :key="u.id" :value="u.name">
|
||||||
|
{{ u.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input v-model.number="c.amount" type="number" step="0.01" min="0" placeholder="用量" class="input chem-amt" :disabled="!c.chemical_id" />
|
||||||
|
</div>
|
||||||
|
<span class="chem-equiv" v-if="c.chemical_id && c.amount > 0">
|
||||||
|
= {{ computedStockAmount(c) }} {{ stockUnit(c) }}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm del-btn" @click="chemRows.splice(i, 1)">×</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-ghost btn-sm" @click="chemRows.push({ chemical_id: '', unit: '毫升', amount: 0 })">+ 添加化学品</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-mute sm mt-2">
|
||||||
|
💡 输入单位可任选(如「0.5 加仑」「100 毫升」),系统自动换算成 Grocy 库存单位(最小精度 = 毫升)。
|
||||||
|
<br>
|
||||||
|
💡 单位换算关系来自 Grocy product 的 <code>userfields.qu_factor</code> 字段(默认 1)。修改去 Grocy 后台 → Master data → Products → Userfields。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||||||
|
|
||||||
|
<div class="actions mt-6">
|
||||||
|
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||||||
|
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '保存中…' : '保存' }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import ChemPicker from '../components/ChemPicker.vue';
|
||||||
|
import * as washesApi from '../api/washes';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import * as chemicalsApi from '../api/chemicals';
|
||||||
|
import { asArray } from '../api/client';
|
||||||
|
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||||
|
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||||
|
import AiFallbackModal from '../components/AiFallbackModal.vue';
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const chemicals = ref([]);
|
||||||
|
const error = ref('');
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
// AI 识别(洗车小票/订单截图)
|
||||||
|
const ai = useAiRecognize();
|
||||||
|
const aiBusy = ai.busy;
|
||||||
|
async function onAiRecognize() {
|
||||||
|
await ai.open('wash', (data) => {
|
||||||
|
if (data.wash_date) form.wash_date = data.wash_date;
|
||||||
|
if (data.wash_type) form.wash_type = data.wash_type;
|
||||||
|
if (data.cost != null) form.cost = data.cost;
|
||||||
|
if (data.location) form.location = data.location;
|
||||||
|
if (data.notes) form.notes = (form.notes ? form.notes + '\n' : '') + data.notes;
|
||||||
|
// 如果识别出 vehicle_hint,提示给用户(不自动选)
|
||||||
|
if (data.vehicle_hint) {
|
||||||
|
console.log('AI 识别到车辆线索:', data.vehicle_hint);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 兜底 modal 提交:用户对着图手填后,确认。把 modal 关闭
|
||||||
|
// (form 字段已经双向绑定到 reactive,无需特殊处理)
|
||||||
|
function onManualConfirm() {
|
||||||
|
ai.cancelFallback();
|
||||||
|
}
|
||||||
|
const form = reactive({
|
||||||
|
wash_date: new Date().toISOString().slice(0, 10),
|
||||||
|
wash_type: 'full',
|
||||||
|
vehicle_id: '',
|
||||||
|
location: '',
|
||||||
|
cost: 0,
|
||||||
|
duration_min: 0,
|
||||||
|
notes: '',
|
||||||
|
});
|
||||||
|
const chemRows = ref([]);
|
||||||
|
|
||||||
|
// Grocy 的所有 quantity_units(用户能选的单位)
|
||||||
|
const availableUnits = ref([]);
|
||||||
|
|
||||||
|
const chemMap = computed(() => {
|
||||||
|
const m = {};
|
||||||
|
for (const c of chemicals.value) m[c.grocy_product_id] = c;
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
|
||||||
|
function stockUnit(row) {
|
||||||
|
const c = chemMap.value[row.chemical_id];
|
||||||
|
return c?.unit || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function computedStockAmount(row) {
|
||||||
|
const c = chemMap.value[row.chemical_id];
|
||||||
|
if (!c) return '—';
|
||||||
|
const quFactor = Number(c.qu_factor || 1);
|
||||||
|
const v = Number(row.amount || 0) * quFactor;
|
||||||
|
// 3 位小数
|
||||||
|
return (Math.round(v * 1000) / 1000).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChemChange(i, ch) {
|
||||||
|
// 选中产品后自动设为 stock unit
|
||||||
|
const row = chemRows.value[i];
|
||||||
|
if (ch?.unit) row.unit = ch.unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单草稿:401 跳转登录前自动 flush,登录后回原页恢复
|
||||||
|
const draft = useFormDraft('washes/new');
|
||||||
|
const restored = draft.load();
|
||||||
|
if (restored) {
|
||||||
|
if (restored.form) Object.assign(form, restored.form);
|
||||||
|
if (Array.isArray(restored.chemRows) && restored.chemRows.length) chemRows.value = restored.chemRows;
|
||||||
|
}
|
||||||
|
watch([form, chemRows], () => {
|
||||||
|
draft.save({ form: { ...form }, chemRows: chemRows.value });
|
||||||
|
}, { deep: true });
|
||||||
|
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||||
|
onBeforeUnmount(() => unregisterFlush());
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await vehiclesApi.list({ active: 1 });
|
||||||
|
vehicles.value = asArray(r.data, 'vehicles');
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const r = await chemicalsApi.all();
|
||||||
|
chemicals.value = asArray(r.data, 'chemicals');
|
||||||
|
} catch {}
|
||||||
|
// 拉 quantity_units 给单位下拉用
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/objects/quantity_units', { credentials: 'include' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (Array.isArray(j)) availableUnits.value = j;
|
||||||
|
} catch {}
|
||||||
|
// PWA 快捷方式:?capture=1 → 自动唤起相机
|
||||||
|
if (route.query.capture === '1' || route.query.capture === 1) {
|
||||||
|
setTimeout(() => triggerCamera(), 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function triggerCamera() {
|
||||||
|
// 优先用 hidden 的 file input(无 camera 时回退为普通文件选择)
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/*';
|
||||||
|
input.setAttribute('capture', 'environment');
|
||||||
|
input.style.display = 'none';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
input.remove();
|
||||||
|
if (!f) return;
|
||||||
|
// 直接走 AI 识别流程(识别后会自动回填本表单)
|
||||||
|
try {
|
||||||
|
await ai.recognizeFromFile(f, 'wash');
|
||||||
|
} catch (err) {
|
||||||
|
// 失败时 fallback modal 会自动打开
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.body.appendChild(input);
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
error.value = '';
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const payload = { ...form };
|
||||||
|
if (!payload.vehicle_id) delete payload.vehicle_id;
|
||||||
|
const chemicals_ = chemRows.value
|
||||||
|
.filter(c => c.chemical_id && c.amount > 0)
|
||||||
|
.map(c => ({ chemical_id: c.chemical_id, amount: c.amount, unit: c.unit }));
|
||||||
|
if (chemicals_.length) payload.chemicals = chemicals_;
|
||||||
|
const r = await washesApi.create(payload);
|
||||||
|
draft.clear();
|
||||||
|
router.push({ name: 'wash-show', params: { id: r.data?.id || r.data?.row?.id } });
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || e.response?.data?.code || '保存失败:' + e.message;
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.form { max-width: 760px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||||
|
.chem-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.chem-row {
|
||||||
|
display: flex; gap: 8px; align-items: flex-start;
|
||||||
|
}
|
||||||
|
.chem-picker-col { flex: 1; min-width: 0; }
|
||||||
|
.chem-amount-col { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||||
|
.chem-unit { width: 80px; font-size: 13px; }
|
||||||
|
.chem-amt { width: 90px; font-variant-numeric: tabular-nums; }
|
||||||
|
.chem-equiv { font-size: 12px; color: var(--text-soft); white-space: nowrap; line-height: 36px; flex-shrink: 0; }
|
||||||
|
.del-btn { flex-shrink: 0; line-height: 36px; }
|
||||||
|
.mt-2 { margin-top: 8px; }
|
||||||
|
.error {
|
||||||
|
color: var(--danger); background: #FBE3DF; padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-sm); font-size: 13px;
|
||||||
|
}
|
||||||
|
.actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||||||
|
@media (max-width: 800px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head .actions { width: 100%; }
|
||||||
|
.head .actions > * { flex: 1; justify-content: center; }
|
||||||
|
.grid { grid-template-columns: 1fr; }
|
||||||
|
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||||
|
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<h1 class="title">洗车详情</h1>
|
||||||
|
<div class="head-actions">
|
||||||
|
<router-link to="/washes" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||||
|
<button class="btn btn-ghost" @click="onAiRecognize" :disabled="aiBusy">
|
||||||
|
<span v-if="aiBusy">识别中…</span>
|
||||||
|
<span v-else>📷 AI 识别</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||||
|
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||||
|
<div v-else>
|
||||||
|
<div class="row">
|
||||||
|
<div class="card card-pad main-card">
|
||||||
|
<div class="row-top">
|
||||||
|
<div>
|
||||||
|
<div class="text-soft" style="font-size:13px">洗车日期</div>
|
||||||
|
<div class="big">{{ data.wash_date }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="pill" :class="typePill(data.wash_type)" style="font-size:14px; padding:6px 14px">
|
||||||
|
{{ typeLabel(data.wash_type) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid mt-4">
|
||||||
|
<div><div class="text-soft sm">车辆</div><div class="val">{{ data.vehicle_name || '—' }}<span v-if="data.vehicle_plate" class="text-soft" style="font-weight:400"> · {{ data.vehicle_plate }}</span></div></div>
|
||||||
|
<div><div class="text-soft sm">位置</div><div class="val">{{ data.location || '—' }}</div></div>
|
||||||
|
<div><div class="text-soft sm">花费</div><div class="val">¥ {{ Number(data.cost).toFixed(2) }}</div></div>
|
||||||
|
<div><div class="text-soft sm">耗时</div><div class="val">{{ data.duration_min ? data.duration_min + ' 分钟' : '—' }}</div></div>
|
||||||
|
<div><div class="text-soft sm">天气</div><div class="val">{{ data.weather_desc || '—' }}<span v-if="data.temp_c" class="text-soft" style="font-weight:400"> · {{ data.temp_c }}℃</span></div></div>
|
||||||
|
<div><div class="text-soft sm">湿度</div><div class="val">{{ data.humidity ? data.humidity + '%' : '—' }}</div></div>
|
||||||
|
</div>
|
||||||
|
<div v-if="data.notes" class="mt-4">
|
||||||
|
<div class="text-soft sm">备注</div>
|
||||||
|
<div class="notes">{{ data.notes }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad side-card">
|
||||||
|
<h3 class="chart-title">化学品使用</h3>
|
||||||
|
<div v-if="!data.chemicals?.length" class="text-mute" style="font-size:13px">未记录</div>
|
||||||
|
<div v-else class="chem-list">
|
||||||
|
<div v-for="c in data.chemicals" :key="c.id" class="chem-item">
|
||||||
|
<div>
|
||||||
|
<div class="val-sm">{{ c.chemical_name || c.chemical_id }}</div>
|
||||||
|
<div class="text-mute" style="font-size:12px">{{ c.chemical_id }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-soft">{{ c.amount }} {{ c.unit || '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 对比照:上传 + 时间轴 -->
|
||||||
|
<div class="card card-pad mt-4">
|
||||||
|
<div class="photo-head">
|
||||||
|
<h3 class="chart-title" style="margin:0">对比照</h3>
|
||||||
|
<div class="photo-tabs">
|
||||||
|
<button :class="['tab', { active: tab === 'gallery' }]" @click="tab = 'gallery'">图册 ({{ photos.length }})</button>
|
||||||
|
<button :class="['tab', { active: tab === 'compare' }]" @click="tab = 'compare'">前后对比</button>
|
||||||
|
<button :class="['tab', { active: tab === 'upload' }]" @click="tab = 'upload'">+ 上传</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 图册视图 -->
|
||||||
|
<div v-if="tab === 'gallery'" class="photo-gallery">
|
||||||
|
<div v-if="!photos.length" class="text-mute" style="padding:24px; text-align:center">
|
||||||
|
暂无照片。<a @click.prevent="tab='upload'" href="#">立即上传</a>
|
||||||
|
</div>
|
||||||
|
<div v-else class="gallery-grid">
|
||||||
|
<div v-for="p in photos" :key="p.id" class="gallery-item">
|
||||||
|
<a :href="p.url" target="_blank">
|
||||||
|
<img :src="p.url" :alt="p.caption || p.photo_type" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<div class="gallery-meta">
|
||||||
|
<span :class="['pill', typePill2(p.photo_type)]">{{ photoTypeLabel(p.photo_type) }}</span>
|
||||||
|
<span v-if="p.caption" class="text-soft sm">{{ p.caption }}</span>
|
||||||
|
<button class="btn btn-ghost btn-xs del" @click="onDelete(p)">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 前后对比视图 -->
|
||||||
|
<div v-if="tab === 'compare'" class="compare-wrap">
|
||||||
|
<div v-if="!compareData?.before && !compareData?.after" class="text-mute" style="padding:24px; text-align:center">
|
||||||
|
还没上传 before/after 照片。<a @click.prevent="tab='upload'" href="#">去上传</a>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compare-row">
|
||||||
|
<div class="compare-side">
|
||||||
|
<div class="compare-label">洗前 <span class="text-mute sm">(before)</span></div>
|
||||||
|
<div v-if="compareData?.before" class="compare-img-wrap">
|
||||||
|
<img :src="compareData.before.url" />
|
||||||
|
<div v-if="compareData.before.caption" class="text-soft sm mt-1">{{ compareData.before.caption }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compare-empty">未上传</div>
|
||||||
|
</div>
|
||||||
|
<div class="compare-divider">→</div>
|
||||||
|
<div class="compare-side">
|
||||||
|
<div class="compare-label">洗后 <span class="text-mute sm">(after)</span></div>
|
||||||
|
<div v-if="compareData?.after" class="compare-img-wrap">
|
||||||
|
<img :src="compareData.after.url" />
|
||||||
|
<div v-if="compareData.after.caption" class="text-soft sm mt-1">{{ compareData.after.caption }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compare-empty">未上传</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 上传视图 -->
|
||||||
|
<div v-if="tab === 'upload'" class="upload-wrap">
|
||||||
|
<div class="upload-row">
|
||||||
|
<div>
|
||||||
|
<label class="label">类型</label>
|
||||||
|
<select v-model="uploadType" class="select">
|
||||||
|
<option value="before">洗前 (before)</option>
|
||||||
|
<option value="after">洗后 (after)</option>
|
||||||
|
<option value="detail">细节特写 (detail)</option>
|
||||||
|
<option value="scene">场景照 (scene)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1">
|
||||||
|
<label class="label">说明(可选)</label>
|
||||||
|
<input v-model="uploadCaption" class="input" placeholder="例:右后轮毂清洁前" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="dropzone" :class="{ active: dragOver }" @dragover.prevent="dragOver=true" @dragleave.prevent="dragOver=false" @drop.prevent="onDrop">
|
||||||
|
<input ref="fileInput" type="file" accept="image/*" multiple style="display:none" @change="onFileChange" />
|
||||||
|
<div v-if="!uploadFiles.length" class="dropzone-empty" @click="$refs.fileInput.click()">
|
||||||
|
<div class="dz-icon">📷</div>
|
||||||
|
<div>点击或拖拽图片到这里上传</div>
|
||||||
|
<div class="text-mute sm mt-1">支持 jpg/png/webp/heic,单张最大 15MB</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="dropzone-list">
|
||||||
|
<div v-for="(f, i) in uploadFiles" :key="i" class="dz-file">
|
||||||
|
<span>{{ f.name }} ({{ Math.round(f.size / 1024) }} KB)</span>
|
||||||
|
<button type="button" class="btn btn-ghost btn-xs" @click="uploadFiles.splice(i,1)">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="dz-actions">
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()">+ 添加</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="onUpload" :disabled="uploading">
|
||||||
|
{{ uploading ? `上传中 ${uploadProgress}/${uploadFiles.length}` : `上传 ${uploadFiles.length} 张` }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card card-pad mt-4">
|
||||||
|
<h3 class="chart-title">元信息</h3>
|
||||||
|
<div class="meta">
|
||||||
|
<div><span class="text-soft">ID:</span> {{ data.id }}</div>
|
||||||
|
<div><span class="text-soft">创建时间:</span> {{ data.created_at }}</div>
|
||||||
|
<div><span class="text-soft">更新时间:</span> {{ data.updated_at }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import * as washesApi from '../api/washes';
|
||||||
|
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const data = ref({});
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const typeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||||
|
const typePill = (t) => ({ quick: 'pill-blue', full: 'pill-green', detail: 'pill-warn' }[t] || 'pill-gray');
|
||||||
|
const photoTypeLabel = (t) => ({ before: '洗前', after: '洗后', detail: '细节', scene: '场景' }[t] || t || '其他');
|
||||||
|
const typePill2 = (t) => ({ before: 'pill-warn', after: 'pill-green', detail: 'pill-blue', scene: 'pill-gray' }[t] || 'pill-gray');
|
||||||
|
|
||||||
|
const tab = ref('gallery');
|
||||||
|
const photos = ref([]);
|
||||||
|
const compareData = ref({ before: null, after: null });
|
||||||
|
|
||||||
|
// 上传
|
||||||
|
const fileInput = ref(null);
|
||||||
|
const uploadFiles = ref([]);
|
||||||
|
const uploadType = ref('after');
|
||||||
|
const uploadCaption = ref('');
|
||||||
|
const uploading = ref(false);
|
||||||
|
const uploadProgress = ref(0);
|
||||||
|
const dragOver = ref(false);
|
||||||
|
|
||||||
|
// AI 识别
|
||||||
|
const ai = useAiRecognize();
|
||||||
|
const aiBusy = ai.busy;
|
||||||
|
async function onAiRecognize() {
|
||||||
|
await ai.open('wash', (d) => {
|
||||||
|
if (d.wash_date) data.value.wash_date = d.wash_date;
|
||||||
|
if (d.wash_type) data.value.wash_type = d.wash_type;
|
||||||
|
if (d.cost != null) data.value.cost = d.cost;
|
||||||
|
if (d.location) data.value.location = d.location;
|
||||||
|
if (d.notes) data.value.notes = (data.value.notes ? data.value.notes + '\n' : '') + d.notes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPhotos() {
|
||||||
|
try {
|
||||||
|
const r = await washesApi.listPhotos(route.params.id);
|
||||||
|
photos.value = r.data || [];
|
||||||
|
const c = await washesApi.comparePhotos(route.params.id).catch(() => ({ data: { before: null, after: null } }));
|
||||||
|
compareData.value = c.data;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload() {
|
||||||
|
if (!uploadFiles.value.length) return;
|
||||||
|
uploading.value = true;
|
||||||
|
uploadProgress.value = 0;
|
||||||
|
for (const f of uploadFiles.value) {
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('file', f);
|
||||||
|
fd.append('photo_type', uploadType.value);
|
||||||
|
if (uploadCaption.value) fd.append('caption', uploadCaption.value);
|
||||||
|
await washesApi.uploadPhoto(route.params.id, fd);
|
||||||
|
uploadProgress.value++;
|
||||||
|
} catch (e) {
|
||||||
|
alert('上传失败:' + (e.response?.data?.error?.message || e.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
uploading.value = false;
|
||||||
|
uploadFiles.value = [];
|
||||||
|
uploadCaption.value = '';
|
||||||
|
await loadPhotos();
|
||||||
|
tab.value = 'gallery';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(p) {
|
||||||
|
if (!confirm('删除这张照片?')) return;
|
||||||
|
await washesApi.deletePhoto(route.params.id, p.id);
|
||||||
|
await loadPhotos();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFileChange(e) {
|
||||||
|
const files = Array.from(e.target.files || []);
|
||||||
|
uploadFiles.value.push(...files);
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
function onDrop(e) {
|
||||||
|
dragOver.value = false;
|
||||||
|
const files = Array.from(e.dataTransfer.files || []).filter(f => f.type.startsWith('image/'));
|
||||||
|
uploadFiles.value.push(...files);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
data.value = (await washesApi.get(route.params.id)).data;
|
||||||
|
await loadPhotos();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||||
|
.head-actions { display: flex; gap: 8px; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.row { display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; }
|
||||||
|
.row-top { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||||
|
.big { font-size: 22px; font-weight: 600; margin-top: 2px; }
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px 24px; }
|
||||||
|
.sm { font-size: 12px; }
|
||||||
|
.val { font-size: 15px; font-weight: 500; margin-top: 2px; }
|
||||||
|
.val-sm { font-size: 14px; font-weight: 500; }
|
||||||
|
.notes { padding: 10px 14px; background: var(--bg-soft); border-radius: var(--radius-sm); margin-top: 4px; font-size: 14px; }
|
||||||
|
.chart-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 14px; }
|
||||||
|
.chem-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.chem-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||||
|
.chem-item:last-child { border-bottom: 0; }
|
||||||
|
.meta { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* 对比照 */
|
||||||
|
.photo-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||||
|
.photo-tabs { display: flex; gap: 4px; }
|
||||||
|
.tab { background: transparent; border: 0; padding: 6px 14px; border-radius: var(--pill); font-size: 13px; color: var(--text-soft); cursor: pointer; transition: all .15s; }
|
||||||
|
.tab:hover { background: var(--bg-soft); color: var(--text); }
|
||||||
|
.tab.active { background: var(--accent); color: #fff; }
|
||||||
|
|
||||||
|
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
|
||||||
|
.gallery-item { background: var(--bg-soft); border-radius: var(--radius-sm); overflow: hidden; }
|
||||||
|
.gallery-item img { width: 100%; height: 180px; object-fit: cover; display: block; }
|
||||||
|
.gallery-meta { padding: 8px 10px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.gallery-meta .del { margin-left: auto; padding: 0 8px; }
|
||||||
|
|
||||||
|
.compare-wrap { padding: 12px 0; }
|
||||||
|
.compare-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; }
|
||||||
|
.compare-side { text-align: center; }
|
||||||
|
.compare-label { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
||||||
|
.compare-img-wrap img { width: 100%; max-height: 420px; object-fit: contain; border-radius: 8px; background: var(--bg-soft); }
|
||||||
|
.compare-empty { padding: 80px 16px; background: var(--bg-soft); border-radius: 8px; color: var(--text-mute); }
|
||||||
|
.compare-divider { font-size: 32px; color: var(--text-soft); padding: 0 8px; }
|
||||||
|
|
||||||
|
.upload-row { display: flex; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.label { font-size: 13px; color: var(--text-soft); display: block; margin-bottom: 4px; }
|
||||||
|
.input, .select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; font-size: 14px; background: var(--bg); width: 100%; }
|
||||||
|
.dropzone { display: block; border: 2px dashed var(--line); border-radius: var(--radius); padding: 24px; cursor: pointer; transition: all .15s; text-align: center; }
|
||||||
|
.dropzone.active, .dropzone:hover { border-color: var(--accent); background: rgba(77,186,154,0.05); }
|
||||||
|
.dropzone-empty { color: var(--text-soft); }
|
||||||
|
.dz-icon { font-size: 36px; margin-bottom: 6px; }
|
||||||
|
.dz-file { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--bg-soft); border-radius: 6px; margin: 6px 0; }
|
||||||
|
.dz-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||||
|
|
||||||
|
.btn-xs { padding: 2px 8px; font-size: 11px; }
|
||||||
|
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
.mt-4 { margin-top: 18px; }
|
||||||
|
.mt-1 { margin-top: 4px; }
|
||||||
|
.r { text-align: right; }
|
||||||
|
.text-soft { color: var(--text-soft); }
|
||||||
|
.text-mute { color: var(--text-mute); }
|
||||||
|
.text-danger { color: var(--danger); }
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.row { grid-template-columns: 1fr; }
|
||||||
|
.grid { grid-template-columns: 1fr 1fr; }
|
||||||
|
.compare-row { grid-template-columns: 1fr; }
|
||||||
|
.compare-divider { transform: rotate(90deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head-actions { display: flex; gap: 8px; }
|
||||||
|
.head-actions > * { flex: 1; justify-content: center; }
|
||||||
|
.row-top { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
.row-top .big { font-size: 24px; }
|
||||||
|
.grid { grid-template-columns: 1fr; gap: 12px; }
|
||||||
|
.photo-head { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||||
|
.photo-tabs { width: 100%; overflow-x: auto; flex-wrap: nowrap; }
|
||||||
|
.photo-tabs .tab { flex: 1; white-space: nowrap; }
|
||||||
|
.gallery-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||||
|
.upload-row { flex-direction: column; }
|
||||||
|
.meta { grid-template-columns: 1fr; }
|
||||||
|
.dropzone { padding: 16px; }
|
||||||
|
.dropzone-empty .dz-icon { font-size: 36px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<template>
|
||||||
|
<AppLayout>
|
||||||
|
<div class="head">
|
||||||
|
<div>
|
||||||
|
<h1 class="title">洗车记录</h1>
|
||||||
|
<p class="subtitle text-soft">共 {{ total }} 条记录{{ selectedCount ? ` · 已选 ${selectedCount} 条` : '' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<button
|
||||||
|
v-if="selectedCount > 0"
|
||||||
|
class="btn btn-danger"
|
||||||
|
@click="openBatchDelete"
|
||||||
|
>批量删除 ({{ selectedCount }})</button>
|
||||||
|
<router-link to="/washes/new" class="btn btn-primary">+ 新建</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card filter">
|
||||||
|
<button
|
||||||
|
class="filter-toggle mobile-only"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="filterOpen"
|
||||||
|
@click="filterOpen = !filterOpen"
|
||||||
|
>
|
||||||
|
<span>筛选</span>
|
||||||
|
<span class="filter-count" v-if="activeFilterCount">{{ activeFilterCount }}</span>
|
||||||
|
<span class="chevron" :class="{ on: filterOpen }">▾</span>
|
||||||
|
</button>
|
||||||
|
<div class="filter-body" :class="{ open: filterOpen }">
|
||||||
|
<div class="filter-row">
|
||||||
|
<div>
|
||||||
|
<label class="label">类型</label>
|
||||||
|
<select v-model="filters.type" class="select" @change="reload">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option value="quick">快速</option>
|
||||||
|
<option value="full">标准</option>
|
||||||
|
<option value="detail">精洗</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">车辆</label>
|
||||||
|
<select v-model="filters.vehicle_id" class="select" @change="reload">
|
||||||
|
<option value="">全部</option>
|
||||||
|
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">开始</label>
|
||||||
|
<input v-model="filters.from" type="date" class="input" @change="reload" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="label">结束</label>
|
||||||
|
<input v-model="filters.to" type="date" class="input" @change="reload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<MobileCardList
|
||||||
|
:columns="columns"
|
||||||
|
:rows="rows"
|
||||||
|
:is-selected="isSelected"
|
||||||
|
:empty-text="'暂无记录'"
|
||||||
|
row-key="id"
|
||||||
|
@row-click="(row) => goTo(row.id)"
|
||||||
|
>
|
||||||
|
<template #checkbox="{ row }">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="isSelected(row.id)"
|
||||||
|
@change="toggleOne(row.id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #cell-date="{ row }">
|
||||||
|
{{ row.wash_date }}
|
||||||
|
</template>
|
||||||
|
<template #cell-type="{ row }">
|
||||||
|
<span class="pill" :class="typePill(row.wash_type)">{{ typeLabel(row.wash_type) }}</span>
|
||||||
|
</template>
|
||||||
|
<template #cell-vehicle="{ row }">
|
||||||
|
{{ row.vehicle_name || '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-location="{ row }">
|
||||||
|
{{ row.location || '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-cost="{ row }">
|
||||||
|
¥ {{ Number(row.cost).toFixed(2) }}
|
||||||
|
</template>
|
||||||
|
<template #cell-duration="{ row }">
|
||||||
|
{{ row.duration_min ? row.duration_min + ' 分钟' : '—' }}
|
||||||
|
</template>
|
||||||
|
<template #cell-weather="{ row }">
|
||||||
|
{{ row.weather_desc || '—' }}
|
||||||
|
</template>
|
||||||
|
<template #actions="{ row }">
|
||||||
|
<button class="btn-link" @click.stop="openSingleDelete(row)">删除</button>
|
||||||
|
<span class="text-brand" style="font-size:12px">查看 →</span>
|
||||||
|
</template>
|
||||||
|
<template #empty>
|
||||||
|
暂无记录
|
||||||
|
<div class="mt-2"><router-link to="/washes/new" class="text-brand">+ 新建第一条</router-link></div>
|
||||||
|
</template>
|
||||||
|
</MobileCardList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 批量删除确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="batchDialog.open"
|
||||||
|
v-model="batchDialog.open"
|
||||||
|
title="批量删除洗车记录"
|
||||||
|
:message="`确认要删除 ${batchDialog.ids.length} 条洗车记录?`"
|
||||||
|
mode="math"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||||
|
:busy="batchDialog.busy"
|
||||||
|
:error="batchDialog.error"
|
||||||
|
@confirm="confirmBatchDelete"
|
||||||
|
@cancel="batchDialog.open = false"
|
||||||
|
/>
|
||||||
|
<!-- 单条删除确认 -->
|
||||||
|
<ConfirmDangerDialog
|
||||||
|
v-if="singleDialog.open"
|
||||||
|
v-model="singleDialog.open"
|
||||||
|
title="删除洗车记录"
|
||||||
|
:message="`确认要删除这条记录${singleDialog.row ? '(' + singleDialog.row.wash_date + ' ¥' + Number(singleDialog.row.cost).toFixed(2) + ')' : ''}?`"
|
||||||
|
mode="type"
|
||||||
|
confirm-label="确认删除"
|
||||||
|
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||||
|
:busy="singleDialog.busy"
|
||||||
|
:error="singleDialog.error"
|
||||||
|
@confirm="confirmSingleDelete"
|
||||||
|
@cancel="singleDialog.open = false"
|
||||||
|
/>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import AppLayout from '../components/AppLayout.vue';
|
||||||
|
import MobileCardList from '../components/MobileCardList.vue';
|
||||||
|
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||||
|
import * as washesApi from '../api/washes';
|
||||||
|
import * as vehiclesApi from '../api/vehicles';
|
||||||
|
import { asArray } from '../api/client';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const rows = ref([]);
|
||||||
|
const vehicles = ref([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const filters = reactive({ type: '', vehicle_id: '', from: '', to: '' });
|
||||||
|
const selected = ref(new Set());
|
||||||
|
const filterOpen = ref(false);
|
||||||
|
|
||||||
|
const selectedCount = computed(() => selected.value.size);
|
||||||
|
const allSelected = computed(() => rows.value.length > 0 && selected.value.size === rows.value.length);
|
||||||
|
const someSelected = computed(() => selected.value.size > 0 && selected.value.size < rows.value.length);
|
||||||
|
const activeFilterCount = computed(() => {
|
||||||
|
let n = 0;
|
||||||
|
if (filters.type) n++;
|
||||||
|
if (filters.vehicle_id) n++;
|
||||||
|
if (filters.from) n++;
|
||||||
|
if (filters.to) n++;
|
||||||
|
return n;
|
||||||
|
});
|
||||||
|
|
||||||
|
// MobileCardList 列定义
|
||||||
|
const columns = [
|
||||||
|
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||||
|
{ key: 'type', label: '类型', alwaysShow: true },
|
||||||
|
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||||
|
{ key: 'location', label: '位置' },
|
||||||
|
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||||
|
{ key: 'duration', label: '耗时' },
|
||||||
|
{ key: 'weather', label: '天气' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function isSelected(id) { return selected.value.has(id); }
|
||||||
|
function toggleOne(id) {
|
||||||
|
const s = new Set(selected.value);
|
||||||
|
s.has(id) ? s.delete(id) : s.add(id);
|
||||||
|
selected.value = s;
|
||||||
|
}
|
||||||
|
function toggleAll() {
|
||||||
|
if (allSelected.value) {
|
||||||
|
selected.value = new Set();
|
||||||
|
} else {
|
||||||
|
selected.value = new Set(rows.value.map(r => r.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||||
|
const typePill = (t) => ({ quick: 'pill-blue', full: 'pill-green', detail: 'pill-warn' }[t] || 'pill-gray');
|
||||||
|
function goTo(id) { router.push({ name: 'wash-show', params: { id } }); }
|
||||||
|
|
||||||
|
// 单条删除
|
||||||
|
const singleDialog = reactive({ open: false, row: null, busy: false, error: '' });
|
||||||
|
function openSingleDelete(row) {
|
||||||
|
singleDialog.row = row;
|
||||||
|
singleDialog.busy = false;
|
||||||
|
singleDialog.error = '';
|
||||||
|
singleDialog.open = true;
|
||||||
|
}
|
||||||
|
async function confirmSingleDelete(challenge) {
|
||||||
|
singleDialog.busy = true;
|
||||||
|
singleDialog.error = '';
|
||||||
|
try {
|
||||||
|
await washesApi.remove(singleDialog.row.id);
|
||||||
|
singleDialog.open = false;
|
||||||
|
selected.value.delete(singleDialog.row.id);
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
singleDialog.error = e.response?.data?.error?.message || e.message;
|
||||||
|
} finally {
|
||||||
|
singleDialog.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const batchDialog = reactive({ open: false, ids: [], busy: false, error: '' });
|
||||||
|
function openBatchDelete() {
|
||||||
|
batchDialog.ids = [...selected.value];
|
||||||
|
batchDialog.busy = false;
|
||||||
|
batchDialog.error = '';
|
||||||
|
batchDialog.open = true;
|
||||||
|
}
|
||||||
|
async function confirmBatchDelete(challenge) {
|
||||||
|
batchDialog.busy = true;
|
||||||
|
batchDialog.error = '';
|
||||||
|
try {
|
||||||
|
await washesApi.batchDelete(batchDialog.ids, challenge);
|
||||||
|
batchDialog.open = false;
|
||||||
|
selected.value = new Set();
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
singleDialog.error = '';
|
||||||
|
batchDialog.error = e.response?.data?.error?.message || e.message;
|
||||||
|
// 题目答错时换一道新题
|
||||||
|
if (e.response?.data?.error?.code === 'CONFIRM_FAIL') {
|
||||||
|
batchDialog.open = false;
|
||||||
|
setTimeout(() => { batchDialog.open = true; }, 50);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchDialog.busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const r = await vehiclesApi.list();
|
||||||
|
vehicles.value = asArray(r.data, 'vehicles');
|
||||||
|
} catch {}
|
||||||
|
reload();
|
||||||
|
});
|
||||||
|
async function reload() {
|
||||||
|
const params = { ...filters };
|
||||||
|
if (!params.type) delete params.type;
|
||||||
|
if (!params.vehicle_id) delete params.vehicle_id;
|
||||||
|
if (!params.from) delete params.from;
|
||||||
|
if (!params.to) delete params.to;
|
||||||
|
const r = await washesApi.list(params);
|
||||||
|
rows.value = r.data.rows || [];
|
||||||
|
total.value = r.data.total || 0;
|
||||||
|
// 清理已不存在的选中项
|
||||||
|
const ids = new Set(rows.value.map(r => r.id));
|
||||||
|
selected.value = new Set([...selected.value].filter(id => ids.has(id)));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; gap: 12px; flex-wrap: wrap; }
|
||||||
|
.head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||||
|
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||||
|
.filter { padding: 16px 20px; }
|
||||||
|
.filter-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||||
|
.check-col { width: 36px; padding-left: 16px; padding-right: 0; }
|
||||||
|
.row-link { cursor: pointer; }
|
||||||
|
.row-link.selected { background: rgba(232, 244, 249, 0.5); }
|
||||||
|
.row-actions { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
|
||||||
|
.btn-link {
|
||||||
|
background: none; border: 0; padding: 2px 4px; cursor: pointer;
|
||||||
|
color: var(--danger); font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-link:hover { text-decoration: underline; }
|
||||||
|
.btn-danger { background: var(--danger); color: #fff; padding: 8px 16px; border-radius: var(--pill); border: 0; font-size: 14px; cursor: pointer; }
|
||||||
|
.btn-danger:hover { background: #d63c2f; }
|
||||||
|
|
||||||
|
/* === 移动端筛选折叠 === */
|
||||||
|
.filter-toggle {
|
||||||
|
display: none;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.filter-count {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center; justify-content: center;
|
||||||
|
min-width: 20px; height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: var(--pill);
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
.chevron {
|
||||||
|
transition: transform .2s;
|
||||||
|
color: var(--text-soft);
|
||||||
|
}
|
||||||
|
.chevron.on { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
/* === 响应式 === */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.filter-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.title { font-size: 20px; }
|
||||||
|
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||||
|
.head-actions { width: 100%; }
|
||||||
|
.head-actions .btn { flex: 1; justify-content: center; }
|
||||||
|
|
||||||
|
.filter { padding: 0; background: transparent; box-shadow: none; }
|
||||||
|
.filter-toggle { display: flex; }
|
||||||
|
.filter-body {
|
||||||
|
display: none;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
.filter-body.open { display: block; }
|
||||||
|
.filter-row { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479px) {
|
||||||
|
.head-actions .btn { padding: 8px 12px; font-size: 13px; }
|
||||||
|
.head-actions .btn-danger { padding: 6px 10px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import vue from '@vitejs/plugin-vue';
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
injectRegister: 'auto', // 自动注入 register 脚本
|
||||||
|
includeAssets: ['favicon-16x16.png', 'favicon-32x32.png'],
|
||||||
|
manifest: {
|
||||||
|
id: '/',
|
||||||
|
name: 'CarLog 洗车管理系统',
|
||||||
|
short_name: 'CarLog',
|
||||||
|
description: '记录洗车/加油/充电/保养/保险/车品的全能车辆账本',
|
||||||
|
lang: 'zh-CN',
|
||||||
|
dir: 'ltr',
|
||||||
|
start_url: '/',
|
||||||
|
scope: '/',
|
||||||
|
display: 'standalone',
|
||||||
|
orientation: 'portrait',
|
||||||
|
background_color: '#F5F8FC',
|
||||||
|
theme_color: '#1B6EF3',
|
||||||
|
categories: ['productivity', 'lifestyle', 'utilities'],
|
||||||
|
icons: [
|
||||||
|
{ src: '/pwa/pwa-192x192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||||
|
{ src: '/pwa/pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||||
|
{
|
||||||
|
src: '/pwa/pwa-maskable-512x512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/pwa/apple-touch-icon.png',
|
||||||
|
sizes: '180x180',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'any',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shortcuts: [
|
||||||
|
{
|
||||||
|
name: '拍照录入',
|
||||||
|
short_name: '拍照',
|
||||||
|
url: '/washes/new?capture=1',
|
||||||
|
description: '打开相机快速拍照记录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '新建洗车',
|
||||||
|
short_name: '洗车',
|
||||||
|
url: '/washes/new',
|
||||||
|
description: '快速记录一次洗车',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '新建加油',
|
||||||
|
short_name: '加油',
|
||||||
|
url: '/refuel/new',
|
||||||
|
description: '快速记录一次加油',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '新建保养',
|
||||||
|
short_name: '保养',
|
||||||
|
url: '/maintenance/new',
|
||||||
|
description: '快速记录一次保养',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// 拍照快捷方式行为
|
||||||
|
capture_links: ['/washes/new?capture=1'],
|
||||||
|
launch_handler: { client_mode: 'auto' },
|
||||||
|
},
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'],
|
||||||
|
navigateFallback: '/index.html',
|
||||||
|
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: ({ url }) => url.pathname.startsWith('/api/') && url.pathname.includes('/static'),
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-static',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: ({ url }) => url.pathname.startsWith('/uploads/'),
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: {
|
||||||
|
cacheName: 'uploads',
|
||||||
|
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: ({ request }) => request.destination === 'image',
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: { cacheName: 'images' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
urlPattern: ({ request }) => request.destination === 'font',
|
||||||
|
handler: 'CacheFirst',
|
||||||
|
options: { cacheName: 'fonts' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: {
|
||||||
|
enabled: false, // dev 不开 SW(避免 HMR 干扰)
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8787',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['vue', 'pinia', 'axios', 'chart.js/auto'],
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
cssCodeSplit: true,
|
||||||
|
target: 'es2018',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (id.includes('node_modules')) {
|
||||||
|
if (id.includes('chart.js') || id.includes('vue-chartjs')) return 'chart';
|
||||||
|
if (id.includes('echarts') || id.includes('zrender')) return 'chart';
|
||||||
|
if (id.includes('workbox') || id.includes('vite-plugin-pwa')) return 'pwa';
|
||||||
|
if (id.includes('vue') || id.includes('pinia') || id.includes('@vue')) return 'vue';
|
||||||
|
if (
|
||||||
|
id.includes('axios') ||
|
||||||
|
id.includes('dayjs') ||
|
||||||
|
id.includes('dompurify')
|
||||||
|
)
|
||||||
|
return 'utils';
|
||||||
|
return 'vendor';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 文件名带 hash,长期缓存友好
|
||||||
|
entryFileNames: 'assets/[name]-[hash].js',
|
||||||
|
chunkFileNames: 'assets/[name]-[hash].js',
|
||||||
|
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
chunkSizeWarningLimit: 600,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Debug Session: page-loading-spinner
|
||||||
|
|
||||||
|
**Status**: [OPEN]
|
||||||
|
**Symptom**: http://localhost:5173/ shows a loading spinner / "转圈圈" - page never finishes loading
|
||||||
|
**User-reported**: 2026-06-19
|
||||||
|
**Environment**:
|
||||||
|
- macOS, Vite dev server on :5173 (PID 28109), Node server on :8787 (PID 52581)
|
||||||
|
- DB: MySQL @ 162.14.110.130:33306 (carlog) — `.setup_done` present
|
||||||
|
- API health: 200 OK, /api/auth/me → 401 (expected for unauthenticated)
|
||||||
|
- HTML / main.js / proxied /api endpoints all return quickly
|
||||||
|
|
||||||
|
## Hypotheses
|
||||||
|
|
||||||
|
1. **H1 — Auth bootstrap redirect loop**: `/api/auth/me` returns 401, the axios 401 interceptor fires `location.href = '/login?redirect=%2F'`, hard navigation reloads page, the same chain runs again, the browser tab keeps showing the loading state of a SPA re-mount.
|
||||||
|
2. **H2 — Home.vue `loading = true` stuck**: User is already logged in (session cookie valid), lands on `/` (Home), the 6 parallel API calls in `Home.vue#onMounted` never resolve (one of them hangs → `Promise.all` never settles → `loading.value` stays `true` → "加载中…" rendered forever).
|
||||||
|
3. **H3 — Vite HMR / module error**: A recent code change in client broke a module, throwing on import; the Vue app never mounts, only `<div id="app"></div>` is visible and the browser tab shows loading.
|
||||||
|
4. **H4 — Login page itself renders a spinner**: After redirect to `/login`, something on the Login page (an external CSS or font request, etc.) keeps the page in a "spinning" state perceived as still loading.
|
||||||
|
5. **H5 — Server DB query hanging for Home's API**: One of the 6 endpoints (e.g. `/stats/overview`, `/dashboard/extra`) hits a long-running SQL on the remote MySQL; the request times out at 15s, but `loading` may not flip until all settle — perceived as a permanent spinner.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
- Step 1: Start debug server (collector) and add light instrumentation
|
||||||
|
- Step 2: Reload the page; collect runtime evidence
|
||||||
|
- Step 3: Pick the right hypothesis and apply a minimal fix
|
||||||
|
- Step 4: Re-run and compare pre/post logs
|
||||||
|
- Step 5: Cleanup on user confirmation
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
# 宝塔面板安装(Node.js 版)
|
||||||
|
|
||||||
|
> 适用:CentOS 7+ / Ubuntu 20+ / Debian 11+,宝塔 7.7+,Node.js ≥ 20
|
||||||
|
|
||||||
|
## 0. 系统要求
|
||||||
|
|
||||||
|
- Node.js **20+**(宝塔「软件商店」→ Node.js 版本管理器 → 安装 20.x LTS)
|
||||||
|
- 内存 ≥ 512 MB,磁盘 ≥ 2 GB
|
||||||
|
- 一台已解析好的域名(可选,纯 IP 也能跑)
|
||||||
|
|
||||||
|
## 1. 宝塔安装 Node.js
|
||||||
|
|
||||||
|
「软件商店」搜索 **Node.js 版本管理器** → 安装 → 切换到 **20.x** → 设为默认。
|
||||||
|
|
||||||
|
SSH 验证:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node -v # 应显示 v20.x.x
|
||||||
|
npm -v # 应显示 10.x
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 创建站点 + 上传代码
|
||||||
|
|
||||||
|
### 方式 A:直接上传 zip
|
||||||
|
|
||||||
|
1. 宝塔 → 网站 → **添加站点**(PHP 静态都无所谓,因为走 Node)→ 记下站点根目录,如 `/www/wwwroot/carwash.example.com`
|
||||||
|
2. 上传 `carwash-system-v2.zip` 到站点根目录
|
||||||
|
3. SSH 到服务器:
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash.example.com
|
||||||
|
unzip -o carwash-system-v2.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 B:git 拉取
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash.example.com
|
||||||
|
git clone <your-repo-url> .
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 安装依赖 + 构建前端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash.example.com
|
||||||
|
npm run install:all
|
||||||
|
```
|
||||||
|
|
||||||
|
这会执行:
|
||||||
|
- `npm install --prefix server`(约 100 个包,含 better-sqlite3,需编译)
|
||||||
|
- `npm install --prefix client`(约 200 个包,纯 JS)
|
||||||
|
- `npm run build --prefix client`(Vite 打包到 `client/dist/`)
|
||||||
|
|
||||||
|
> ⚠ **better-sqlite3 需要 Node-gyp 编译**。如遇 `node-gyp` 报错:
|
||||||
|
> ```bash
|
||||||
|
> # CentOS
|
||||||
|
> yum install -y python3 make gcc gcc-c++ nodejs
|
||||||
|
> # Ubuntu/Debian
|
||||||
|
> apt install -y python3 make g++ build-essential
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 4. 初始化数据库
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash.example.com
|
||||||
|
npm run migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
期望输出:
|
||||||
|
```
|
||||||
|
✓ Migration 0001_init.sql
|
||||||
|
✓ Migration 0002_auth.sql
|
||||||
|
✓ Migration 0003_vehicles.sql
|
||||||
|
|
||||||
|
✓ 已创建默认管理员账号
|
||||||
|
用户名: admin
|
||||||
|
密码: carwash2026
|
||||||
|
⚠ 首次登录后请到「设置 → 账户」修改密码!
|
||||||
|
```
|
||||||
|
|
||||||
|
可选灌入演示数据:`npm run seed-demo`
|
||||||
|
|
||||||
|
## 5. 配置 .env
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env
|
||||||
|
```
|
||||||
|
|
||||||
|
填天气 API key、Grocy 凭证等(全部可留空,留空不影响启动;只是相关功能不可用)。
|
||||||
|
|
||||||
|
## 6. 启动服务(PM2)
|
||||||
|
|
||||||
|
宝塔「软件商店」搜索 **PM2 管理器** → 安装。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash.example.com
|
||||||
|
pm2 start npm --name carwash -- run serve
|
||||||
|
pm2 save
|
||||||
|
pm2 startup # 设置开机启动(按提示复制粘贴输出的命令)
|
||||||
|
```
|
||||||
|
|
||||||
|
验证:
|
||||||
|
```bash
|
||||||
|
pm2 list # 看到 carwash status = online
|
||||||
|
curl http://127.0.0.1:8787/api/health
|
||||||
|
# → {"ok":true,...}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Nginx 反向代理
|
||||||
|
|
||||||
|
宝塔 → 网站 → 你的站点 → **设置** → **反向代理**:
|
||||||
|
|
||||||
|
- 代理名称:carwash
|
||||||
|
- 目标 URL:`http://127.0.0.1:8787`
|
||||||
|
- 发送域名:留空
|
||||||
|
|
||||||
|
或在「配置文件」里把 `location /` 替换为:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8787;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 上传大小限制(备份恢复可能上传大文件)
|
||||||
|
client_max_body_size 50M;
|
||||||
|
```
|
||||||
|
|
||||||
|
> 不需要 PHP,不需要伪静态规则。Node 直接吐 `index.html`。
|
||||||
|
|
||||||
|
## 8. 申请 HTTPS(强烈建议)
|
||||||
|
|
||||||
|
宝塔 → 站点 → **SSL** → Let's Encrypt → 申请 → 强制 HTTPS。
|
||||||
|
|
||||||
|
## 9. 备份与 cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 每天凌晨 3 点备份
|
||||||
|
crontab -e
|
||||||
|
0 3 * * * cd /www/wwwroot/carwash.example.com && npm run backup
|
||||||
|
```
|
||||||
|
|
||||||
|
备份保留 10 份,自动清理老的(`storage/backups/carwash-YYYYMMDDHHMM.tar.gz`)。
|
||||||
|
|
||||||
|
## 10. 升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /www/wwwroot/carwash.example.com
|
||||||
|
git pull # 或重新上传新版 zip 后 unzip -o
|
||||||
|
npm run install:all
|
||||||
|
npm run migrate
|
||||||
|
pm2 restart carwash
|
||||||
|
```
|
||||||
|
|
||||||
|
数据库结构变更在 `server/migrations/0004_*.sql` 这种格式里加,`migrate` 会自动跳过已应用的。
|
||||||
|
|
||||||
|
## 11. 常见问题
|
||||||
|
|
||||||
|
**Q: 端口 8787 被占?**
|
||||||
|
A: 编辑 `.env` 改 `PORT=8788`,然后 `pm2 restart carwash` + 改 nginx 代理。
|
||||||
|
|
||||||
|
**Q: better-sqlite3 安装失败?**
|
||||||
|
A: 系统缺少编译工具链,安装 python3 + make + g++(见步骤 3)。
|
||||||
|
|
||||||
|
**Q: 忘记 admin 密码?**
|
||||||
|
A: SSH 跑 `npm run users -- passwd admin 新密码`。
|
||||||
|
|
||||||
|
**Q: 浏览器打开页面是空白?**
|
||||||
|
A: 检查 `pm2 logs carwash`;最常见是没跑 `npm run build:client` 或 nginx 配置没指向 Node。
|
||||||
|
|
||||||
|
**Q: SQLite 锁了?**
|
||||||
|
A: 检查 `server/data/` 下有没有遗留的 `-wal` / `-shm` 文件没被清理;正常 `pm2 stop` 会触发 checkpoint。
|
||||||
|
|
||||||
|
**Q: 多设备共享数据?**
|
||||||
|
A: 这是个人单机系统。如果要支持远程访问,**必须**走 HTTPS + 反向代理(本教程标准配置),不要直接暴露 8787 端口到公网。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"url": [
|
||||||
|
"http://localhost:4173/login",
|
||||||
|
"http://localhost:4173/"
|
||||||
|
],
|
||||||
|
"numberOfRuns": 1,
|
||||||
|
"settings": {
|
||||||
|
"preset": "desktop",
|
||||||
|
"chromeFlags": "--user-data-dir=/tmp/lh-cache --no-sandbox --disable-dev-shm-usage --disable-gpu",
|
||||||
|
"skipAudits": ["uses-http2", "is-on-https", "redirects-http"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"assert": {
|
||||||
|
"assertions": {
|
||||||
|
"categories:performance": ["warn", { "minScore": 0.6 }],
|
||||||
|
"categories:accessibility": ["error", { "minScore": 0.7 }],
|
||||||
|
"categories:best-practices": ["warn", { "minScore": 0.7 }],
|
||||||
|
"categories:seo": ["warn", { "minScore": 0.6 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"name": "carwash-system",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "个人自用洗车记录系统 - Vue 3 + Node.js + MySQL/SQLite",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"install:all": "npm install && npm install --prefix server && npm install --prefix client && npm run build:client",
|
||||||
|
"build:client": "npm run build --prefix client",
|
||||||
|
"dev": "concurrently -k -n SERVER,CLIENT -c green,cyan \"npm:serve\" \"npm:dev:client\"",
|
||||||
|
"dev:client": "npm run dev --prefix client",
|
||||||
|
"migrate": "node server/src/bin/migrate.js",
|
||||||
|
"serve": "node server/src/bin/serve.js",
|
||||||
|
"users": "node server/src/bin/users.js",
|
||||||
|
"weather": "node server/src/bin/weather.js",
|
||||||
|
"grocy-sync": "node server/src/bin/grocy-sync.js",
|
||||||
|
"grocy-refresh-products": "node server/src/bin/grocy-refresh-products.js",
|
||||||
|
"export": "node server/src/bin/export.js",
|
||||||
|
"backup": "node server/src/bin/backup.js",
|
||||||
|
"seed-demo": "node server/src/bin/seed-demo.js",
|
||||||
|
"verify": "node server/src/bin/verify.js",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"lighthouse": "lhci autorun",
|
||||||
|
"lighthouse:pwa": "node client/scripts/check-pwa.mjs",
|
||||||
|
"a11y": "pa11y-ci --config .pa11yci.json http://localhost:4173/login"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@lhci/cli": "^0.15.1",
|
||||||
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
|
"concurrently": "^9.0.1",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"husky": "^9.1.0",
|
||||||
|
"lint-staged": "^15.2.0",
|
||||||
|
"pa11y-ci": "^4.1.1",
|
||||||
|
"prettier": "^3.3.3",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"vitest": "^2.1.0",
|
||||||
|
"vue-eslint-parser": "^9.4.3"
|
||||||
|
},
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,vue}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{json,md,yml,yaml,css}": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0001: 基础表(Node.js / better-sqlite3 版)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 基础 PRAGMA 由 server/src/db.js 统一设置(journal_mode=WAL / foreign_keys=ON / synchronous=NORMAL / busy_timeout=5000)
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. chemicals - 药剂字典(Grocy 缓存层)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS chemicals (
|
||||||
|
grocy_product_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT,
|
||||||
|
unit TEXT NOT NULL DEFAULT 'ml',
|
||||||
|
standard_dose REAL,
|
||||||
|
notes TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
fetched_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
PRIMARY KEY (grocy_product_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chemicals_category ON chemicals(category);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chemicals_active ON chemicals(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chemicals_fetched ON chemicals(fetched_at);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. weather_snapshots - 天气快照
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS weather_snapshots (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_date TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('qweather','openweathermap')),
|
||||||
|
temp_c REAL,
|
||||||
|
humidity INTEGER,
|
||||||
|
weather_desc TEXT,
|
||||||
|
weather_code TEXT,
|
||||||
|
wind_kph REAL,
|
||||||
|
precip_mm REAL,
|
||||||
|
raw_json TEXT,
|
||||||
|
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_weather_city_date ON weather_snapshots(city, snapshot_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weather_date ON weather_snapshots(snapshot_date);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. wash_records - 洗车记录
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wash_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
wash_date TEXT NOT NULL,
|
||||||
|
wash_type TEXT NOT NULL CHECK (wash_type IN ('quick','full','detail','other')),
|
||||||
|
weather_snapshot_id INTEGER,
|
||||||
|
location TEXT,
|
||||||
|
cost REAL NOT NULL DEFAULT 0,
|
||||||
|
duration_min INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wash_records_date ON wash_records(wash_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wash_records_type ON wash_records(wash_type);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wash_records_weather ON wash_records(weather_snapshot_id);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. chemical_usage - 药剂消耗
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS chemical_usage (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
usage_date TEXT NOT NULL,
|
||||||
|
chemical_id TEXT NOT NULL,
|
||||||
|
amount REAL NOT NULL,
|
||||||
|
wash_record_id INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
sync_status TEXT NOT NULL DEFAULT 'pending' CHECK (sync_status IN ('pending','synced','failed')),
|
||||||
|
sync_at TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_date ON chemical_usage(usage_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_chemical ON chemical_usage(chemical_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_wash ON chemical_usage(wash_record_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_usage_sync ON chemical_usage(sync_status);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. settings - 运行时配置 KV
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||||
|
description TEXT,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 预置 11 个 key
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||||
|
('db_path', NULL, 0, '数据库路径(SQLite 模式)'),
|
||||||
|
('app_city', NULL, 0, '所在城市(用于天气查询)'),
|
||||||
|
('app_timezone', 'Asia/Shanghai', 0, 'PHP 时区(保留兼容)'),
|
||||||
|
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
|
||||||
|
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
|
||||||
|
('weather_provider', 'qweather', 0, '天气提供方 qweather/openweathermap'),
|
||||||
|
('qweather_api_key', NULL, 1, '和风天气 API key'),
|
||||||
|
('qweather_api_host', 'api.qweather.com', 0, '和风 API host'),
|
||||||
|
('openweathermap_api_key', NULL, 1, 'OpenWeatherMap API key'),
|
||||||
|
('backup_keep_count', '10', 0, '本地备份保留份数'),
|
||||||
|
('backup_dir', 'storage/backups', 0, '备份输出目录');
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 6. 视图
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_wash_monthly_count;
|
||||||
|
CREATE VIEW v_wash_monthly_count AS
|
||||||
|
SELECT
|
||||||
|
substr(wash_date, 1, 7) AS month,
|
||||||
|
COUNT(*) AS wash_count,
|
||||||
|
SUM(COALESCE(cost, 0)) AS total_cost
|
||||||
|
FROM wash_records
|
||||||
|
GROUP BY substr(wash_date, 1, 7)
|
||||||
|
ORDER BY month DESC;
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_chemical_monthly_usage;
|
||||||
|
CREATE VIEW v_chemical_monthly_usage AS
|
||||||
|
SELECT
|
||||||
|
substr(cu.usage_date, 1, 7) AS month,
|
||||||
|
c.grocy_product_id AS grocy_product_id,
|
||||||
|
c.name AS chemical_name,
|
||||||
|
c.unit AS unit,
|
||||||
|
SUM(cu.amount) AS total_amount,
|
||||||
|
COUNT(*) AS usage_count
|
||||||
|
FROM chemical_usage cu
|
||||||
|
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
|
||||||
|
GROUP BY substr(cu.usage_date, 1, 7), c.grocy_product_id
|
||||||
|
ORDER BY month DESC, total_amount DESC;
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_last_wash;
|
||||||
|
CREATE VIEW v_last_wash AS
|
||||||
|
SELECT
|
||||||
|
id AS wash_id,
|
||||||
|
wash_date,
|
||||||
|
wash_type,
|
||||||
|
CAST(julianday('now') - julianday(wash_date) AS INTEGER) AS days_since
|
||||||
|
FROM wash_records
|
||||||
|
ORDER BY wash_date DESC, id DESC
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0002: 用户认证 + 防撞库
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. users - 登录账号
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
last_login_at TEXT,
|
||||||
|
last_login_ip TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. login_attempts - 登录尝试记录
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
attempted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
ip_address TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL CHECK (success IN (0, 1)),
|
||||||
|
user_agent TEXT,
|
||||||
|
failure_reason TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attempts_ip_time ON login_attempts(ip_address, attempted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attempts_user_time ON login_attempts(username, attempted_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_attempts_time ON login_attempts(attempted_at);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. auth_locks - 锁状态
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_locks (
|
||||||
|
lock_key TEXT PRIMARY KEY,
|
||||||
|
lock_type TEXT NOT NULL CHECK (lock_type IN ('ip','user')),
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
locked_until TEXT NOT NULL,
|
||||||
|
reason TEXT,
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_locks_until ON auth_locks(locked_until);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. auth 设置 seed
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||||
|
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
|
||||||
|
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
|
||||||
|
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
|
||||||
|
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
|
||||||
|
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
|
||||||
|
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
|
||||||
|
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
|
||||||
|
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
|
||||||
|
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
|
||||||
|
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
|
||||||
|
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0003: 车辆管理
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. vehicles - 车辆字典
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
plate TEXT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'car' CHECK (type IN ('car','suv','mpv','truck','other')),
|
||||||
|
color TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vehicles_active ON vehicles(is_active);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vehicles_sort ON vehicles(sort_order);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. wash_records 加 vehicle_id
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE wash_records ADD COLUMN vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wash_records_vehicle ON wash_records(vehicle_id);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. 视图:v_last_wash 加 vehicle_name
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DROP VIEW IF EXISTS v_last_wash;
|
||||||
|
CREATE VIEW v_last_wash AS
|
||||||
|
SELECT
|
||||||
|
w.id AS wash_id,
|
||||||
|
w.wash_date,
|
||||||
|
w.wash_type,
|
||||||
|
w.vehicle_id,
|
||||||
|
v.name AS vehicle_name,
|
||||||
|
CAST(julianday('now') - julianday(w.wash_date) AS INTEGER) AS days_since
|
||||||
|
FROM wash_records w
|
||||||
|
LEFT JOIN vehicles v ON v.id = w.vehicle_id
|
||||||
|
ORDER BY w.wash_date DESC, w.id DESC
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0004: Grocy 主数据同步字段
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 1. chemicals 表加 Grocy 完整字段
|
||||||
|
ALTER TABLE chemicals ADD COLUMN description TEXT;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN current_amount REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN current_value REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN min_stock_amount REAL NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN best_before_date TEXT;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN location TEXT;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN product_group_id INTEGER;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN qu_id INTEGER;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN location_id INTEGER;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN picture_file_name TEXT;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN last_synced_at TEXT;
|
||||||
|
|
||||||
|
-- 2. 索引
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chem_amount ON chemicals(current_amount);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chem_pg ON chemicals(product_group_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_chem_synced ON chemicals(last_synced_at);
|
||||||
|
|
||||||
|
-- 3. Grocy 设置 seed
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||||
|
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
|
||||||
|
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比,库存/min_stock_amount * 100 <= 该值时标红)'),
|
||||||
|
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0005: 资料来源 + 分类映射 + 用品详情
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 1. chemicals 表加资料来源 + 同步元数据
|
||||||
|
ALTER TABLE chemicals ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||||
|
-- source: 'grocy' | 'manual' | 'seed'
|
||||||
|
ALTER TABLE chemicals ADD COLUMN grocy_last_pulled_at TEXT;
|
||||||
|
|
||||||
|
-- 2. 分类映射表(grocy_product_group_id → 真实名字)
|
||||||
|
CREATE TABLE IF NOT EXISTS category_mappings (
|
||||||
|
grocy_group_id INTEGER PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 3. chemical_inventory_log 进销存记录(本地系统用)
|
||||||
|
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
chemical_id TEXT NOT NULL,
|
||||||
|
change_type TEXT NOT NULL CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust')),
|
||||||
|
amount_delta REAL NOT NULL,
|
||||||
|
amount_after REAL,
|
||||||
|
source TEXT NOT NULL DEFAULT 'local',
|
||||||
|
-- 'local' = 本系统产生,'grocy' = 来自 Grocy
|
||||||
|
source_ref TEXT,
|
||||||
|
-- 外部引用(如 Grocy stock log id、wash_record_id 等)
|
||||||
|
note TEXT,
|
||||||
|
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invlog_chem ON chemical_inventory_log(chemical_id, occurred_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invlog_type ON chemical_inventory_log(change_type);
|
||||||
|
|
||||||
|
-- 4. settings seed
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||||
|
('grocy_categories_json', '[]', 0, 'Grocy 分类 ID → 显示名映射(JSON: [{id, name}])');
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0006: 单位换算
|
||||||
|
-- =============================================================================
|
||||||
|
-- qu_factor: 1 个 user_input_unit 包含多少 stock_unit(1=无换算)
|
||||||
|
-- 例如:阿达姆斯加仑装 stock_unit=毫升,qu_factor=3785(1 加仑 = 3785 毫升)
|
||||||
|
ALTER TABLE chemicals ADD COLUMN qu_factor REAL NOT NULL DEFAULT 1.0;
|
||||||
|
ALTER TABLE chemicals ADD COLUMN consume_unit_id INTEGER; -- Grocy qu_id_consume
|
||||||
|
ALTER TABLE chemicals ADD COLUMN consume_unit_name TEXT; -- 显示用
|
||||||
|
|
||||||
|
-- chemical_usage 加 stock_amount(最小单位,扣减 Grocy 用)
|
||||||
|
ALTER TABLE chemical_usage ADD COLUMN unit TEXT; -- 用户输入的单位
|
||||||
|
ALTER TABLE chemical_usage ADD COLUMN stock_amount REAL; -- 换算后 Grocy stock unit 量
|
||||||
|
ALTER TABLE chemical_usage ADD COLUMN consume_unit_id INTEGER;
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
-- 0007_vehicle_logs.sql — 保养 / 加油 / 充电三类用车记录
|
||||||
|
-- 三张表结构对称:(vehicle_id, log_date, odometer_km, location, total_cost, notes, created_at)
|
||||||
|
-- 业务差异字段单独存
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS maintenance_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
vehicle_id INTEGER NOT NULL,
|
||||||
|
maint_date TEXT NOT NULL,
|
||||||
|
odometer_km INTEGER,
|
||||||
|
total_cost REAL NOT NULL DEFAULT 0,
|
||||||
|
shop TEXT,
|
||||||
|
items_json TEXT NOT NULL DEFAULT '[]', -- [{name, cost, interval_km}, ...]
|
||||||
|
next_due_date TEXT,
|
||||||
|
next_due_km INTEGER,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_maint_vehicle_date ON maintenance_records(vehicle_id, maint_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_maint_date ON maintenance_records(maint_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refuel_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
vehicle_id INTEGER NOT NULL,
|
||||||
|
refuel_date TEXT NOT NULL,
|
||||||
|
odometer_km INTEGER,
|
||||||
|
liters REAL NOT NULL,
|
||||||
|
price_per_liter REAL,
|
||||||
|
total_cost REAL NOT NULL,
|
||||||
|
fuel_type TEXT, -- 92 / 95 / 98 / 0#柴油 / 自定义
|
||||||
|
is_full INTEGER NOT NULL DEFAULT 0, -- 是否加满(计算油耗需要)
|
||||||
|
station TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refuel_vehicle_date ON refuel_records(vehicle_id, refuel_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_refuel_date ON refuel_records(refuel_date DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS charging_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
vehicle_id INTEGER NOT NULL,
|
||||||
|
charge_date TEXT NOT NULL,
|
||||||
|
odometer_km INTEGER,
|
||||||
|
kwh REAL NOT NULL,
|
||||||
|
price_per_kwh REAL,
|
||||||
|
total_cost REAL NOT NULL,
|
||||||
|
charge_type TEXT, -- slow (慢充/交流) / fast (快充/直流) / home (家充) / public (公共桩)
|
||||||
|
start_soc INTEGER, -- 起始电量 %
|
||||||
|
end_soc INTEGER, -- 结束电量 %
|
||||||
|
station TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_charging_vehicle_date ON charging_records(vehicle_id, charge_date DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_charging_date ON charging_records(charge_date DESC);
|
||||||
|
|
||||||
|
-- 一张视图方便首页拿"最近 30 天每类最新 5 条"
|
||||||
|
DROP VIEW IF EXISTS v_recent_logs;
|
||||||
|
CREATE VIEW v_recent_logs AS
|
||||||
|
SELECT 'maintenance' AS log_type, id, vehicle_id, maint_date AS log_date,
|
||||||
|
total_cost, odometer_km, shop AS location
|
||||||
|
FROM maintenance_records
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'refuel' AS log_type, id, vehicle_id, refuel_date AS log_date,
|
||||||
|
total_cost, odometer_km, station AS location
|
||||||
|
FROM refuel_records
|
||||||
|
UNION ALL
|
||||||
|
SELECT 'charging' AS log_type, id, vehicle_id, charge_date AS log_date,
|
||||||
|
total_cost, odometer_km, station AS location
|
||||||
|
FROM charging_records;
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- 0008_mileage_and_insurance.sql
|
||||||
|
-- 1. 混动车:保养记录加 EV 里程和 HEV 里程
|
||||||
|
ALTER TABLE maintenance_records ADD COLUMN ev_km INTEGER;
|
||||||
|
ALTER TABLE maintenance_records ADD COLUMN hev_km INTEGER;
|
||||||
|
|
||||||
|
-- 2. 保险记录(含附件)
|
||||||
|
CREATE TABLE IF NOT EXISTS insurance_records (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
vehicle_id INTEGER NOT NULL,
|
||||||
|
insurance_type TEXT NOT NULL, -- 交强险 / 商业险 / 车损险 / 三责险 / 座位险 / 不计免赔 / 玻璃险 / 划痕险 / 自燃险 / 涉水险
|
||||||
|
company TEXT, -- 人保 / 平安 / 太保 / 中华 / ...
|
||||||
|
policy_no TEXT, -- 保单号
|
||||||
|
start_date TEXT NOT NULL, -- 生效日
|
||||||
|
end_date TEXT NOT NULL, -- 到期日
|
||||||
|
premium REAL, -- 保费
|
||||||
|
coverage_amount REAL, -- 保额(可选)
|
||||||
|
notes TEXT,
|
||||||
|
attachment_path TEXT, -- 保单图片/PDF 相对路径(uploads/insurance/xxx.pdf)
|
||||||
|
attachment_name TEXT, -- 原文件名
|
||||||
|
attachment_mime TEXT, -- mime type
|
||||||
|
attachment_size INTEGER, -- 字节
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_insurance_vehicle ON insurance_records(vehicle_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_insurance_end_date ON insurance_records(end_date);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 0009_vehicle_powertrain.sql
|
||||||
|
-- 车辆动力类型:纯油 (ice) / 混动 (hev) / 纯电 (ev) / 增程 (erev)
|
||||||
|
-- 油耗只在 ice 上算;电耗只在 ev 上算;hev/erev 算不了(分不清油/电)
|
||||||
|
ALTER TABLE vehicles ADD COLUMN powertrain TEXT NOT NULL DEFAULT 'ice'
|
||||||
|
CHECK (powertrain IN ('ice', 'hev', 'ev', 'erev'));
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- 0010_operation_logs.sql — 操作日志(审计用)
|
||||||
|
-- 记录"会改变数据"的操作,重点是删除类(不可逆),也兼容未来扩展 create/update
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
username TEXT, -- 冗余存一份:用户被删/改名后日志还能看懂
|
||||||
|
action TEXT NOT NULL, -- 'delete' | 'batch_delete' | 'create' | 'update' | ...
|
||||||
|
target_type TEXT NOT NULL, -- 'wash_record' | 'chemical' | ...
|
||||||
|
target_ids TEXT NOT NULL, -- JSON 数组(批量时是多个 id)
|
||||||
|
target_summary TEXT, -- 人类可读的摘要,例如 "洗车 2026-01-15 快速 ¥30"
|
||||||
|
detail_json TEXT, -- 任意 JSON,存删除前的快照等
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oplog_created ON operation_logs(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oplog_user_time ON operation_logs(username, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oplog_action ON operation_logs(action, target_type, created_at DESC);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 0011_soft_delete.sql — 统一软删(is_deleted)+ 操作日志完善
|
||||||
|
-- 所有数据表加 is_deleted 标志,DELETE 改为 UPDATE SET is_deleted=1
|
||||||
|
-- 恢复:UPDATE SET is_deleted=0(操作日志已存完整快照)
|
||||||
|
|
||||||
|
ALTER TABLE vehicles ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE wash_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE chemical_usage ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE maintenance_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE refuel_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE charging_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
ALTER TABLE insurance_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||||
|
|
||||||
|
-- 索引加速
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_vehicles_is_deleted ON vehicles(is_deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_wash_records_is_deleted ON wash_records(is_deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_maintenance_is_deleted ON maintenance_records(is_deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_refuel_is_deleted ON refuel_records(is_deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_charging_is_deleted ON charging_records(is_deleted);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_insurance_is_deleted ON insurance_records(is_deleted);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- 0012_operation_logs_recovery.sql — 操作日志恢复支持
|
||||||
|
-- 1. operation_logs 表加 recovered_at 字段(恢复时间戳)
|
||||||
|
-- 2. 各数据表 is_deleted 默认值设为 0(已有列则跳过 ALTER TABLE 报错)
|
||||||
|
|
||||||
|
ALTER TABLE operation_logs ADD COLUMN recovered_at TEXT;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- 0013_weather_wttr.sql — 天气表支持 wttr provider
|
||||||
|
-- 原 CHECK 只允许 qweather/openweathermap,扩展到包含 wttr
|
||||||
|
-- SQLite 无法直接 ALTER CHECK,需重建表
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS _weather_snapshots_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
snapshot_date TEXT NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL CHECK (provider IN ('wttr','qweather','openweathermap')),
|
||||||
|
temp_c REAL,
|
||||||
|
humidity INTEGER,
|
||||||
|
weather_desc TEXT,
|
||||||
|
weather_code TEXT,
|
||||||
|
wind_kph REAL,
|
||||||
|
precip_mm REAL,
|
||||||
|
raw_json TEXT,
|
||||||
|
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
INSERT INTO _weather_snapshots_new
|
||||||
|
(id, snapshot_date, city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, fetched_at)
|
||||||
|
SELECT id, snapshot_date, city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, fetched_at
|
||||||
|
FROM weather_snapshots;
|
||||||
|
DROP TABLE weather_snapshots;
|
||||||
|
ALTER TABLE _weather_snapshots_new RENAME TO weather_snapshots;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uk_weather_city_date ON weather_snapshots(city, snapshot_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weather_date ON weather_snapshots(snapshot_date);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 0014: Grocy 鉴权改造 + 同步日志表 + 天气默认城市
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- 1. settings 表:新增 grocy_username / grocy_password
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||||
|
('grocy_username', '', 1, 'Grocy 用户名(session cookie 鉴权)'),
|
||||||
|
('grocy_password', '', 1, 'Grocy 密码(session cookie 鉴权)');
|
||||||
|
|
||||||
|
-- 2. 新增默认城市设置
|
||||||
|
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||||
|
('app_city_default', '', 0, '天气默认城市(永久生效)');
|
||||||
|
|
||||||
|
-- 3. Grocy 同步日志表
|
||||||
|
CREATE TABLE IF NOT EXISTS grocy_sync_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
action TEXT NOT NULL, -- 'pull_products' | 'sync_usage'
|
||||||
|
status TEXT NOT NULL, -- 'success' | 'failed' | 'partial'
|
||||||
|
ok_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
detail TEXT, -- JSON 详情
|
||||||
|
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grocy_sync_logs_action ON grocy_sync_logs(action);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_grocy_sync_logs_started ON grocy_sync_logs(started_at DESC);
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 0015_wash_photos.sql (MySQL) — 洗车对比照(before / after / detail)
|
||||||
|
CREATE TABLE IF NOT EXISTS wash_photos (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wash_id INT NOT NULL,
|
||||||
|
photo_type VARCHAR(20) NOT NULL DEFAULT 'detail', -- before / after / detail / scene
|
||||||
|
file_path VARCHAR(500) NOT NULL,
|
||||||
|
file_name VARCHAR(255) NOT NULL,
|
||||||
|
mime_type VARCHAR(50) DEFAULT NULL,
|
||||||
|
file_size INT DEFAULT NULL,
|
||||||
|
width INT DEFAULT NULL,
|
||||||
|
height INT DEFAULT NULL,
|
||||||
|
caption VARCHAR(255) DEFAULT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
INDEX idx_wash_photos_wash (wash_id, is_deleted),
|
||||||
|
INDEX idx_wash_photos_type (photo_type),
|
||||||
|
INDEX idx_wash_photos_created (created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
-- 0016_vehicle_current_km.sql — 车辆当前里程(手动校准)
|
||||||
|
-- 真实里程 = MAX(current_km, MAX(odometer_km) FROM 各日志表)
|
||||||
|
-- 用户可以手动覆盖 current_km(比如仪表盘数与日志对不上时)
|
||||||
|
ALTER TABLE vehicles
|
||||||
|
ADD COLUMN current_km INT DEFAULT NULL COMMENT '手动校准的当前里程,NULL 时按各日志表 MAX 算';
|
||||||
|
|
||||||
|
-- 保险提示阈值(可被 settings 覆盖)
|
||||||
|
CREATE TABLE IF NOT EXISTS notification_prefs (
|
||||||
|
key_name VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||||
|
days INT NOT NULL,
|
||||||
|
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO notification_prefs (key_name, days, enabled) VALUES
|
||||||
|
('refuel_remind_days', 30, 1),
|
||||||
|
('maintenance_remind_days', 180, 1),
|
||||||
|
('wash_remind_days', 14, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE updated_at = NOW();
|
||||||
|
|
||||||
|
-- 站内通知表(OCR / 同步 / 备份结果持久化)
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT DEFAULT NULL,
|
||||||
|
type VARCHAR(30) NOT NULL, -- ocr_done / sync_done / backup_done / system
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
body TEXT DEFAULT NULL,
|
||||||
|
link VARCHAR(500) DEFAULT NULL,
|
||||||
|
severity VARCHAR(20) NOT NULL DEFAULT 'info', -- info / warn / error / success
|
||||||
|
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_notif_user_unread (user_id, is_read, created_at DESC),
|
||||||
|
INDEX idx_notif_created (created_at DESC)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- 0017_tags.sql — 标签系统
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL,
|
||||||
|
color VARCHAR(20) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_tag_name (name)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS record_tags (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
record_type VARCHAR(20) NOT NULL, -- wash / refuel / charge / maintenance / insurance
|
||||||
|
record_id INT NOT NULL,
|
||||||
|
tag_id INT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_record_tag (record_type, record_id, tag_id),
|
||||||
|
INDEX idx_record (record_type, record_id),
|
||||||
|
INDEX idx_tag (tag_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
-- 0018_achievements.sql — 成就系统
|
||||||
|
CREATE TABLE IF NOT EXISTS achievements (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
code VARCHAR(50) NOT NULL, -- 成就 code,如 'wash_streak_30'
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(255) DEFAULT NULL,
|
||||||
|
icon VARCHAR(20) DEFAULT NULL, -- emoji
|
||||||
|
threshold INT NOT NULL DEFAULT 1, -- 触发条件
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_ach_code (code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
achievement_id INT NOT NULL,
|
||||||
|
progress INT NOT NULL DEFAULT 0,
|
||||||
|
unlocked_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uniq_user_ach (user_id, achievement_id),
|
||||||
|
INDEX idx_user (user_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT INTO achievements (code, name, description, icon, threshold) VALUES
|
||||||
|
('wash_first', '洗车新手', '完成第一笔洗车记录', '🧽', 1),
|
||||||
|
('wash_10', '勤洗车友', '累计洗车 10 次', '🚿', 10),
|
||||||
|
('wash_50', '洗车达人', '累计洗车 50 次', '🛁', 50),
|
||||||
|
('wash_100', '洗车狂魔', '累计洗车 100 次', '🏆', 100),
|
||||||
|
('wash_streak_7', '一周一洗', '连续 7 天至少洗 1 次', '📅', 7),
|
||||||
|
('wash_streak_30', '月度好习惯', '连续 30 天至少洗 1 次', '🌟', 30),
|
||||||
|
('refuel_10', '小有车生活', '累计加油 10 次', '⛽', 10),
|
||||||
|
('refuel_50', '老司机', '累计加油 50 次', '🚗', 50),
|
||||||
|
('mileage_10000', '万里征程', '累计行驶突破 10000 公里', '🛣️', 10000),
|
||||||
|
('mileage_100000', '十万俱乐部', '累计行驶突破 100000 公里', '🏅', 100000),
|
||||||
|
('maintain_first', '爱车初保养', '完成第一笔保养记录', '🔧', 1),
|
||||||
|
('maintain_5', '按时保养', '累计保养 5 次', '⚙️', 5),
|
||||||
|
('cost_track_30d', '记账坚持者', '连续 30 天有记录', '📊', 30),
|
||||||
|
('insure_first', '保险达人', '记录第一张保单', '🛡️', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), icon = VALUES(icon), threshold = VALUES(threshold);
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 洗车记录系统 - Migration 0001: 基础表 (MySQL 8.x)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. chemicals - 药剂字典(Grocy 缓存层)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS chemicals (
|
||||||
|
grocy_product_id VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
category VARCHAR(255) DEFAULT NULL,
|
||||||
|
unit VARCHAR(50) NOT NULL DEFAULT 'ml',
|
||||||
|
standard_dose DOUBLE DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
fetched_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (grocy_product_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_chemicals_category ON chemicals(category);
|
||||||
|
CREATE INDEX idx_chemicals_active ON chemicals(is_active);
|
||||||
|
CREATE INDEX idx_chemicals_fetched ON chemicals(fetched_at);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. weather_snapshots - 天气快照
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS weather_snapshots (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
snapshot_date VARCHAR(10) NOT NULL,
|
||||||
|
city VARCHAR(100) NOT NULL,
|
||||||
|
provider VARCHAR(50) NOT NULL,
|
||||||
|
temp_c DOUBLE DEFAULT NULL,
|
||||||
|
humidity INT DEFAULT NULL,
|
||||||
|
weather_desc VARCHAR(255) DEFAULT NULL,
|
||||||
|
weather_code VARCHAR(20) DEFAULT NULL,
|
||||||
|
wind_kph DOUBLE DEFAULT NULL,
|
||||||
|
precip_mm DOUBLE DEFAULT NULL,
|
||||||
|
raw_json TEXT DEFAULT NULL,
|
||||||
|
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uk_weather_city_date ON weather_snapshots(city, snapshot_date);
|
||||||
|
CREATE INDEX idx_weather_date ON weather_snapshots(snapshot_date);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. wash_records - 洗车记录
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS wash_records (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
wash_date VARCHAR(10) NOT NULL,
|
||||||
|
wash_type VARCHAR(20) NOT NULL,
|
||||||
|
weather_snapshot_id INT DEFAULT NULL,
|
||||||
|
location VARCHAR(255) DEFAULT NULL,
|
||||||
|
cost DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
duration_min INT DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_wash_type CHECK (wash_type IN ('quick','full','detail','other')),
|
||||||
|
CONSTRAINT fk_wash_weather FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_wash_records_date ON wash_records(wash_date);
|
||||||
|
CREATE INDEX idx_wash_records_type ON wash_records(wash_type);
|
||||||
|
CREATE INDEX idx_wash_records_weather ON wash_records(weather_snapshot_id);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. chemical_usage - 药剂消耗
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS chemical_usage (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
usage_date VARCHAR(10) NOT NULL,
|
||||||
|
chemical_id VARCHAR(255) NOT NULL,
|
||||||
|
amount DOUBLE NOT NULL,
|
||||||
|
wash_record_id INT DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
sync_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||||
|
sync_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_sync_status CHECK (sync_status IN ('pending','synced','failed')),
|
||||||
|
CONSTRAINT fk_usage_chemical FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_usage_wash FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_usage_date ON chemical_usage(usage_date);
|
||||||
|
CREATE INDEX idx_usage_chemical ON chemical_usage(chemical_id);
|
||||||
|
CREATE INDEX idx_usage_wash ON chemical_usage(wash_record_id);
|
||||||
|
CREATE INDEX idx_usage_sync ON chemical_usage(sync_status);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. settings - 运行时配置 KV
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
`key` VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||||
|
value TEXT DEFAULT NULL,
|
||||||
|
is_secret TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
description TEXT DEFAULT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('app_city', NULL, 0, '所在城市(用于天气查询)'),
|
||||||
|
('app_timezone', 'Asia/Shanghai', 0, '时区'),
|
||||||
|
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
|
||||||
|
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
|
||||||
|
('backup_keep_count', '10', 0, '本地备份保留份数'),
|
||||||
|
('backup_dir', 'storage/backups', 0, '备份输出目录');
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 6. views
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DROP VIEW IF EXISTS v_wash_monthly_count;
|
||||||
|
CREATE VIEW v_wash_monthly_count AS
|
||||||
|
SELECT
|
||||||
|
SUBSTRING(wash_date, 1, 7) AS month,
|
||||||
|
COUNT(*) AS wash_count,
|
||||||
|
SUM(COALESCE(cost, 0)) AS total_cost
|
||||||
|
FROM wash_records
|
||||||
|
GROUP BY SUBSTRING(wash_date, 1, 7)
|
||||||
|
ORDER BY month DESC;
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_chemical_monthly_usage;
|
||||||
|
CREATE VIEW v_chemical_monthly_usage AS
|
||||||
|
SELECT
|
||||||
|
SUBSTRING(cu.usage_date, 1, 7) AS month,
|
||||||
|
c.grocy_product_id AS grocy_product_id,
|
||||||
|
c.name AS chemical_name,
|
||||||
|
c.unit AS unit,
|
||||||
|
SUM(cu.amount) AS total_amount,
|
||||||
|
COUNT(*) AS usage_count
|
||||||
|
FROM chemical_usage cu
|
||||||
|
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
|
||||||
|
GROUP BY SUBSTRING(cu.usage_date, 1, 7), c.grocy_product_id
|
||||||
|
ORDER BY month DESC, total_amount DESC;
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_last_wash;
|
||||||
|
CREATE VIEW v_last_wash AS
|
||||||
|
SELECT
|
||||||
|
id AS wash_id,
|
||||||
|
wash_date,
|
||||||
|
wash_type,
|
||||||
|
DATEDIFF(NOW(), STR_TO_DATE(wash_date, '%Y-%m-%d')) AS days_since
|
||||||
|
FROM wash_records
|
||||||
|
ORDER BY wash_date DESC, id DESC
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
-- 0002_auth.sql - 用户认证 + 防撞库 (MySQL)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
username VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
last_login_at DATETIME DEFAULT NULL,
|
||||||
|
last_login_ip VARCHAR(45) DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_role CHECK (role IN ('user','admin'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_active ON users(is_active);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ip_address VARCHAR(45) NOT NULL,
|
||||||
|
username VARCHAR(50) NOT NULL,
|
||||||
|
success TINYINT(1) NOT NULL,
|
||||||
|
user_agent VARCHAR(500) DEFAULT NULL,
|
||||||
|
failure_reason VARCHAR(100) DEFAULT NULL,
|
||||||
|
CONSTRAINT chk_success CHECK (success IN (0, 1))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_attempts_ip_time ON login_attempts(ip_address, attempted_at);
|
||||||
|
CREATE INDEX idx_attempts_user_time ON login_attempts(username, attempted_at);
|
||||||
|
CREATE INDEX idx_attempts_time ON login_attempts(attempted_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS auth_locks (
|
||||||
|
lock_key VARCHAR(100) PRIMARY KEY,
|
||||||
|
lock_type VARCHAR(10) NOT NULL,
|
||||||
|
target VARCHAR(50) NOT NULL,
|
||||||
|
locked_until DATETIME NOT NULL,
|
||||||
|
reason VARCHAR(255) DEFAULT NULL,
|
||||||
|
attempts INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_lock_type CHECK (lock_type IN ('ip','user'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_locks_until ON auth_locks(locked_until);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
|
||||||
|
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
|
||||||
|
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
|
||||||
|
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
|
||||||
|
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
|
||||||
|
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
|
||||||
|
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
|
||||||
|
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
|
||||||
|
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
|
||||||
|
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
|
||||||
|
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- 0003_vehicles.sql - 车辆管理 (MySQL)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
plate VARCHAR(20) DEFAULT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL DEFAULT 'car',
|
||||||
|
color VARCHAR(30) DEFAULT NULL,
|
||||||
|
notes TEXT DEFAULT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_vehicle_type CHECK (type IN ('car','suv','mpv','truck','other'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_vehicles_active ON vehicles(is_active);
|
||||||
|
CREATE INDEX idx_vehicles_sort ON vehicles(sort_order);
|
||||||
|
|
||||||
|
ALTER TABLE wash_records ADD COLUMN vehicle_id INT DEFAULT NULL;
|
||||||
|
CREATE INDEX idx_wash_records_vehicle ON wash_records(vehicle_id);
|
||||||
|
|
||||||
|
DROP VIEW IF EXISTS v_last_wash;
|
||||||
|
CREATE VIEW v_last_wash AS
|
||||||
|
SELECT
|
||||||
|
w.id AS wash_id,
|
||||||
|
w.wash_date,
|
||||||
|
w.wash_type,
|
||||||
|
w.vehicle_id,
|
||||||
|
v.name AS vehicle_name,
|
||||||
|
DATEDIFF(NOW(), STR_TO_DATE(w.wash_date, '%Y-%m-%d')) AS days_since
|
||||||
|
FROM wash_records w
|
||||||
|
LEFT JOIN vehicles v ON v.id = w.vehicle_id
|
||||||
|
ORDER BY w.wash_date DESC, w.id DESC
|
||||||
|
LIMIT 1;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- 0004_grocy_full.sql - Grocy 主数据同步字段 (MySQL)
|
||||||
|
ALTER TABLE chemicals
|
||||||
|
ADD COLUMN description TEXT DEFAULT NULL,
|
||||||
|
ADD COLUMN current_amount DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN current_value DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN min_stock_amount DOUBLE NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN best_before_date VARCHAR(20) DEFAULT NULL,
|
||||||
|
ADD COLUMN location VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN product_group_id INT DEFAULT NULL,
|
||||||
|
ADD COLUMN qu_id INT DEFAULT NULL,
|
||||||
|
ADD COLUMN location_id INT DEFAULT NULL,
|
||||||
|
ADD COLUMN picture_file_name VARCHAR(255) DEFAULT NULL,
|
||||||
|
ADD COLUMN last_synced_at DATETIME DEFAULT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX idx_chem_amount ON chemicals(current_amount);
|
||||||
|
CREATE INDEX idx_chem_pg ON chemicals(product_group_id);
|
||||||
|
CREATE INDEX idx_chem_synced ON chemicals(last_synced_at);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
|
||||||
|
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比)'),
|
||||||
|
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- 0005_inventory_detail.sql (MySQL)
|
||||||
|
ALTER TABLE chemicals
|
||||||
|
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||||
|
ADD COLUMN grocy_last_pulled_at DATETIME DEFAULT NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS category_mappings (
|
||||||
|
grocy_group_id INT PRIMARY KEY,
|
||||||
|
display_name VARCHAR(100) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
chemical_id VARCHAR(255) NOT NULL,
|
||||||
|
change_type VARCHAR(20) NOT NULL,
|
||||||
|
amount_delta DOUBLE NOT NULL,
|
||||||
|
amount_after DOUBLE DEFAULT NULL,
|
||||||
|
source VARCHAR(20) NOT NULL DEFAULT 'local',
|
||||||
|
source_ref VARCHAR(255) DEFAULT NULL,
|
||||||
|
note TEXT DEFAULT NULL,
|
||||||
|
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT chk_change_type CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust'))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE INDEX idx_invlog_chem ON chemical_inventory_log(chemical_id, occurred_at DESC);
|
||||||
|
CREATE INDEX idx_invlog_type ON chemical_inventory_log(change_type);
|
||||||
|
|
||||||
|
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||||
|
('grocy_categories_json', '[]', 0, 'Grocy 分类映射 JSON');
|
||||||