feat: import CarLog v2.8 code + dev plan

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

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

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

后续 Trae 实施, 提交后我 code review + 跑测试。
This commit is contained in:
2026-06-20 22:30:19 +08:00
parent 77adc8e498
commit 65b0bb04f8
174 changed files with 31594 additions and 1 deletions
+19
View File
@@ -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
+18
View File
@@ -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=
+35
View File
@@ -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"]
}
+1 -1
View File
@@ -44,4 +44,4 @@ yarn-error.log*
*.tar.gz
# Mavis
.mavis/
.mavis/.DS_Store
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env sh
# .husky/pre-commit — 提交前自动 lint + format 已暂存文件
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install lint-staged
+14
View File
@@ -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
}
+26
View File
@@ -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
+12
View File
@@ -0,0 +1,12 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 4,
"useTabs": false,
"printWidth": 120,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf",
"vueIndentScriptAndStyle": false
}
+459
View File
@@ -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');
+42
View File
@@ -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>
+6905
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 578 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+23
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+166
View File
@@ -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);
});
+42
View File
@@ -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.');
+22
View File
@@ -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>
+19
View File
@@ -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 });
+8
View File
@@ -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');
+16
View File
@@ -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}`);
+166
View File
@@ -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;
+19
View File
@@ -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`);
+22
View File
@@ -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);
+6
View File
@@ -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`);
+16
View File
@@ -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)}`;
+9
View File
@@ -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`);
+17
View File
@@ -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 } });
+64
View File
@@ -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>
+290
View File
@@ -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>
+59
View File
@@ -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>
+90
View File
@@ -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>
+189
View File
@@ -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>
+240
View File
@@ -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>
+241
View File
@@ -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>
+218
View File
@@ -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>
+29
View File
@@ -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>
+125
View File
@@ -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 };
}
+83
View File
@@ -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');
+69
View File
@@ -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;
+44
View File
@@ -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; },
},
});
+59
View File
@@ -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; },
},
});
+89
View File
@@ -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,
};
});
+156
View File
@@ -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; }
}
+77
View File
@@ -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);
}
+246
View File
@@ -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>
+358
View File
@@ -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>
+432
View File
@@ -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>
+168
View File
@@ -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>
+386
View File
@@ -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 EnteronGrocySearch/
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>
+364
View File
@@ -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>
+452
View File
@@ -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>
+199
View File
@@ -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>
+369
View File
@@ -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>
+62
View File
@@ -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>
+275
View File
@@ -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>
+374
View File
@@ -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>
+663
View File
@@ -0,0 +1,663 @@
<template>
<AppLayout>
<h1 class="title">设置</h1>
<p class="text-soft" style="margin: 4px 0 24px; font-size:14px">配置账户天气GrocyCSV 导出</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>
+414
View File
@@ -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>
+379
View File
@@ -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>
+136
View File
@@ -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>
+167
View File
@@ -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>
+326
View File
@@ -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>
+352
View File
@@ -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>
+351
View File
@@ -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>
+158
View File
@@ -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,
},
});
+1134
View File
File diff suppressed because it is too large Load Diff
+180
View File
@@ -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
```
### 方式 Bgit 拉取
```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 端口到公网。
+24
View File
@@ -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 }]
}
}
}
}
+58
View File
@@ -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"
}
}
+155
View File
@@ -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;
+68
View File
@@ -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');
+45
View File
@@ -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;
+27
View File
@@ -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_unit1=无换算)
-- 例如:阿达姆斯加仑装 stock_unit=毫升,qu_factor=37851 加仑 = 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;
+75
View File
@@ -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'));
+18
View File
@@ -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);
+19
View File
@@ -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;
+26
View File
@@ -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);
+26
View File
@@ -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);
+19
View File
@@ -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;
+19
View File
@@ -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;
+39
View File
@@ -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);
+148
View File
@@ -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;
+57
View File
@@ -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');
+35
View File
@@ -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');
@@ -0,0 +1,10 @@
-- 0006_unit_conversion.sql (MySQL)
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;
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;
@@ -0,0 +1,72 @@
-- 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;
CREATE INDEX idx_maint_vehicle_date ON maintenance_records(vehicle_id, maint_date DESC);
CREATE INDEX idx_maint_date ON maintenance_records(maint_date DESC);
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;
CREATE INDEX idx_refuel_vehicle_date ON refuel_records(vehicle_id, refuel_date DESC);
CREATE INDEX idx_refuel_date ON refuel_records(refuel_date DESC);
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;
CREATE INDEX idx_charging_vehicle_date ON charging_records(vehicle_id, charge_date DESC);
CREATE INDEX idx_charging_date ON charging_records(charge_date DESC);
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;

Some files were not shown because too many files have changed in this diff Show More