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 + 跑测试。
@@ -0,0 +1,19 @@
|
||||
# .editorconfig — 统一编辑器配置
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.vue]
|
||||
indent_size = 4
|
||||
@@ -0,0 +1,18 @@
|
||||
# 复制为 .env 后填入实际值(所有项可选;只配需要的功能即可)
|
||||
|
||||
# 服务监听
|
||||
PORT=8787
|
||||
HOST=127.0.0.1
|
||||
NODE_ENV=production
|
||||
|
||||
# 城市(默认)
|
||||
APP_CITY=Beijing
|
||||
|
||||
# 天气 API(任选其一)
|
||||
QWECHAT_API_KEY=
|
||||
QWECHAT_HOST=devapi.qweather.com
|
||||
OPENWEATHERMAP_API_KEY=
|
||||
|
||||
# Grocy 集成
|
||||
GROCY_URL=
|
||||
GROCY_API_TOKEN=
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2022": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "espree",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": { "jsx": false }
|
||||
},
|
||||
"extends": ["eslint:recommended"],
|
||||
"rules": {
|
||||
"no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"no-undef": "error",
|
||||
"no-console": "off",
|
||||
"no-var": "error",
|
||||
"prefer-const": "warn",
|
||||
"eqeqeq": ["error", "always", { "null": "ignore" }],
|
||||
"no-implicit-coercion": "warn",
|
||||
"no-throw-literal": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["client/src/**/*.vue"],
|
||||
"parser": "vue-eslint-parser",
|
||||
"parserOptions": {
|
||||
"parser": "espree"
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": ["node_modules/", "client/dist/", "server/storage/", "uploads/", "*.min.js"]
|
||||
}
|
||||
@@ -44,4 +44,4 @@ yarn-error.log*
|
||||
*.tar.gz
|
||||
|
||||
# Mavis
|
||||
.mavis/
|
||||
.mavis/.DS_Store
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env sh
|
||||
# .husky/pre-commit — 提交前自动 lint + format 已暂存文件
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no-install lint-staged
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"chromeLaunchConfig": {
|
||||
"args": ["--no-sandbox", "--disable-setuid-sandbox", "--headless=new"]
|
||||
},
|
||||
"standard": "WCAG2AA",
|
||||
"runners": ["htmlcs"],
|
||||
"ignore": [
|
||||
"WCAG2AA.Principle1.Guideline1_3.1_3_1.H49.AlignAttr",
|
||||
"WCAG2AA.Principle1.Guideline1_4.1_4_3.Contrast",
|
||||
"color-contrast"
|
||||
],
|
||||
"rules": [],
|
||||
"timeout": 60000
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# 依赖
|
||||
node_modules/
|
||||
client/node_modules/
|
||||
server/node_modules/
|
||||
|
||||
# 构建产物
|
||||
client/dist/
|
||||
server/storage/
|
||||
uploads/
|
||||
|
||||
# 数据
|
||||
server/data/*.sqlite
|
||||
server/data/*.sqlite-shm
|
||||
server/data/*.sqlite-wal
|
||||
server/data/*.db
|
||||
|
||||
# 日志 / 临时
|
||||
*.log
|
||||
.DS_Store
|
||||
.setup_done
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# 备份
|
||||
*.zip
|
||||
*.tar.gz
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 120,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"vueIndentScriptAndStyle": false
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
-- ============================================================
|
||||
-- 洗车管理系统 MySQL 一键初始化(v2.1)
|
||||
-- 适用 MySQL 8.0+ / utf8mb4
|
||||
--
|
||||
-- 用法:
|
||||
-- 1. 宝塔:选 carlog 库 →『导入』→ 选本文件
|
||||
-- 2. 命令行:mysql -uroot -p carlog < carlog-init.sql
|
||||
--
|
||||
-- 完全幂等,反复重跑不会破坏数据
|
||||
-- ============================================================
|
||||
|
||||
DROP PROCEDURE IF EXISTS _add_index_if_missing;
|
||||
CREATE PROCEDURE _add_index_if_missing(IN p_table VARCHAR(64), IN p_index VARCHAR(64), IN p_def TEXT, IN p_unique INT) BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = p_table AND index_name = p_index) THEN
|
||||
IF p_unique = 1 THEN SET @s = CONCAT('CREATE UNIQUE INDEX ', p_index, ' ON ', p_table, ' ', p_def);
|
||||
ELSE SET @s = CONCAT('CREATE INDEX ', p_index, ' ON ', p_table, ' ', p_def); END IF;
|
||||
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
END IF;
|
||||
END;
|
||||
|
||||
DROP PROCEDURE IF EXISTS _try_sql;
|
||||
CREATE PROCEDURE _try_sql(IN p_sql TEXT) BEGIN
|
||||
DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN END;
|
||||
SET @s = p_sql;
|
||||
PREPARE st FROM @s; EXECUTE st; DEALLOCATE PREPARE st;
|
||||
END;
|
||||
|
||||
|
||||
-- >>> 0001_init.sql
|
||||
-- ==========================================================
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0001: 基础表 (MySQL 8.x)
|
||||
-- =============================================================================
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. chemicals - 药剂字典(Grocy 缓存层)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chemicals (
|
||||
grocy_product_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(255) DEFAULT NULL,
|
||||
unit VARCHAR(50) NOT NULL DEFAULT 'ml',
|
||||
standard_dose DOUBLE DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
fetched_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (grocy_product_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('chemicals', 'idx_chemicals_category', '(category)', 0);
|
||||
CALL _add_index_if_missing('chemicals', 'idx_chemicals_active', '(is_active)', 0);
|
||||
CALL _add_index_if_missing('chemicals', 'idx_chemicals_fetched', '(fetched_at)', 0);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. weather_snapshots - 天气快照
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS weather_snapshots (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
snapshot_date VARCHAR(10) NOT NULL,
|
||||
city VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
temp_c DOUBLE DEFAULT NULL,
|
||||
humidity INT DEFAULT NULL,
|
||||
weather_desc VARCHAR(255) DEFAULT NULL,
|
||||
weather_code VARCHAR(20) DEFAULT NULL,
|
||||
wind_kph DOUBLE DEFAULT NULL,
|
||||
precip_mm DOUBLE DEFAULT NULL,
|
||||
raw_json TEXT DEFAULT NULL,
|
||||
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('weather_snapshots', 'uk_weather_city_date', '(city, snapshot_date)', 1);
|
||||
CALL _add_index_if_missing('weather_snapshots', 'idx_weather_date', '(snapshot_date)', 0);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. wash_records - 洗车记录
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wash_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
wash_date VARCHAR(10) NOT NULL,
|
||||
wash_type VARCHAR(20) NOT NULL,
|
||||
weather_snapshot_id INT DEFAULT NULL,
|
||||
location VARCHAR(255) DEFAULT NULL,
|
||||
cost DOUBLE NOT NULL DEFAULT 0,
|
||||
duration_min INT DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_wash_type CHECK (wash_type IN ('quick','full','detail','other')),
|
||||
CONSTRAINT fk_wash_weather FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('wash_records', 'idx_wash_records_date', '(wash_date)', 0);
|
||||
CALL _add_index_if_missing('wash_records', 'idx_wash_records_type', '(wash_type)', 0);
|
||||
CALL _add_index_if_missing('wash_records', 'idx_wash_records_weather', '(weather_snapshot_id)', 0);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. chemical_usage - 药剂消耗
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chemical_usage (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
usage_date VARCHAR(10) NOT NULL,
|
||||
chemical_id VARCHAR(255) NOT NULL,
|
||||
amount DOUBLE NOT NULL,
|
||||
wash_record_id INT DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
sync_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
sync_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_sync_status CHECK (sync_status IN ('pending','synced','failed')),
|
||||
CONSTRAINT fk_usage_chemical FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_usage_wash FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('chemical_usage', 'idx_usage_date', '(usage_date)', 0);
|
||||
CALL _add_index_if_missing('chemical_usage', 'idx_usage_chemical', '(chemical_id)', 0);
|
||||
CALL _add_index_if_missing('chemical_usage', 'idx_usage_wash', '(wash_record_id)', 0);
|
||||
CALL _add_index_if_missing('chemical_usage', 'idx_usage_sync', '(sync_status)', 0);
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. settings - 运行时配置 KV
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
`key` VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||
value TEXT DEFAULT NULL,
|
||||
is_secret TINYINT(1) NOT NULL DEFAULT 0,
|
||||
description TEXT DEFAULT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('app_city', NULL, 0, '所在城市(用于天气查询)'),
|
||||
('app_timezone', 'Asia/Shanghai', 0, '时区'),
|
||||
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
|
||||
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
|
||||
('backup_keep_count', '10', 0, '本地备份保留份数'),
|
||||
('backup_dir', 'storage/backups', 0, '备份输出目录');
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. views
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP VIEW IF EXISTS v_wash_monthly_count;
|
||||
CALL _try_sql('CREATE VIEW v_wash_monthly_count AS SELECT SUBSTRING(wash_date, 1, 7) AS month, COUNT(*) AS wash_count, SUM(COALESCE(cost, 0)) AS total_cost FROM wash_records GROUP BY SUBSTRING(wash_date, 1, 7) ORDER BY month DESC');
|
||||
DROP VIEW IF EXISTS v_chemical_monthly_usage;
|
||||
CALL _try_sql('CREATE VIEW v_chemical_monthly_usage AS SELECT SUBSTRING(cu.usage_date, 1, 7) AS month, c.grocy_product_id AS grocy_product_id, c.name AS chemical_name, c.unit AS unit, SUM(cu.amount) AS total_amount, COUNT(*) AS usage_count FROM chemical_usage cu JOIN chemicals c ON c.grocy_product_id = cu.chemical_id GROUP BY SUBSTRING(cu.usage_date, 1, 7), c.grocy_product_id ORDER BY month DESC, total_amount DESC');
|
||||
DROP VIEW IF EXISTS v_last_wash;
|
||||
CALL _try_sql('CREATE VIEW v_last_wash AS SELECT id AS wash_id, wash_date, wash_type, DATEDIFF(NOW(), STR_TO_DATE(wash_date, ''%Y-%m-%d'')) AS days_since FROM wash_records ORDER BY wash_date DESC, id DESC LIMIT 1');
|
||||
|
||||
-- >>> 0002_auth.sql
|
||||
-- ==========================================================
|
||||
-- 0002_auth.sql - 用户认证 + 防撞库 (MySQL)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME DEFAULT NULL,
|
||||
last_login_ip VARCHAR(45) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_role CHECK (role IN ('user','admin'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('users', 'idx_users_active', '(is_active)', 0);
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
success TINYINT(1) NOT NULL,
|
||||
user_agent VARCHAR(500) DEFAULT NULL,
|
||||
failure_reason VARCHAR(100) DEFAULT NULL,
|
||||
CONSTRAINT chk_success CHECK (success IN (0, 1))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('login_attempts', 'idx_attempts_ip_time', '(ip_address, attempted_at)', 0);
|
||||
CALL _add_index_if_missing('login_attempts', 'idx_attempts_user_time', '(username, attempted_at)', 0);
|
||||
CALL _add_index_if_missing('login_attempts', 'idx_attempts_time', '(attempted_at)', 0);
|
||||
CREATE TABLE IF NOT EXISTS auth_locks (
|
||||
lock_key VARCHAR(100) PRIMARY KEY,
|
||||
lock_type VARCHAR(10) NOT NULL,
|
||||
target VARCHAR(50) NOT NULL,
|
||||
locked_until DATETIME NOT NULL,
|
||||
reason VARCHAR(255) DEFAULT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_lock_type CHECK (lock_type IN ('ip','user'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('auth_locks', 'idx_locks_until', '(locked_until)', 0);
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
|
||||
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
|
||||
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
|
||||
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
|
||||
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
|
||||
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
|
||||
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
|
||||
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
|
||||
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
|
||||
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
|
||||
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
|
||||
|
||||
-- >>> 0003_vehicles.sql
|
||||
-- ==========================================================
|
||||
-- 0003_vehicles.sql - 车辆管理 (MySQL)
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plate VARCHAR(20) DEFAULT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'car',
|
||||
color VARCHAR(30) DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_vehicle_type CHECK (type IN ('car','suv','mpv','truck','other'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('vehicles', 'idx_vehicles_active', '(is_active)', 0);
|
||||
CALL _add_index_if_missing('vehicles', 'idx_vehicles_sort', '(sort_order)', 0);
|
||||
CALL _try_sql('ALTER TABLE wash_records ADD COLUMN vehicle_id INT DEFAULT NULL');
|
||||
CALL _add_index_if_missing('wash_records', 'idx_wash_records_vehicle', '(vehicle_id)', 0);
|
||||
DROP VIEW IF EXISTS v_last_wash;
|
||||
CALL _try_sql('CREATE VIEW v_last_wash AS SELECT w.id AS wash_id, w.wash_date, w.wash_type, w.vehicle_id, v.name AS vehicle_name, DATEDIFF(NOW(), STR_TO_DATE(w.wash_date, ''%Y-%m-%d'')) AS days_since FROM wash_records w LEFT JOIN vehicles v ON v.id = w.vehicle_id ORDER BY w.wash_date DESC, w.id DESC LIMIT 1');
|
||||
|
||||
-- >>> 0004_grocy_full.sql
|
||||
-- ==========================================================
|
||||
-- 0004_grocy_full.sql - Grocy 主数据同步字段 (MySQL)
|
||||
CALL _try_sql('ALTER TABLE chemicals ADD COLUMN description TEXT DEFAULT NULL, ADD COLUMN current_amount DOUBLE NOT NULL DEFAULT 0, ADD COLUMN current_value DOUBLE NOT NULL DEFAULT 0, ADD COLUMN min_stock_amount DOUBLE NOT NULL DEFAULT 0, ADD COLUMN best_before_date VARCHAR(20) DEFAULT NULL, ADD COLUMN location VARCHAR(255) DEFAULT NULL, ADD COLUMN product_group_id INT DEFAULT NULL, ADD COLUMN qu_id INT DEFAULT NULL, ADD COLUMN location_id INT DEFAULT NULL, ADD COLUMN picture_file_name VARCHAR(255) DEFAULT NULL, ADD COLUMN last_synced_at DATETIME DEFAULT NULL');
|
||||
CALL _add_index_if_missing('chemicals', 'idx_chem_amount', '(current_amount)', 0);
|
||||
CALL _add_index_if_missing('chemicals', 'idx_chem_pg', '(product_group_id)', 0);
|
||||
CALL _add_index_if_missing('chemicals', 'idx_chem_synced', '(last_synced_at)', 0);
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
|
||||
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比)'),
|
||||
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
|
||||
|
||||
-- >>> 0005_inventory_detail.sql
|
||||
-- ==========================================================
|
||||
-- 0005_inventory_detail.sql (MySQL)
|
||||
CALL _try_sql('ALTER TABLE chemicals ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT ''manual'', ADD COLUMN grocy_last_pulled_at DATETIME DEFAULT NULL');
|
||||
CREATE TABLE IF NOT EXISTS category_mappings (
|
||||
grocy_group_id INT PRIMARY KEY,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
chemical_id VARCHAR(255) NOT NULL,
|
||||
change_type VARCHAR(20) NOT NULL,
|
||||
amount_delta DOUBLE NOT NULL,
|
||||
amount_after DOUBLE DEFAULT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'local',
|
||||
source_ref VARCHAR(255) DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_change_type CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('chemical_inventory_log', 'idx_invlog_chem', '(chemical_id, occurred_at DESC)', 0);
|
||||
CALL _add_index_if_missing('chemical_inventory_log', 'idx_invlog_type', '(change_type)', 0);
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('grocy_categories_json', '[]', 0, 'Grocy 分类映射 JSON');
|
||||
|
||||
-- >>> 0006_unit_conversion.sql
|
||||
-- ==========================================================
|
||||
-- 0006_unit_conversion.sql (MySQL)
|
||||
CALL _try_sql('ALTER TABLE chemicals ADD COLUMN qu_factor DOUBLE NOT NULL DEFAULT 1.0, ADD COLUMN consume_unit_id INT DEFAULT NULL, ADD COLUMN consume_unit_name VARCHAR(100) DEFAULT NULL');
|
||||
CALL _try_sql('ALTER TABLE chemical_usage ADD COLUMN unit VARCHAR(50) DEFAULT NULL, ADD COLUMN stock_amount DOUBLE DEFAULT NULL, ADD COLUMN consume_unit_id INT DEFAULT NULL');
|
||||
|
||||
-- >>> 0007_vehicle_logs.sql
|
||||
-- ==========================================================
|
||||
-- 0007_vehicle_logs.sql (MySQL)
|
||||
CREATE TABLE IF NOT EXISTS maintenance_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
vehicle_id INT NOT NULL,
|
||||
maint_date VARCHAR(10) NOT NULL,
|
||||
odometer_km INT DEFAULT NULL,
|
||||
total_cost DOUBLE NOT NULL DEFAULT 0,
|
||||
shop VARCHAR(255) DEFAULT NULL,
|
||||
items_json JSON NOT NULL DEFAULT ('[]'),
|
||||
next_due_date VARCHAR(10) DEFAULT NULL,
|
||||
next_due_km INT DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('maintenance_records', 'idx_maint_vehicle_date', '(vehicle_id, maint_date DESC)', 0);
|
||||
CALL _add_index_if_missing('maintenance_records', 'idx_maint_date', '(maint_date DESC)', 0);
|
||||
CREATE TABLE IF NOT EXISTS refuel_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
vehicle_id INT NOT NULL,
|
||||
refuel_date VARCHAR(10) NOT NULL,
|
||||
odometer_km INT DEFAULT NULL,
|
||||
liters DOUBLE NOT NULL,
|
||||
price_per_liter DOUBLE DEFAULT NULL,
|
||||
total_cost DOUBLE NOT NULL,
|
||||
fuel_type VARCHAR(20) DEFAULT NULL,
|
||||
is_full TINYINT(1) NOT NULL DEFAULT 0,
|
||||
station VARCHAR(255) DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('refuel_records', 'idx_refuel_vehicle_date', '(vehicle_id, refuel_date DESC)', 0);
|
||||
CALL _add_index_if_missing('refuel_records', 'idx_refuel_date', '(refuel_date DESC)', 0);
|
||||
CREATE TABLE IF NOT EXISTS charging_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
vehicle_id INT NOT NULL,
|
||||
charge_date VARCHAR(10) NOT NULL,
|
||||
odometer_km INT DEFAULT NULL,
|
||||
kwh DOUBLE NOT NULL,
|
||||
price_per_kwh DOUBLE DEFAULT NULL,
|
||||
total_cost DOUBLE NOT NULL,
|
||||
charge_type VARCHAR(20) DEFAULT NULL,
|
||||
start_soc INT DEFAULT NULL,
|
||||
end_soc INT DEFAULT NULL,
|
||||
station VARCHAR(255) DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('charging_records', 'idx_charging_vehicle_date', '(vehicle_id, charge_date DESC)', 0);
|
||||
CALL _add_index_if_missing('charging_records', 'idx_charging_date', '(charge_date DESC)', 0);
|
||||
DROP VIEW IF EXISTS v_recent_logs;
|
||||
CALL _try_sql('CREATE VIEW v_recent_logs AS SELECT ''maintenance'' AS log_type, id, vehicle_id, maint_date AS log_date, total_cost, odometer_km, shop AS location FROM maintenance_records UNION ALL SELECT ''refuel'' AS log_type, id, vehicle_id, refuel_date AS log_date, total_cost, odometer_km, station AS location FROM refuel_records UNION ALL SELECT ''charging'' AS log_type, id, vehicle_id, charge_date AS log_date, total_cost, odometer_km, station AS location FROM charging_records');
|
||||
|
||||
-- >>> 0008_mileage_and_insurance.sql
|
||||
-- ==========================================================
|
||||
-- 0008_mileage_and_insurance.sql (MySQL)
|
||||
CALL _try_sql('ALTER TABLE maintenance_records ADD COLUMN ev_km INT DEFAULT NULL, ADD COLUMN hev_km INT DEFAULT NULL');
|
||||
CREATE TABLE IF NOT EXISTS insurance_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
vehicle_id INT NOT NULL,
|
||||
insurance_type VARCHAR(50) NOT NULL,
|
||||
company VARCHAR(100) DEFAULT NULL,
|
||||
policy_no VARCHAR(100) DEFAULT NULL,
|
||||
start_date VARCHAR(10) NOT NULL,
|
||||
end_date VARCHAR(10) NOT NULL,
|
||||
premium DOUBLE DEFAULT NULL,
|
||||
coverage_amount DOUBLE DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
attachment_path VARCHAR(500) DEFAULT NULL,
|
||||
attachment_name VARCHAR(255) DEFAULT NULL,
|
||||
attachment_mime VARCHAR(100) DEFAULT NULL,
|
||||
attachment_size INT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('insurance_records', 'idx_insurance_vehicle', '(vehicle_id)', 0);
|
||||
CALL _add_index_if_missing('insurance_records', 'idx_insurance_end_date', '(end_date)', 0);
|
||||
|
||||
-- >>> 0009_vehicle_powertrain.sql
|
||||
-- ==========================================================
|
||||
-- 0009_vehicle_powertrain.sql (MySQL)
|
||||
CALL _try_sql('ALTER TABLE vehicles ADD COLUMN powertrain VARCHAR(10) NOT NULL DEFAULT ''ice''');
|
||||
|
||||
-- >>> 0010_operation_logs.sql
|
||||
-- ==========================================================
|
||||
-- 0010_operation_logs.sql (MySQL)
|
||||
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT DEFAULT NULL,
|
||||
username VARCHAR(50) DEFAULT NULL,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
target_type VARCHAR(50) NOT NULL,
|
||||
target_ids TEXT NOT NULL,
|
||||
target_summary TEXT DEFAULT NULL,
|
||||
detail_json TEXT DEFAULT NULL,
|
||||
ip VARCHAR(45) DEFAULT NULL,
|
||||
user_agent VARCHAR(500) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('operation_logs', 'idx_oplog_created', '(created_at DESC)', 0);
|
||||
CALL _add_index_if_missing('operation_logs', 'idx_oplog_user_time', '(username, created_at DESC)', 0);
|
||||
CALL _add_index_if_missing('operation_logs', 'idx_oplog_action', '(action, target_type, created_at DESC)', 0);
|
||||
|
||||
-- >>> 0011_soft_delete.sql
|
||||
-- ==========================================================
|
||||
-- 0011_soft_delete.sql (MySQL)
|
||||
CALL _try_sql('ALTER TABLE vehicles ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _try_sql('ALTER TABLE wash_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _try_sql('ALTER TABLE chemical_usage ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _try_sql('ALTER TABLE maintenance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _try_sql('ALTER TABLE refuel_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _try_sql('ALTER TABLE charging_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _try_sql('ALTER TABLE insurance_records ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0');
|
||||
CALL _add_index_if_missing('vehicles', 'ix_vehicles_is_deleted', '(is_deleted)', 0);
|
||||
CALL _add_index_if_missing('wash_records', 'ix_wash_records_is_deleted', '(is_deleted)', 0);
|
||||
CALL _add_index_if_missing('maintenance_records', 'ix_maintenance_is_deleted', '(is_deleted)', 0);
|
||||
CALL _add_index_if_missing('refuel_records', 'ix_refuel_is_deleted', '(is_deleted)', 0);
|
||||
CALL _add_index_if_missing('charging_records', 'ix_charging_is_deleted', '(is_deleted)', 0);
|
||||
CALL _add_index_if_missing('insurance_records', 'ix_insurance_is_deleted', '(is_deleted)', 0);
|
||||
|
||||
-- >>> 0012_operation_logs_recovery.sql
|
||||
-- ==========================================================
|
||||
-- 0012_operation_logs_recovery.sql (MySQL)
|
||||
CALL _try_sql('ALTER TABLE operation_logs ADD COLUMN recovered_at DATETIME DEFAULT NULL');
|
||||
|
||||
-- >>> 0013_weather_wttr.sql
|
||||
-- ==========================================================
|
||||
-- 0013_weather_wttr.sql (MySQL)
|
||||
-- 删除旧 CHECK 并重建(MySQL 允许 ALTER TABLE 改 CHECK,但为保险用 ALTER COLUMN)
|
||||
CALL _try_sql('ALTER TABLE weather_snapshots MODIFY COLUMN provider VARCHAR(50) NOT NULL');
|
||||
|
||||
-- >>> 0014_grocy_auth.sql
|
||||
-- ==========================================================
|
||||
-- 0014_grocy_auth.sql (MySQL)
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('grocy_username', '', 1, 'Grocy 用户名(session cookie 鉴权)'),
|
||||
('grocy_password', '', 1, 'Grocy 密码(session cookie 鉴权)'),
|
||||
('app_city_default', '', 0, '天气默认城市(永久生效)');
|
||||
CREATE TABLE IF NOT EXISTS grocy_sync_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
action VARCHAR(50) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
ok_count INT NOT NULL DEFAULT 0,
|
||||
fail_count INT NOT NULL DEFAULT 0,
|
||||
detail TEXT DEFAULT NULL,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
finished_at DATETIME DEFAULT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
CALL _add_index_if_missing('grocy_sync_logs', 'idx_grocy_sync_logs_action', '(action)', 0);
|
||||
CALL _add_index_if_missing('grocy_sync_logs', 'idx_grocy_sync_logs_started', '(started_at DESC)', 0);
|
||||
|
||||
-- >>> 0015_wash_photos.sql
|
||||
-- ==========================================================
|
||||
-- 0015_wash_photos.sql (MySQL) — 洗车对比照(before / after / detail)
|
||||
CREATE TABLE IF NOT EXISTS wash_photos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
wash_id INT NOT NULL,
|
||||
photo_type VARCHAR(20) NOT NULL DEFAULT 'detail', -- before / after / detail / scene
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(50) DEFAULT NULL,
|
||||
file_size INT DEFAULT NULL,
|
||||
width INT DEFAULT NULL,
|
||||
height INT DEFAULT NULL,
|
||||
caption VARCHAR(255) DEFAULT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
INDEX idx_wash_photos_wash (wash_id, is_deleted),
|
||||
INDEX idx_wash_photos_type (photo_type),
|
||||
INDEX idx_wash_photos_created (created_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
DROP PROCEDURE IF EXISTS _add_index_if_missing;
|
||||
DROP PROCEDURE IF EXISTS _try_sql;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (filename VARCHAR(255) PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0001_init.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0002_auth.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0003_vehicles.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0004_grocy_full.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0005_inventory_detail.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0006_unit_conversion.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0007_vehicle_logs.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0008_mileage_and_insurance.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0009_vehicle_powertrain.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0010_operation_logs.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0011_soft_delete.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0012_operation_logs_recovery.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0013_weather_wttr.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0014_grocy_auth.sql');
|
||||
INSERT IGNORE INTO schema_migrations (filename) VALUES ('0015_wash_photos.sql');
|
||||
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-Hans-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, user-scalable=no">
|
||||
<title>CarLog 车记 · 个人爱车管理</title>
|
||||
<meta name="description" content="Vue 3 + Node.js 个人爱车记录系统">
|
||||
<meta name="theme-color" content="#1B6EF3" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0B4FB8" media="(prefers-color-scheme: dark)">
|
||||
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/svg+xml" href="/pwa-icon.svg">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/pwa/apple-touch-icon.png">
|
||||
|
||||
<!-- iOS PWA -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<meta name="apple-mobile-web-app-title" content="CarLog">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-TileColor" content="#1B6EF3">
|
||||
<meta name="msapplication-config" content="none">
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet" media="print" onload="this.media='all'">
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet"></noscript>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript>
|
||||
<div style="padding:40px;text-align:center;font-family:system-ui">
|
||||
<h1>CarLog 需要启用 JavaScript</h1>
|
||||
<p>请在浏览器中开启 JavaScript 后刷新页面。</p>
|
||||
</div>
|
||||
</noscript>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "carwash-client",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"chart.js": "^4.4.4",
|
||||
"pinia": "^2.2.4",
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"sharp": "^0.35.1",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 578 B |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,23 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#1B6EF3"/>
|
||||
<stop offset="100%" stop-color="#0B4FB8"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="96" fill="url(#g)"/>
|
||||
<!-- car silhouette -->
|
||||
<g fill="#ffffff" transform="translate(96 200)">
|
||||
<path d="M0 90 C0 60 30 40 70 40 L130 30 C170 0 230 0 270 30 L330 40 C370 40 400 60 400 90 L400 140 L0 140 Z"/>
|
||||
<circle cx="100" cy="150" r="34" fill="#1B6EF3" stroke="#fff" stroke-width="10"/>
|
||||
<circle cx="300" cy="150" r="34" fill="#1B6EF3" stroke="#fff" stroke-width="10"/>
|
||||
</g>
|
||||
<!-- water drops -->
|
||||
<g fill="#B8E0FF" opacity="0.85">
|
||||
<circle cx="160" cy="100" r="10"/>
|
||||
<circle cx="200" cy="80" r="6"/>
|
||||
<circle cx="320" cy="100" r="8"/>
|
||||
<circle cx="360" cy="80" r="5"/>
|
||||
</g>
|
||||
<text x="256" y="430" font-family="-apple-system,Segoe UI,Roboto,sans-serif" font-size="64" font-weight="800" text-anchor="middle" fill="#ffffff">CL</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* PWA 安装性验证脚本
|
||||
* 检查:
|
||||
* 1. manifest.webmanifest 合法
|
||||
* 2. Service Worker 注册成功
|
||||
* 3. 图标全部能加载
|
||||
* 4. PWA 必需字段(name, icons[192/512], start_url, display, theme_color, background_color)
|
||||
* 5. 离线 fallback(/offline 或 navigateFallback 命中)
|
||||
*/
|
||||
import puppeteer from 'puppeteer';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
const URL_TO_TEST = process.env.PWA_URL || 'http://localhost:4173/login';
|
||||
const CHROME_PATH =
|
||||
process.env.CHROME_PATH ||
|
||||
'/Users/yabozi/.cache/puppeteer/chrome/mac-148.0.7778.97/chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing';
|
||||
|
||||
const checks = [];
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function ok(name, detail) {
|
||||
checks.push({ status: '✅', name, detail });
|
||||
pass++;
|
||||
}
|
||||
function ko(name, detail) {
|
||||
checks.push({ status: '❌', name, detail });
|
||||
fail++;
|
||||
}
|
||||
|
||||
async function fetchStatus(url) {
|
||||
try {
|
||||
const r = await fetch(url, { redirect: 'follow' });
|
||||
return r.status;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`🔍 PWA check: ${URL_TO_TEST}\n`);
|
||||
|
||||
// 1. manifest 文件可访问 + 合法 JSON
|
||||
const manifestUrl = new URL('/manifest.webmanifest', URL_TO_TEST).toString();
|
||||
const manifestStatus = await fetchStatus(manifestUrl);
|
||||
if (manifestStatus === 200) {
|
||||
ok('manifest 200', manifestUrl);
|
||||
const r = await fetch(manifestUrl);
|
||||
const m = await r.json();
|
||||
const required = ['name', 'short_name', 'start_url', 'display', 'icons'];
|
||||
const missing = required.filter((k) => !m[k]);
|
||||
if (missing.length === 0) {
|
||||
ok('manifest 必备字段', Object.keys(m).join(', '));
|
||||
} else {
|
||||
ko('manifest 必备字段缺失', missing.join(', '));
|
||||
}
|
||||
// 图标尺寸
|
||||
const sizes = (m.icons || []).map((i) => i.sizes).filter(Boolean);
|
||||
const has192 = sizes.some((s) => s.includes('192'));
|
||||
const has512 = sizes.some((s) => s.includes('512'));
|
||||
const hasMaskable = (m.icons || []).some((i) => i.purpose === 'maskable');
|
||||
const hasApple = (m.icons || []).some((i) =>
|
||||
(i.src || '').includes('apple-touch')
|
||||
);
|
||||
if (has192 && has512) ok('icons 192+512', sizes.join(', '));
|
||||
else ko('icons 192/512 缺失', sizes.join(', '));
|
||||
if (hasMaskable) ok('maskable icon', 'present');
|
||||
else ko('maskable icon', 'missing');
|
||||
if (hasApple) ok('apple-touch icon', 'present');
|
||||
else ko('apple-touch icon', 'missing');
|
||||
} else {
|
||||
ko('manifest 不可访问', `status=${manifestStatus}, url=${manifestUrl}`);
|
||||
}
|
||||
|
||||
// 2. Service Worker 注册
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME_PATH,
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
`--user-data-dir=/tmp/lh-cache-pwa-check-${Date.now()}`,
|
||||
],
|
||||
});
|
||||
const page = await browser.newPage();
|
||||
const swLogs = [];
|
||||
page.on('console', (m) => {
|
||||
const t = m.text();
|
||||
if (t.includes('[PWA]') || t.includes('ServiceWorker')) swLogs.push(t);
|
||||
});
|
||||
|
||||
await page.goto(URL_TO_TEST, { waitUntil: 'networkidle0', timeout: 30000 });
|
||||
// 等几秒给 SW 机会注册
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
const swInfo = await page.evaluate(async () => {
|
||||
if (!('serviceWorker' in navigator)) return { supported: false };
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
return {
|
||||
supported: true,
|
||||
count: regs.length,
|
||||
scopes: regs.map((r) => r.scope),
|
||||
active: regs.map((r) => !!r.active),
|
||||
scripts: regs.map((r) => r.active && r.active.scriptURL),
|
||||
};
|
||||
});
|
||||
if (swInfo.supported && swInfo.count > 0 && swInfo.active.every(Boolean)) {
|
||||
ok('Service Worker 已注册', `scope=${swInfo.scopes.join(',')}`);
|
||||
} else if (swInfo.supported && swInfo.count > 0) {
|
||||
ko('Service Worker 未激活', JSON.stringify(swInfo));
|
||||
} else {
|
||||
ko('Service Worker 未注册', 'getRegistrations() 为空');
|
||||
}
|
||||
|
||||
// 3. 关键 meta 标签
|
||||
const meta = await page.evaluate(() => {
|
||||
const get = (sel) => document.querySelector(sel)?.getAttribute('content') || null;
|
||||
return {
|
||||
themeColor: get('meta[name="theme-color"]'),
|
||||
appleCapable: get('meta[name="apple-mobile-web-app-capable"]'),
|
||||
appleTitle: get('meta[name="apple-mobile-web-app-title"]'),
|
||||
viewport: get('meta[name="viewport"]'),
|
||||
manifestLink: !!document.querySelector('link[rel="manifest"]'),
|
||||
appleTouchIcon: !!document.querySelector('link[rel="apple-touch-icon"]'),
|
||||
};
|
||||
});
|
||||
if (meta.themeColor) ok('theme-color', meta.themeColor);
|
||||
else ko('theme-color', 'missing');
|
||||
if (meta.appleCapable === 'yes') ok('apple-mobile-web-app-capable', 'yes');
|
||||
else ko('apple-mobile-web-app-capable', meta.appleCapable || 'missing');
|
||||
if (meta.manifestLink) ok('manifest link', 'present');
|
||||
else ko('manifest link', 'missing');
|
||||
if (meta.appleTouchIcon) ok('apple-touch-icon link', 'present');
|
||||
else ko('apple-touch-icon link', 'missing');
|
||||
|
||||
// 4. SW 日志确认 offline ready
|
||||
const offlineReady = swLogs.some((l) => l.includes('离线缓存就绪') || l.includes('offline'));
|
||||
if (offlineReady) ok('PWA offline log', '检测到 offline ready');
|
||||
// 离线就绪不是强校验,标 warn 即可
|
||||
if (!offlineReady) {
|
||||
checks.push({
|
||||
status: '⚠️',
|
||||
name: 'PWA offline 日志',
|
||||
detail: '运行后无离线 ready 日志(可能 SW 还没预缓存完,可忽略)',
|
||||
});
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
|
||||
// 输出
|
||||
for (const c of checks) {
|
||||
console.log(`${c.status} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
|
||||
}
|
||||
console.log(`\n总计: ✅ ${pass} ❌ ${fail}`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('PWA check 失败:', e);
|
||||
process.exit(2);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// 用 sharp 把 SVG 转为多尺寸 PNG
|
||||
import sharp from 'sharp';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const svg = await readFile(path.join(__dirname, '../public/pwa-icon.svg'));
|
||||
|
||||
const targets = [
|
||||
// 标准 PWA icons
|
||||
{ out: 'public/pwa/pwa-192x192.png', size: 192, type: 'normal' },
|
||||
{ out: 'public/pwa/pwa-512x512.png', size: 512, type: 'normal' },
|
||||
// maskable:四周留 20% 安全区,logo 居中缩到 60%
|
||||
{ out: 'public/pwa/pwa-maskable-512x512.png', size: 512, type: 'maskable' },
|
||||
// apple touch
|
||||
{ out: 'public/pwa/apple-touch-icon.png', size: 180, type: 'normal' },
|
||||
// favicon
|
||||
{ out: 'public/favicon-32x32.png', size: 32, type: 'normal' },
|
||||
{ out: 'public/favicon-16x16.png', size: 16, type: 'normal' },
|
||||
];
|
||||
|
||||
const bgColor = { r: 27, g: 110, b: 243 }; // 渐变起始色 #1B6EF3
|
||||
|
||||
for (const { out, size, type } of targets) {
|
||||
let buffer = await sharp(svg).resize(size, size).png().toBuffer();
|
||||
|
||||
if (type === 'maskable') {
|
||||
// maskable 重新画:蓝底全填 + logo 60%
|
||||
const inner = await sharp(svg).resize(Math.floor(size * 0.6), Math.floor(size * 0.6)).png().toBuffer();
|
||||
buffer = await sharp({
|
||||
create: { width: size, height: size, channels: 4, background: bgColor },
|
||||
})
|
||||
.composite([{ input: inner, gravity: 'center' }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
await writeFile(path.join(__dirname, '..', out), buffer);
|
||||
console.log('✓', out, `${size}x${size}`, type);
|
||||
}
|
||||
|
||||
console.log('Done.');
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
</router-view>
|
||||
<PwaToasts />
|
||||
<DebugPanel />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import DebugPanel from './components/DebugPanel.vue';
|
||||
import PwaToasts from './components/PwaToasts.vue';
|
||||
const auth = useAuthStore();
|
||||
onMounted(() => auth.refresh());
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 极短的全局 fade,避免 out-in 模式下空窗期过长 */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 80ms ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
// client/src/api/ai.js — AI 截图识别
|
||||
import http from './client';
|
||||
|
||||
export const getConfig = () => http.get('/ai/config');
|
||||
export const saveConfig = (data) => http.post('/ai/config', data);
|
||||
export const test = (data) => http.post('/ai/test', data || {});
|
||||
|
||||
// 上传图片,返回 { image_id, url, name, size, mime }
|
||||
export const uploadImage = (file) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return http.post('/ai/upload', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
};
|
||||
|
||||
// 识别图片
|
||||
// type: 'wash' | 'refuel' | 'charge' | 'maint' | 'insurance'
|
||||
export const recognize = (image_id, type) => http.post('/ai/recognize', { image_id, type });
|
||||
@@ -0,0 +1,8 @@
|
||||
// client/src/api/auth.js
|
||||
import client from './client';
|
||||
export const login = (username, password) => client.post('/auth/login', { username, password });
|
||||
export const logout = () => client.post('/auth/logout');
|
||||
export const changeAccount = (data) => client.post('/auth/account', data);
|
||||
export const me = () => client.get('/auth/me');
|
||||
export const csrf = () => client.get('/auth/csrf');
|
||||
export const health = () => client.get('/health');
|
||||
@@ -0,0 +1,16 @@
|
||||
// client/src/api/chemicals.js
|
||||
import client from './client';
|
||||
export const list = (params) => client.get('/chemicals', { params });
|
||||
export const all = () => client.get('/chemicals/list');
|
||||
export const get = (id) => client.get(`/chemicals/${id}`);
|
||||
export const update = (id, data) => client.put(`/chemicals/${id}`, data);
|
||||
export const create = (data) => client.post('/chemicals', data);
|
||||
export const sync = () => client.post('/chemicals/sync');
|
||||
export const refreshIds = () => client.post('/chemicals/refresh-ids');
|
||||
export const addStock = (id, data) => client.post(`/chemicals/${id}/add`, data);
|
||||
export const consumeStock = (id, data) => client.post(`/chemicals/${id}/consume`, data);
|
||||
export const grocySearch = (q) => client.get('/chemicals/grocy-search', { params: { q } });
|
||||
export const getCategories = () => client.get('/chemicals/categories');
|
||||
export const getCategoryMappings = () => client.get('/chemicals/category-mappings');
|
||||
export const saveCategoryMappings = (mappings) => client.post('/chemicals/category-mappings', { mappings });
|
||||
export const deleteCategoryMapping = (id) => client.delete(`/chemicals/category-mappings/${id}`);
|
||||
@@ -0,0 +1,166 @@
|
||||
// client/src/api/client.js — axios 实例,自动带 cookie + CSRF
|
||||
import axios from 'axios';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useDebugStore } from '../stores/debug';
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api',
|
||||
withCredentials: true,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// 特殊端点:长 timeout(同步类操作)
|
||||
const LONG_TIMEOUT_OVERRIDE = {
|
||||
'/chemicals/sync': 90000, // Grocy 拉取最多 1.5 分钟
|
||||
'/grocy/sync': 90000,
|
||||
'/chemicals/grocy-search': 60000, // Grocy 全局搜索(要拉全量 products 列表)
|
||||
'/chemicals/refresh-ids': 45000, // 后台轻量同步:只拉一次 /api/objects/products
|
||||
};
|
||||
const origRequest = client.interceptors.request;
|
||||
client.interceptors.request.use((cfg) => {
|
||||
const path = (cfg.url || '').replace(/^\/+/, '/');
|
||||
if (LONG_TIMEOUT_OVERRIDE[path]) {
|
||||
cfg.timeout = LONG_TIMEOUT_OVERRIDE[path];
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
|
||||
// 统一处理 server 的 {ok, data, error} 包装:
|
||||
// 成功 → 把 data 字段提到顶层(业务代码直接拿到)
|
||||
// 失败 → 抛错,err.response.data = server 的 error 对象
|
||||
client.interceptors.response.use(
|
||||
(r) => {
|
||||
const body = r.data;
|
||||
if (body && typeof body === 'object' && 'ok' in body) {
|
||||
if (body.ok) {
|
||||
r.data = body.data; // 剥掉包装,client 拿到业务 data
|
||||
} else {
|
||||
// ok=false:构造一个类 axios 错误抛出去
|
||||
const err = new Error(body.error?.message || '请求失败');
|
||||
err.response = { status: r.status, data: body.error || body };
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
// 调试模式:记录所有调用
|
||||
try {
|
||||
const debug = useDebugStore();
|
||||
if (debug.enabled) {
|
||||
debug.logCall({
|
||||
method: r.config?.method?.toUpperCase(),
|
||||
url: r.config?.url,
|
||||
status: r.status,
|
||||
body: summarize(r.data),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
return r;
|
||||
},
|
||||
async (err) => {
|
||||
const status = err.response?.status;
|
||||
const body = err.response?.data;
|
||||
// 把 server 的 error 也归一化到 err.response.data
|
||||
if (body && typeof body === 'object' && 'ok' in body && body.ok === false) {
|
||||
err.response.data = body.error || body;
|
||||
}
|
||||
|
||||
// 调试模式:上报到 DebugPanel
|
||||
try {
|
||||
const debug = useDebugStore();
|
||||
if (debug.enabled) {
|
||||
const cfg = err.config || {};
|
||||
// 预期错误白名单:启动时检查登录状态、未登录访问受保护资源、CSRF 缺失
|
||||
const url = cfg.url || '';
|
||||
const isExpected = (
|
||||
(status === 401 && (url === '/auth/me' || url === '/auth/csrf')) ||
|
||||
(status === 401 && location.pathname === '/login') ||
|
||||
(status === 403 && url === '/auth/me')
|
||||
);
|
||||
if (!isExpected) {
|
||||
debug.log({
|
||||
kind: 'api',
|
||||
title: `${(cfg.method || 'GET').toUpperCase()} ${cfg.url} → ${status || 'Network Error'}`,
|
||||
detail: {
|
||||
method: cfg.method?.toUpperCase(),
|
||||
url: cfg.url,
|
||||
baseURL: cfg.baseURL,
|
||||
fullURL: (cfg.baseURL || '') + (cfg.url || ''),
|
||||
status,
|
||||
statusText: err.response?.statusText,
|
||||
requestHeaders: cfg.headers,
|
||||
requestBody: cfg.data ? safeParse(cfg.data) : null,
|
||||
responseBody: err.response?.data,
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
debug.logCall({
|
||||
method: cfg.method?.toUpperCase(),
|
||||
url: cfg.url,
|
||||
status: status || 'ERR',
|
||||
body: err.response?.data,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (status === 401) {
|
||||
const auth = useAuthStore();
|
||||
auth.clear();
|
||||
if (location.pathname !== '/login') {
|
||||
// 把当前页所有 form 草稿强制刷盘(不丢用户填的数据)
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('form-draft:flush-all'));
|
||||
} catch {}
|
||||
const returnTo = location.pathname + location.search;
|
||||
location.href = '/login?redirect=' + encodeURIComponent(returnTo) + '&reason=expired';
|
||||
}
|
||||
}
|
||||
// CSRF token 失效:自动刷新 + 重试原请求。retry 标记挂在 config 上防死循环。
|
||||
if (status === 403 && (body?.code === 'CSRF' || body?.error?.code === 'CSRF') && !err.config?.__csrfRetried) {
|
||||
try {
|
||||
const auth = useAuthStore();
|
||||
await auth.refreshCsrf();
|
||||
const retryCfg = { ...err.config, __csrfRetried: true };
|
||||
// 用新 token 重新发
|
||||
if (auth.csrfToken && ['post', 'put', 'delete', 'patch'].includes(retryCfg.method)) {
|
||||
retryCfg.headers = { ...(retryCfg.headers || {}), 'X-CSRF-Token': auth.csrfToken };
|
||||
}
|
||||
return client.request(retryCfg);
|
||||
} catch (refreshErr) {
|
||||
// refresh 失败就 fall through 到 reject
|
||||
}
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
function safeParse(s) {
|
||||
if (typeof s !== 'string') return s;
|
||||
try { return JSON.parse(s); } catch { return s; }
|
||||
}
|
||||
function summarize(d) {
|
||||
if (d == null) return d;
|
||||
if (Array.isArray(d)) return `Array(${d.length})`;
|
||||
if (typeof d === 'object') {
|
||||
const keys = Object.keys(d);
|
||||
return `Object{${keys.slice(0, 6).join(',')}${keys.length > 6 ? '…' : ''}}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
client.interceptors.request.use((cfg) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.csrfToken && ['post', 'put', 'delete', 'patch'].includes(cfg.method)) {
|
||||
cfg.headers['X-CSRF-Token'] = auth.csrfToken;
|
||||
}
|
||||
return cfg;
|
||||
});
|
||||
|
||||
/** 统一解包:list API 可能直接返 array,也可能返 {key: [...]}
|
||||
* 用法:const list = asArray(r.data, 'vehicles'); */
|
||||
export function asArray(data, key) {
|
||||
if (Array.isArray(data)) return data;
|
||||
if (data && typeof data === 'object' && key && Array.isArray(data[key])) return data[key];
|
||||
return [];
|
||||
}
|
||||
|
||||
export default client;
|
||||
@@ -0,0 +1,19 @@
|
||||
// client/src/api/insurance.js — 保险记录 CRUD + 附件上传
|
||||
import http from './client';
|
||||
|
||||
export const list = (params) => http.get('/insurances', { params });
|
||||
export const get = (id) => http.get(`/insurances/${id}`);
|
||||
export const create = (data) => http.post('/insurances', data);
|
||||
export const update = (id, data) => http.put(`/insurances/${id}`, data);
|
||||
export const remove = (id) => http.delete(`/insurances/${id}`);
|
||||
|
||||
// 上传保单附件(图片或 PDF)
|
||||
export const upload = (id, file) => {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
return http.post(`/insurances/${id}/upload`, fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteAttachment = (id) => http.delete(`/insurances/${id}/attachment`);
|
||||
@@ -0,0 +1,22 @@
|
||||
// client/src/api/logs.js — 保养 / 加油 / 充电三个领域共用
|
||||
import http from './client';
|
||||
|
||||
const RES = {
|
||||
maintenances: '/maintenances',
|
||||
refuels: '/refuels',
|
||||
chargings: '/chargings',
|
||||
};
|
||||
|
||||
function factory(base) {
|
||||
return {
|
||||
list: (params) => http.get(base, { params }),
|
||||
get: (id) => http.get(`${base}/${id}`),
|
||||
create: (data) => http.post(base, data),
|
||||
update: (id, data) => http.put(`${base}/${id}`, data),
|
||||
remove: (id) => http.delete(`${base}/${id}`),
|
||||
};
|
||||
}
|
||||
|
||||
export const maintApi = factory(RES.maintenances);
|
||||
export const refuelApi = factory(RES.refuels);
|
||||
export const chargingApi = factory(RES.chargings);
|
||||
@@ -0,0 +1,6 @@
|
||||
// client/src/api/operationLogs.js
|
||||
import client from './client';
|
||||
export const list = (params) => client.get('/operation-logs', { params });
|
||||
export const get = (id) => client.get(`/operation-logs/${id}`);
|
||||
export const options = () => client.get('/operation-logs/options');
|
||||
export const recover = (id) => client.post(`/operation-logs/${id}/recover`);
|
||||
@@ -0,0 +1,16 @@
|
||||
// client/src/api/settings.js
|
||||
import client from './client';
|
||||
export const get = () => client.get('/settings');
|
||||
export const update = (data) => client.post('/settings', data);
|
||||
export const overview = () => client.get('/stats/overview');
|
||||
export const dashboardExtra = () => client.get('/dashboard/extra');
|
||||
export const getCity = () => client.get('/settings/city');
|
||||
export const grocyLogs = (limit) => client.get('/settings/grocy-logs', { params: { limit } });
|
||||
export const getWeather = () => client.get('/settings/weather');
|
||||
export const resetAll = (confirm_token, seed = true) =>
|
||||
client.post('/settings/reset', { confirm_token, seed });
|
||||
|
||||
// 月度报表
|
||||
export const reportMonths = (limit = 12) => client.get('/reports/monthly/list', { params: { limit } });
|
||||
export const reportExcelUrl = (month) => `/api/reports/monthly/excel?month=${encodeURIComponent(month)}`;
|
||||
export const reportPdfUrl = (month) => `/api/reports/monthly/pdf?month=${encodeURIComponent(month)}`;
|
||||
@@ -0,0 +1,9 @@
|
||||
// client/src/api/vehicles.js
|
||||
import client from './client';
|
||||
export const list = (params) => client.get('/vehicles', { params });
|
||||
export const get = (id) => client.get(`/vehicles/${id}`);
|
||||
export const create = (data) => client.post('/vehicles', data);
|
||||
export const update = (id, data) => client.put(`/vehicles/${id}`, data);
|
||||
export const remove = (id) => client.delete(`/vehicles/${id}`);
|
||||
export const stats = () => client.get('/vehicles/stats');
|
||||
export const health = (id) => client.get(`/vehicles/${id}/health`);
|
||||
@@ -0,0 +1,17 @@
|
||||
// client/src/api/washes.js
|
||||
import client from './client';
|
||||
export const list = (params) => client.get('/washes', { params });
|
||||
export const get = (id) => client.get(`/washes/${id}`);
|
||||
export const create = (data) => client.post('/washes', data);
|
||||
export const remove = (id) => client.delete(`/washes/${id}`);
|
||||
export const batchDelete = (ids, challenge) => client.post('/washes/batch-delete', { ids, challenge });
|
||||
export const types = () => client.get('/washes/types');
|
||||
|
||||
// 对比照
|
||||
export const listPhotos = (id) => client.get(`/washes/${id}/photos`);
|
||||
export const uploadPhoto = (id, formData) => client.post(`/washes/${id}/photos`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
export const deletePhoto = (id, photoId) => client.delete(`/washes/${id}/photos/${photoId}`);
|
||||
export const comparePhotos = (id, type1 = 'before', type2 = 'after') =>
|
||||
client.get(`/washes/${id}/photos/compare`, { params: { type1, type2 } });
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show" class="ai-fallback-backdrop" @click.self="$emit('cancel')">
|
||||
<div class="ai-fallback-modal">
|
||||
<header class="hd">
|
||||
<h2>AI 识别未成功,手动填一下</h2>
|
||||
<p class="sub">AI 只是加速器,不是真理 — 看着图填完提交就行</p>
|
||||
<button class="x" @click="$emit('cancel')" aria-label="关闭">×</button>
|
||||
</header>
|
||||
<div class="body">
|
||||
<div class="img-pane">
|
||||
<img v-if="imageUrl" :src="imageUrl" alt="待识别图片" />
|
||||
<div v-else class="img-missing">图片不可预览</div>
|
||||
<p class="hint">📌 对照右栏填表,遇到看不清的字段直接留空</p>
|
||||
</div>
|
||||
<div class="form-pane">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<footer class="ft">
|
||||
<button type="button" class="btn btn-ghost" @click="$emit('cancel')">取消</button>
|
||||
<button type="button" class="btn btn-primary" @click="$emit('confirm')">填好了,提交</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
imageUrl: { type: String, default: '' },
|
||||
});
|
||||
defineEmits(['confirm', 'cancel']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-fallback-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(15, 23, 42, 0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 9999; padding: 16px;
|
||||
}
|
||||
.ai-fallback-modal {
|
||||
background: #fff; border-radius: 14px; box-shadow: 0 24px 60px rgba(0,0,0,.25);
|
||||
width: min(1100px, 100%); max-height: 90vh; display: flex; flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.hd { padding: 18px 20px 12px; border-bottom: 1px solid var(--c-border); position: relative; }
|
||||
.hd h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.hd .sub { margin: 0; color: var(--c-mute); font-size: 13px; }
|
||||
.hd .x { position: absolute; top: 12px; right: 12px; background: transparent; border: 0; font-size: 24px; cursor: pointer; color: var(--c-mute); }
|
||||
.hd .x:hover { color: var(--c-fg); }
|
||||
.body { display: grid; grid-template-columns: 1fr 1fr; gap: 0; flex: 1; min-height: 0; }
|
||||
.img-pane { background: #f8fafc; padding: 16px; overflow: auto; border-right: 1px solid var(--c-border); display: flex; flex-direction: column; gap: 12px; }
|
||||
.img-pane img { max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,.08); }
|
||||
.img-pane .img-missing { color: var(--c-mute); padding: 40px; text-align: center; }
|
||||
.img-pane .hint { font-size: 12px; color: var(--c-mute); margin: 0; }
|
||||
.form-pane { padding: 16px 20px; overflow: auto; }
|
||||
.ft { padding: 12px 20px; border-top: 1px solid var(--c-border); display: flex; justify-content: flex-end; gap: 10px; }
|
||||
@media (max-width: 720px) {
|
||||
.body { grid-template-columns: 1fr; }
|
||||
.img-pane { max-height: 40vh; border-right: 0; border-bottom: 1px solid var(--c-border); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,290 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="container">
|
||||
<div class="left">
|
||||
<router-link to="/" class="brand" @click="close">
|
||||
<span class="logo">CL</span>
|
||||
<span class="brand-text">
|
||||
<span class="brand-name">CarLog</span>
|
||||
<span class="brand-sub">车记</span>
|
||||
</span>
|
||||
</router-link>
|
||||
<!-- 桌面端:横排导航 -->
|
||||
<nav class="nav-desktop">
|
||||
<div class="nav-group">
|
||||
<router-link to="/" class="nav-item" exact-active-class="active" @click="close">概览</router-link>
|
||||
<router-link to="/washes" class="nav-item" active-class="active" @click="close">洗车记录</router-link>
|
||||
<router-link to="/vehicles" class="nav-item" active-class="active" @click="close">车辆</router-link>
|
||||
<router-link to="/maintenances" class="nav-item" active-class="active" @click="close">保养</router-link>
|
||||
</div>
|
||||
<span class="nav-sep" aria-hidden="true">│</span>
|
||||
<div class="nav-group">
|
||||
<router-link to="/refuels" class="nav-item" active-class="active" @click="close">加油</router-link>
|
||||
<router-link to="/chargings" class="nav-item" active-class="active" @click="close">充电</router-link>
|
||||
<router-link to="/insurances" class="nav-item" active-class="active" @click="close">保险</router-link>
|
||||
<router-link to="/chemicals" class="nav-item" active-class="active" @click="close">车品</router-link>
|
||||
</div>
|
||||
<span class="nav-sep" aria-hidden="true">│</span>
|
||||
<div class="nav-group">
|
||||
<router-link to="/stats" class="nav-item" active-class="active" @click="close">统计</router-link>
|
||||
<router-link to="/operation-logs" class="nav-item" active-class="active" @click="close">操作日志</router-link>
|
||||
<router-link to="/settings" class="nav-item" active-class="active" @click="close">设置</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="right">
|
||||
<span v-if="auth.user" class="user">
|
||||
<span class="avatar">{{ initial }}</span>
|
||||
<span class="username">{{ auth.user.username }}</span>
|
||||
</span>
|
||||
<form @submit.prevent="onLogout" class="logout-form">
|
||||
<button class="btn btn-ghost btn-sm desktop-only" type="submit">退出</button>
|
||||
</form>
|
||||
<!-- 移动端:汉堡按钮 -->
|
||||
<button
|
||||
class="hamburger mobile-only"
|
||||
type="button"
|
||||
:aria-expanded="open"
|
||||
aria-label="打开菜单"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="bar" :class="{ on: open }"></span>
|
||||
<span class="bar" :class="{ on: open }"></span>
|
||||
<span class="bar" :class="{ on: open }"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端抽屉菜单 -->
|
||||
<Transition name="drawer">
|
||||
<div v-if="open" class="drawer" role="dialog" aria-label="导航菜单">
|
||||
<div class="drawer-mask" @click="close"></div>
|
||||
<nav class="drawer-panel">
|
||||
<div class="drawer-head">
|
||||
<div class="user">
|
||||
<span class="avatar">{{ initial }}</span>
|
||||
<div class="user-meta">
|
||||
<div class="username">{{ auth.user?.username || '未登录' }}</div>
|
||||
<div class="user-sub text-mute">CarLog 车记</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title">核心</div>
|
||||
<router-link to="/" class="drawer-item" exact-active-class="active" @click="close">概览</router-link>
|
||||
<router-link to="/washes" class="drawer-item" active-class="active" @click="close">洗车记录</router-link>
|
||||
<router-link to="/vehicles" class="drawer-item" active-class="active" @click="close">车辆</router-link>
|
||||
<router-link to="/maintenances" class="drawer-item" active-class="active" @click="close">保养</router-link>
|
||||
</div>
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title">能耗</div>
|
||||
<router-link to="/refuels" class="drawer-item" active-class="active" @click="close">加油</router-link>
|
||||
<router-link to="/chargings" class="drawer-item" active-class="active" @click="close">充电</router-link>
|
||||
<router-link to="/insurances" class="drawer-item" active-class="active" @click="close">保险</router-link>
|
||||
<router-link to="/chemicals" class="drawer-item" active-class="active" @click="close">车品</router-link>
|
||||
</div>
|
||||
<div class="drawer-section">
|
||||
<div class="drawer-section-title">其他</div>
|
||||
<router-link to="/stats" class="drawer-item" active-class="active" @click="close">统计</router-link>
|
||||
<router-link to="/operation-logs" class="drawer-item" active-class="active" @click="close">操作日志</router-link>
|
||||
<router-link to="/settings" class="drawer-item" active-class="active" @click="close">设置</router-link>
|
||||
</div>
|
||||
<div class="drawer-foot">
|
||||
<form @submit.prevent="onLogout">
|
||||
<button class="btn btn-ghost btn-block" type="submit">退出登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const initial = computed(() => (auth.user?.username?.[0] || '?').toUpperCase());
|
||||
|
||||
const open = ref(false);
|
||||
function close() { open.value = false; }
|
||||
|
||||
// 路由切换时自动关闭抽屉
|
||||
watch(() => route.fullPath, () => { open.value = false; });
|
||||
|
||||
async function onLogout() {
|
||||
await auth.logout();
|
||||
router.push({ name: 'login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-top: var(--safe-top);
|
||||
}
|
||||
.container {
|
||||
max-width: 1240px; margin: 0 auto; padding: 0 24px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
height: 64px;
|
||||
}
|
||||
.left { display: flex; align-items: center; gap: 32px; min-width: 0; flex: 1; }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.logo {
|
||||
width: 32px; height: 32px; border-radius: 8px;
|
||||
background: var(--accent); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; font-weight: 700; letter-spacing: -0.02em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand-text { display: flex; align-items: baseline; gap: 6px; }
|
||||
.brand-name { font-size: 16px; }
|
||||
.brand-sub { font-size: 13px; color: var(--text-soft); font-weight: 500; }
|
||||
|
||||
/* === 桌面端导航 === */
|
||||
.nav-desktop { display: flex; align-items: center; gap: 4px; }
|
||||
.nav-group { display: flex; align-items: center; gap: 2px; }
|
||||
.nav-sep {
|
||||
color: var(--line);
|
||||
margin: 0 10px;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.nav-item {
|
||||
padding: 6px 12px; border-radius: var(--pill);
|
||||
font-size: 14px; color: var(--text-soft);
|
||||
transition: all .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.nav-item:hover { color: var(--text); background: var(--bg-soft); }
|
||||
.nav-item.active { color: #fff; background: var(--accent); }
|
||||
|
||||
.right { display: flex; align-items: center; gap: 14px; flex-shrink: 0; }
|
||||
.user { display: flex; align-items: center; gap: 8px; font-size: 14px; }
|
||||
.avatar {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
background: var(--brand); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 12px; font-weight: 600;
|
||||
}
|
||||
.logout-form { margin: 0; }
|
||||
|
||||
/* === 移动端:显示汉堡按钮,隐藏桌面导航 === */
|
||||
.mobile-only { display: none; }
|
||||
.desktop-only { display: inline-flex; }
|
||||
.hamburger {
|
||||
background: transparent; border: 0;
|
||||
width: 40px; height: 40px;
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.hamburger:hover { background: var(--bg-soft); }
|
||||
.bar {
|
||||
display: block; width: 22px; height: 2px;
|
||||
background: var(--text); border-radius: 2px;
|
||||
transition: all .25s;
|
||||
transform-origin: center;
|
||||
}
|
||||
.bar.on:nth-child(1) { transform: translateY(7px) rotate(45deg); }
|
||||
.bar.on:nth-child(2) { opacity: 0; }
|
||||
.bar.on:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
|
||||
|
||||
/* === 抽屉 === */
|
||||
.drawer {
|
||||
position: fixed; inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.drawer-mask {
|
||||
position: absolute; inset: 0;
|
||||
background: rgba(15, 34, 51, 0.5);
|
||||
}
|
||||
.drawer-panel {
|
||||
position: absolute; top: 0; right: 0; bottom: 0;
|
||||
width: 280px; max-width: 80vw;
|
||||
background: var(--card);
|
||||
box-shadow: -4px 0 24px rgba(15, 34, 51, 0.15);
|
||||
display: flex; flex-direction: column;
|
||||
padding-top: var(--safe-top);
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
.drawer-head {
|
||||
padding: 20px 20px 16px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.user-meta { display: flex; flex-direction: column; }
|
||||
.username { font-weight: 600; font-size: 15px; }
|
||||
.user-sub { font-size: 12px; margin-top: 2px; }
|
||||
.drawer-section { padding: 12px 12px 0; }
|
||||
.drawer-section-title {
|
||||
font-size: 11px; color: var(--text-mute);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
padding: 8px 8px 4px; font-weight: 600;
|
||||
}
|
||||
.drawer-item {
|
||||
display: block;
|
||||
padding: 12px 12px; margin: 2px 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
transition: background .12s;
|
||||
}
|
||||
.drawer-item:active { background: var(--bg-soft); }
|
||||
.drawer-item.active {
|
||||
background: var(--accent); color: #fff;
|
||||
}
|
||||
.drawer-foot {
|
||||
margin-top: auto;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
|
||||
/* 抽屉动画 */
|
||||
.drawer-enter-active, .drawer-leave-active {
|
||||
transition: opacity .2s;
|
||||
}
|
||||
.drawer-enter-active .drawer-panel, .drawer-leave-active .drawer-panel {
|
||||
transition: transform .25s ease;
|
||||
}
|
||||
.drawer-enter-from, .drawer-leave-to { opacity: 0; }
|
||||
.drawer-enter-from .drawer-panel, .drawer-leave-to .drawer-panel {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* === 响应式断点 === */
|
||||
@media (max-width: 1023px) {
|
||||
/* 平板:保留桌面导航(很多 1024 平板横屏能装下),但缩小间距 */
|
||||
.container { gap: 16px; }
|
||||
.left { gap: 16px; }
|
||||
.nav-sep { margin: 0 4px; }
|
||||
.nav-item { padding: 6px 8px; font-size: 13px; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* 移动端:切到汉堡菜单 */
|
||||
.container { height: 56px; padding: 0 16px; }
|
||||
.left { gap: 12px; }
|
||||
.nav-desktop { display: none; }
|
||||
.desktop-only { display: none; }
|
||||
.mobile-only { display: flex; }
|
||||
.user .username { display: none; } /* 顶栏不再显示用户名(抽屉里有) */
|
||||
.brand-text { display: none; } /* 顶栏只保留 logo */
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.container { padding: 0 12px; }
|
||||
.hamburger { width: 36px; height: 36px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<AppHeader />
|
||||
<main class="main">
|
||||
<div class="container">
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<span>CarLog 车记 · 个人洗车管理 · Node.js + Vue 3</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AppHeader from './AppHeader.vue';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout { min-height: 100%; display: flex; flex-direction: column; }
|
||||
.main {
|
||||
flex: 1;
|
||||
padding: 28px 0 60px;
|
||||
padding-bottom: calc(60px + var(--safe-bottom));
|
||||
padding-left: var(--safe-left);
|
||||
padding-right: var(--safe-right);
|
||||
}
|
||||
.container {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.footer {
|
||||
padding: 18px 24px;
|
||||
padding-bottom: calc(18px + var(--safe-bottom));
|
||||
text-align: center;
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 1023px) {
|
||||
.main { padding: 24px 0 48px; }
|
||||
.container { padding: 0 20px; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.main { padding: 16px 0 40px; }
|
||||
.container { padding: 0 16px; }
|
||||
.footer { font-size: 11px; padding: 14px 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.main { padding: 12px 0 32px; }
|
||||
.container { padding: 0 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<canvas ref="canvas"></canvas>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* ChartBlock.vue — 通用 Chart.js 包装
|
||||
* - 只在 mounted 时按需 import chart.js/auto(避免首屏静态依赖)
|
||||
* - props.type / props.data / props.options 直接传给 Chart 构造器
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
type: { type: String, required: true },
|
||||
data: { type: Object, required: true },
|
||||
options: { type: Object, default: () => ({}) },
|
||||
});
|
||||
|
||||
const canvas = ref(null);
|
||||
let chart = null;
|
||||
let ChartMod = null;
|
||||
let mounted = true;
|
||||
|
||||
async function ensureChart() {
|
||||
if (!ChartMod) {
|
||||
const mod = await import('chart.js/auto');
|
||||
ChartMod = mod.default || mod.Chart || mod;
|
||||
}
|
||||
return ChartMod;
|
||||
}
|
||||
|
||||
async function render() {
|
||||
if (!mounted) return;
|
||||
// 等 canvas 真的完成 DOM 插入
|
||||
await nextTick();
|
||||
if (!mounted || !canvas.value) return;
|
||||
if (chart) {
|
||||
// 已有实例:仅更新
|
||||
try {
|
||||
chart.data = props.data;
|
||||
if (props.options) chart.options = props.options;
|
||||
chart.update();
|
||||
} catch (e) {
|
||||
console.warn('[ChartBlock] update 失败,重建', e);
|
||||
safeDestroy();
|
||||
await createChart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
await createChart();
|
||||
}
|
||||
|
||||
async function createChart() {
|
||||
try {
|
||||
const Chart = await ensureChart();
|
||||
if (!mounted || !canvas.value) return;
|
||||
chart = new Chart(canvas.value, {
|
||||
type: props.type,
|
||||
data: props.data,
|
||||
options: props.options,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[ChartBlock] 构造 Chart 失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
function safeDestroy() {
|
||||
if (!chart) return;
|
||||
try {
|
||||
chart.destroy();
|
||||
} catch (e) {
|
||||
/* 忽略 */
|
||||
}
|
||||
chart = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
render();
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
mounted = false;
|
||||
safeDestroy();
|
||||
});
|
||||
watch(
|
||||
() => [props.data, props.options],
|
||||
() => render(),
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="chem-picker" ref="rootEl">
|
||||
<!-- 搜索框(tag + input 同一行) -->
|
||||
<div class="search-wrap">
|
||||
<!-- 已选标签 -->
|
||||
<span v-if="selected.length > 0" class="tag">
|
||||
{{ selected[0].name }}
|
||||
<button type="button" class="tag-x" @click.stop="clearSelected">×</button>
|
||||
</span>
|
||||
<!-- 输入框 -->
|
||||
<input
|
||||
ref="inputEl"
|
||||
:value="query"
|
||||
class="input chem-search"
|
||||
:placeholder="selected.length > 0 ? '' : placeholder"
|
||||
autocomplete="off"
|
||||
@input="query = $event.target.value"
|
||||
@focus="open = true"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<!-- 下拉 -->
|
||||
<div v-if="open && filtered.length > 0" class="dropdown">
|
||||
<div
|
||||
v-for="(ch, idx) in filtered"
|
||||
:key="ch.grocy_product_id"
|
||||
class="item"
|
||||
:class="{ active: idx === activeIdx }"
|
||||
@click="add(ch)"
|
||||
@mouseover="activeIdx = idx"
|
||||
>
|
||||
<span class="item-name">{{ ch.name }}</span>
|
||||
<span class="item-meta">
|
||||
{{ ch.category || '—' }}
|
||||
<span class="item-stock">库存 {{ ch.current_amount }} {{ ch.unit || '' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="open && query && filtered.length === 0" class="dropdown empty">
|
||||
无匹配「{{ query }}」
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' }, // 当前选中 id
|
||||
chemicals: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '搜索化学品…' },
|
||||
});
|
||||
const emit = defineEmits(['update:modelValue', 'change']);
|
||||
|
||||
// 当前已选标签(从 chemicals 解析)
|
||||
const selected = computed(() =>
|
||||
props.chemicals.filter(c => c.grocy_product_id === props.modelValue)
|
||||
);
|
||||
|
||||
const query = ref('');
|
||||
const open = ref(false);
|
||||
const activeIdx = ref(0);
|
||||
const rootEl = ref(null);
|
||||
const inputEl = ref(null);
|
||||
|
||||
// 搜索框显示:选中项显示名字,无选中时显示用户输入
|
||||
const inputDisplay = computed(() => {
|
||||
if (selected.value.length > 0) return selected.value[0].name;
|
||||
return query.value;
|
||||
});
|
||||
|
||||
// 模糊搜索:匹配 name 和 category
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim().toLowerCase();
|
||||
if (!q) return props.chemicals.slice(0, 30); // 无关键词显示前30个
|
||||
return props.chemicals
|
||||
.filter(c =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
(c.category || '').toLowerCase().includes(q)
|
||||
)
|
||||
.slice(0, 50); // 最多50条
|
||||
});
|
||||
|
||||
watch(query, () => { activeIdx.value = 0; });
|
||||
watch(open, (v) => { if (v) nextTick(() => inputEl.value?.focus()); });
|
||||
|
||||
function add(ch) {
|
||||
emit('update:modelValue', ch.grocy_product_id);
|
||||
emit('change', ch);
|
||||
query.value = '';
|
||||
open.value = false;
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
function remove(id) {
|
||||
if (id === props.modelValue) {
|
||||
emit('update:modelValue', '');
|
||||
emit('change', null);
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelected() {
|
||||
emit('update:modelValue', '');
|
||||
emit('change', null);
|
||||
query.value = '';
|
||||
open.value = false;
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIdx.value = Math.min(activeIdx.value + 1, filtered.value.length - 1);
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIdx.value = Math.max(activeIdx.value - 1, 0);
|
||||
} else if (e.key === 'Enter' && filtered.value.length > 0) {
|
||||
e.preventDefault();
|
||||
add(filtered.value[activeIdx.value]);
|
||||
} else if (e.key === 'Escape') {
|
||||
open.value = false;
|
||||
} else if (e.key === 'Backspace' && !query.value && props.modelValue) {
|
||||
emit('update:modelValue', '');
|
||||
emit('change', null);
|
||||
}
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
function onDocClick(e) {
|
||||
if (rootEl.value && !rootEl.value.contains(e.target)) {
|
||||
open.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick));
|
||||
onUnmounted(() => document.removeEventListener('click', onDocClick));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chem-picker { position: relative; }
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
min-height: 36px; /* 固定高度 = 跟其他 input 一致 */
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.search-wrap:focus-within {
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 2px rgba(30, 91, 138, 0.15);
|
||||
}
|
||||
.tag {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
background: var(--brand); color: #fff;
|
||||
padding: 2px 6px 2px 8px; border-radius: 12px;
|
||||
font-size: 12px; white-space: nowrap; flex-shrink: 0;
|
||||
}
|
||||
.tag-x {
|
||||
background: none; border: none; color: inherit;
|
||||
cursor: pointer; padding: 0; line-height: 1;
|
||||
font-size: 14px; opacity: 0.7;
|
||||
}
|
||||
.tag-x:hover { opacity: 1; }
|
||||
.chem-search {
|
||||
flex: 1; min-width: 80px;
|
||||
border: none; outline: none; background: transparent;
|
||||
font-size: 14px; color: var(--text);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.chem-search::placeholder { color: var(--text-soft); }
|
||||
.dropdown {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
|
||||
background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); box-shadow: 0 4px 12px rgba(0,0,0,.1);
|
||||
z-index: 200; max-height: 320px; overflow-y: auto;
|
||||
}
|
||||
.dropdown.empty { padding: 8px 12px; color: var(--text-soft); font-size: 13px; }
|
||||
.item {
|
||||
padding: 8px 12px; cursor: pointer;
|
||||
border-bottom: 1px solid var(--border); font-size: 13px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
.item:last-child { border-bottom: none; }
|
||||
.item:hover, .item.active { background: var(--bg-soft); }
|
||||
.item-name { font-weight: 500; }
|
||||
.item-meta { font-size: 12px; color: var(--text-soft); display: flex; gap: 8px; align-items: center; }
|
||||
.item-stock { color: var(--brand); }
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="modal-mask" @click.self="onCancel">
|
||||
<div class="modal">
|
||||
<div class="modal-title">
|
||||
<span class="warn-icon">{{ titleIcon }}</span>
|
||||
{{ title }}
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="modal-message">{{ message }}</p>
|
||||
<slot />
|
||||
|
||||
<!-- 数学题模式 -->
|
||||
<div v-if="mode === 'math'" class="challenge">
|
||||
<div class="challenge-q">
|
||||
请计算:
|
||||
<code class="math">{{ challenge.a }} {{ challenge.op }} {{ challenge.b }} = ?</code>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="answer"
|
||||
type="number"
|
||||
class="input"
|
||||
:placeholder="`输入结果`"
|
||||
@keyup.enter="onConfirm"
|
||||
ref="inputEl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 打字确认模式 -->
|
||||
<div v-else-if="mode === 'type'" class="challenge">
|
||||
<div class="challenge-q">
|
||||
请输入 <code class="type-word">{{ confirmWord }}</code> 以确认
|
||||
</div>
|
||||
<input
|
||||
v-model="typed"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="`输入 ${confirmWord}`"
|
||||
@keyup.enter="onConfirm"
|
||||
ref="inputEl"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 温馨提示 -->
|
||||
<div v-if="tips.length" class="tips">
|
||||
<div class="tips-label">💡 温馨提示</div>
|
||||
<ul class="tips-list">
|
||||
<li v-for="(tip, i) in tips" :key="i">{{ tip }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="modal-error">{{ error }}</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" :disabled="busy" @click="onCancel">取消</button>
|
||||
<button class="btn btn-danger" :disabled="!canSubmit || busy" @click="onConfirm">
|
||||
{{ busy ? '处理中…' : confirmLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '确认操作' },
|
||||
message: { type: String, default: '' },
|
||||
mode: { type: String, default: 'type' }, // 'math' | 'type'
|
||||
confirmLabel: { type: String, default: '确认删除' },
|
||||
confirmWord: { type: String, default: '删除' },
|
||||
busy: { type: Boolean, default: false },
|
||||
error: { type: String, default: '' },
|
||||
tips: { type: Array, default: () => [] }, // 温馨提示列表
|
||||
dangerType: { type: String, default: 'delete' }, // 'delete' | 'recover'
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
|
||||
|
||||
const challenge = ref(genChallenge());
|
||||
const answer = ref(null);
|
||||
const typed = ref('');
|
||||
const inputEl = ref(null);
|
||||
|
||||
function genChallenge() {
|
||||
const a = 1 + Math.floor(Math.random() * 9);
|
||||
const b = 1 + Math.floor(Math.random() * 9);
|
||||
const ops = ['+', '-', '*'];
|
||||
const op = ops[Math.floor(Math.random() * ops.length)];
|
||||
return { a, b, op };
|
||||
}
|
||||
|
||||
const titleIcon = computed(() => {
|
||||
if (props.dangerType === 'recover') return '🔄';
|
||||
return '⚠️';
|
||||
});
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.mode === 'math') {
|
||||
if (answer.value == null || answer.value === '') return false;
|
||||
const { a, b, op } = challenge.value;
|
||||
const expected = op === '+' ? a + b : op === '-' ? a - b : a * b;
|
||||
return Number(answer.value) === expected;
|
||||
}
|
||||
if (props.mode === 'type') {
|
||||
return String(typed.value || '').trim() === props.confirmWord;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (v) {
|
||||
challenge.value = genChallenge();
|
||||
answer.value = null;
|
||||
typed.value = '';
|
||||
nextTick(() => inputEl.value?.focus());
|
||||
}
|
||||
});
|
||||
|
||||
function onConfirm() {
|
||||
if (!canSubmit.value || props.busy) return;
|
||||
if (props.mode === 'math') {
|
||||
emit('confirm', { a: challenge.value.a, b: challenge.value.b, op: challenge.value.op, answer: Number(answer.value) });
|
||||
} else {
|
||||
emit('confirm', typed.value);
|
||||
}
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
if (props.busy) return;
|
||||
emit('cancel');
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(15, 34, 51, 0.42);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal {
|
||||
background: var(--card);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
|
||||
width: 100%; max-width: 480px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.modal-title {
|
||||
padding: 18px 22px 6px;
|
||||
font-size: 17px; font-weight: 600;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.warn-icon { font-size: 20px; }
|
||||
.modal-body { padding: 0 22px 16px; }
|
||||
.modal-message { margin: 0 0 12px; color: var(--text); font-size: 14px; line-height: 1.5; }
|
||||
.challenge {
|
||||
background: var(--bg-soft);
|
||||
padding: 14px 16px;
|
||||
border-radius: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.challenge-q { font-size: 14px; margin-bottom: 10px; color: var(--text); }
|
||||
.math, .type-word {
|
||||
background: var(--card);
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, monospace;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin: 0 4px;
|
||||
}
|
||||
.tips {
|
||||
background: #EFF6FF;
|
||||
border: 1px solid #BFDBFE;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.tips-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #1D4ED8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.tips-list {
|
||||
margin: 0; padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: #1E40AF;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.tips-list li { margin: 0; }
|
||||
.modal-error {
|
||||
margin: 0 22px 14px;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
background: #FBE3DF;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex; justify-content: flex-end; gap: 10px;
|
||||
padding: 12px 22px 18px;
|
||||
background: var(--bg-soft);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.btn-danger {
|
||||
background: var(--danger); color: #fff;
|
||||
}
|
||||
.btn-danger:hover:not(:disabled) { background: #d63c2f; }
|
||||
.btn-danger:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
/* === 移动端:底部弹出 sheet(全屏式) === */
|
||||
@media (max-width: 767px) {
|
||||
.modal-mask {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
.modal {
|
||||
max-width: none;
|
||||
width: 100%;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: sheetUp .25s ease;
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
@keyframes sheetUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
.modal-actions {
|
||||
position: sticky; bottom: 0;
|
||||
padding: 12px 16px calc(12px + var(--safe-bottom));
|
||||
}
|
||||
.modal-actions .btn {
|
||||
flex: 1;
|
||||
min-height: 44px; /* iOS HIG 触控标准 */
|
||||
}
|
||||
.modal-title { padding: 16px 16px 4px; font-size: 16px; }
|
||||
.modal-body { padding: 0 16px 14px; }
|
||||
.modal-error { margin: 0 16px 12px; }
|
||||
.challenge { padding: 12px 14px; }
|
||||
.challenge .input { min-height: 44px; font-size: 16px; } /* iOS 16px 防缩放 */
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.tips-list { font-size: 11px; }
|
||||
.modal-message { font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="store.enabled" class="debug-root" :class="{ collapsed: store.collapsed }">
|
||||
<!-- 折叠态:小气泡 -->
|
||||
<button v-if="store.collapsed" class="bubble" @click="store.togglePanel()">
|
||||
🐞 <span v-if="store.count" class="dot"></span>
|
||||
</button>
|
||||
<!-- 展开态:完整面板 -->
|
||||
<div v-else class="panel">
|
||||
<header class="hdr">
|
||||
<div class="tabs">
|
||||
<button :class="['tab', { active: store.tab==='errors' }]" @click="store.setTab('errors')">
|
||||
错误 <span v-if="store.count" class="badge err-badge">{{ store.count }}</span>
|
||||
</button>
|
||||
<button :class="['tab', { active: store.tab==='calls' }]" @click="store.setTab('calls')">
|
||||
API <span class="badge">{{ store.callCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn-mini" @click="store.clear()">清空</button>
|
||||
<button class="btn-mini" @click="copyAll" :disabled="!store.count && !store.callCount">复制</button>
|
||||
<button class="btn-mini" @click="store.togglePanel()">−</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- 错误 tab -->
|
||||
<div v-if="store.tab==='errors'" class="list" :class="{ 'empty-wrap': !store.count }">
|
||||
<article v-for="e in [...store.errors].reverse()" :key="e.id" class="card-err">
|
||||
<header class="row">
|
||||
<span class="ts">{{ fmtTime(e.ts) }}</span>
|
||||
<span class="kind" :class="e.kind">{{ kindLabel(e.kind) }}</span>
|
||||
<span class="ttl">{{ e.title }}</span>
|
||||
<button class="mini" @click="copyOne(e)">复制</button>
|
||||
<button class="mini x" @click="remove(e.id)">×</button>
|
||||
</header>
|
||||
<pre class="detail">{{ fmtDetail(e.detail) }}</pre>
|
||||
</article>
|
||||
<div v-if="!store.count" class="empty">暂无错误 · 一切正常 ✨</div>
|
||||
</div>
|
||||
<!-- API tab -->
|
||||
<div v-else class="list" :class="{ 'empty-wrap': !store.callCount }">
|
||||
<article v-for="c in [...store.calls].reverse()" :key="c.id" class="card-err">
|
||||
<header class="row">
|
||||
<span class="ts">{{ fmtTime(c.ts) }}</span>
|
||||
<span class="status" :class="statusClass(c.status)">{{ c.status }}</span>
|
||||
<span class="method" :class="mClass(c.method)">{{ c.method }}</span>
|
||||
<span class="url" :title="c.url">{{ c.url }}</span>
|
||||
<button class="mini" @click="copyCall(c)">复制</button>
|
||||
</header>
|
||||
<pre v-if="c.body != null" class="detail">{{ fmtBody(c.body) }}</pre>
|
||||
</article>
|
||||
<div v-if="!store.callCount" class="empty">暂无 API 调用记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useDebugStore } from '../stores/debug';
|
||||
const store = useDebugStore();
|
||||
|
||||
function fmtTime(iso) {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString('zh-CN', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0');
|
||||
}
|
||||
function kindLabel(k) {
|
||||
return ({ api: 'API', vue: '组件', runtime: '运行', promise: 'Promise' }[k] || k);
|
||||
}
|
||||
function statusClass(s) {
|
||||
if (s == null) return 's-net';
|
||||
if (s >= 500) return 's-err';
|
||||
if (s >= 400) return 's-warn';
|
||||
if (s >= 200 && s < 300) return 's-ok';
|
||||
return 's-info';
|
||||
}
|
||||
function mClass(m) { return 'm-' + (m || '').toLowerCase(); }
|
||||
function fmtDetail(d) {
|
||||
if (!d || typeof d !== 'object') return String(d);
|
||||
return JSON.stringify(d, null, 2);
|
||||
}
|
||||
function fmtBody(b) {
|
||||
if (typeof b === 'string') return b;
|
||||
return JSON.stringify(b, null, 2);
|
||||
}
|
||||
function copyOne(e) {
|
||||
const text = `[${e.ts}] [${e.kind}] ${e.title}\n${fmtDetail(e.detail)}`;
|
||||
copyText(text);
|
||||
}
|
||||
function copyCall(c) {
|
||||
const text = `[${c.ts}] ${c.method} ${c.url} → ${c.status}\n${c.body != null ? fmtBody(c.body) : ''}`;
|
||||
copyText(text);
|
||||
}
|
||||
function copyAll() {
|
||||
const parts = [];
|
||||
if (store.errors.length) parts.push('## 错误\n' + store.errors.map(e =>
|
||||
`[${e.ts}] [${e.kind}] ${e.title}\n${fmtDetail(e.detail)}`
|
||||
).join('\n\n'));
|
||||
if (store.calls.length) parts.push('## API\n' + store.calls.map(c =>
|
||||
`[${c.ts}] ${c.method} ${c.url} → ${c.status}\n${c.body != null ? fmtBody(c.body) : ''}`
|
||||
).join('\n'));
|
||||
copyText(parts.join('\n\n────\n\n'));
|
||||
}
|
||||
async function copyText(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
flash('已复制到剪贴板 ✓');
|
||||
} catch {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text; document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand('copy'); flash('已复制(兼容模式)✓'); }
|
||||
catch { flash('复制失败,请手动 Ctrl+C'); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
function flash(msg) {
|
||||
const t = document.createElement('div');
|
||||
t.className = 'flash-toast';
|
||||
t.textContent = msg;
|
||||
document.body.appendChild(t);
|
||||
setTimeout(() => t.classList.add('show'), 10);
|
||||
setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 300); }, 1500);
|
||||
}
|
||||
function remove(id) {
|
||||
const i = store.errors.findIndex(e => e.id === id);
|
||||
if (i >= 0) store.errors.splice(i, 1);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.debug-root {
|
||||
position: fixed; right: 16px; bottom: 16px; z-index: 9999;
|
||||
font-family: 'SF Mono', Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
max-width: 620px;
|
||||
}
|
||||
.bubble {
|
||||
background: #0F2233; color: #fff; border: 0; border-radius: 999px;
|
||||
padding: 8px 14px; font-size: 13px; box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
cursor: pointer; position: relative;
|
||||
}
|
||||
.bubble:hover { background: #1A3A55; }
|
||||
.dot {
|
||||
position: absolute; top: 4px; right: 4px; width: 6px; height: 6px;
|
||||
background: #D9695C; border-radius: 50%;
|
||||
}
|
||||
.panel {
|
||||
background: #fff; border: 1px solid #E1ECF2; border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||
overflow: hidden;
|
||||
display: flex; flex-direction: column;
|
||||
max-height: 70vh; width: 620px;
|
||||
}
|
||||
.hdr {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
background: #0F2233; color: #fff; padding: 0 8px 0 0;
|
||||
}
|
||||
.tabs { display: flex; }
|
||||
.tab {
|
||||
background: transparent; color: #B0BEC5; border: 0;
|
||||
padding: 10px 16px; font-size: 12px; cursor: pointer; font-weight: 500;
|
||||
border-bottom: 2px solid transparent; transition: all .15s;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.tab:hover { color: #fff; }
|
||||
.tab.active { color: #fff; border-bottom-color: #4DBA9A; }
|
||||
.badge {
|
||||
background: rgba(255,255,255,0.15); color: #fff; padding: 1px 7px;
|
||||
border-radius: 10px; font-size: 10px; font-weight: 600;
|
||||
}
|
||||
.err-badge { background: #D9695C; }
|
||||
.actions { display: flex; gap: 6px; padding-right: 8px; }
|
||||
.btn-mini {
|
||||
background: rgba(255,255,255,0.1); color: #fff; border: 0;
|
||||
padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 11px;
|
||||
}
|
||||
.btn-mini:hover:not(:disabled) { background: rgba(255,255,255,0.2); }
|
||||
.btn-mini:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.list { overflow-y: auto; flex: 1; padding: 8px; }
|
||||
.list.empty-wrap { padding: 0; }
|
||||
.card-err {
|
||||
background: #FAFBFC; border: 1px solid #E1ECF2; border-radius: 8px;
|
||||
margin-bottom: 6px; padding: 8px 10px;
|
||||
}
|
||||
.row {
|
||||
display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
|
||||
}
|
||||
.ts { color: #8A9CAB; font-size: 11px; }
|
||||
.kind {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.kind.api { background: #FBE3DF; color: #A33B30; }
|
||||
.kind.vue { background: #FBEED9; color: #8B6510; }
|
||||
.kind.runtime { background: #DEF4EC; color: #2E8A6B; }
|
||||
.kind.promise { background: #E0F0FA; color: #1E5B8A; }
|
||||
.ttl { font-weight: 500; color: #0F2233; flex: 1; min-width: 0; }
|
||||
.status {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
.s-ok { background: #DEF4EC; color: #2E8A6B; }
|
||||
.s-warn { background: #FBEED9; color: #8B6510; }
|
||||
.s-err { background: #FBE3DF; color: #A33B30; }
|
||||
.s-net { background: #EEF2F5; color: #5A6F80; }
|
||||
.s-info { background: #E0F0FA; color: #1E5B8A; }
|
||||
.method {
|
||||
font-size: 10px; padding: 1px 6px; border-radius: 4px; font-weight: 600;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
.m-get { background: #E0F0FA; color: #1E5B8A; }
|
||||
.m-post { background: #DEF4EC; color: #2E8A6B; }
|
||||
.m-put { background: #FBEED9; color: #8B6510; }
|
||||
.m-delete { background: #FBE3DF; color: #A33B30; }
|
||||
.m-patch { background: #E0F0FA; color: #1E5B8A; }
|
||||
.url {
|
||||
font-size: 11px; color: #0F2233; flex: 1; min-width: 0;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
.mini {
|
||||
background: transparent; border: 1px solid #E1ECF2; color: #5A6F80;
|
||||
padding: 2px 8px; border-radius: 4px; cursor: pointer; font-size: 11px;
|
||||
}
|
||||
.mini:hover { background: #E0F0FA; color: #1E5B8A; }
|
||||
.mini.x:hover { background: #FBE3DF; color: #A33B30; }
|
||||
.detail {
|
||||
background: #F2F8FB; padding: 6px 8px; border-radius: 4px;
|
||||
margin: 6px 0 0; font-size: 11px; line-height: 1.5;
|
||||
white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow: auto;
|
||||
}
|
||||
.empty { padding: 32px; text-align: center; color: #8A9CAB; font-family: 'Outfit', system-ui; }
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.flash-toast {
|
||||
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%) translateY(20px);
|
||||
background: #0F2233; color: #fff; padding: 8px 18px; border-radius: 8px;
|
||||
font-size: 13px; opacity: 0; transition: all .3s; z-index: 10000;
|
||||
}
|
||||
.flash-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<!-- 桌面端:表格视图 -->
|
||||
<table v-if="!isMobile" class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-if="$slots.checkbox" class="check-col"></th>
|
||||
<th v-for="col in columns" :key="col.key" :class="col.thClass">
|
||||
{{ col.label }}
|
||||
</th>
|
||||
<th v-if="$slots.actions" class="actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(row, idx) in rows"
|
||||
:key="rowKey ? row[rowKey] : idx"
|
||||
:class="[rowClass, { selected: isSelected?.(row) }]"
|
||||
@click="onRowClick(row, $event)"
|
||||
>
|
||||
<td v-if="$slots.checkbox" class="check-col" @click.stop>
|
||||
<slot name="checkbox" :row="row" />
|
||||
</td>
|
||||
<td v-for="col in columns" :key="col.key" :class="col.tdClass" @click="onCellClick(row, col, $event)">
|
||||
<slot :name="`cell-${col.key}`" :row="row" :col="col">
|
||||
{{ col.formatter ? col.formatter(row[col.key], row) : row[col.key] }}
|
||||
</slot>
|
||||
</td>
|
||||
<td v-if="$slots.actions" class="row-actions" @click.stop>
|
||||
<slot name="actions" :row="row" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td :colspan="columns.length + (($slots.checkbox ? 1 : 0) + ($slots.actions ? 1 : 0))" class="text-mute" style="text-align:center; padding:32px">
|
||||
<slot name="empty">{{ emptyText }}</slot>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- 移动端:卡片视图 -->
|
||||
<div v-else class="card-list">
|
||||
<div
|
||||
v-for="(row, idx) in rows"
|
||||
:key="rowKey ? row[rowKey] : idx"
|
||||
class="card-item"
|
||||
:class="{ selected: isSelected?.(row), 'with-actions': $slots.actions }"
|
||||
@click="onRowClick(row, $event)"
|
||||
>
|
||||
<div v-if="$slots.checkbox" class="card-check" @click.stop>
|
||||
<slot name="checkbox" :row="row" />
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 主标题行(按 columns 第一个作为主) -->
|
||||
<div v-for="(col, ci) in columns" :key="col.key" class="card-row" :class="col.key === primaryKey ? 'primary' : 'secondary'">
|
||||
<span v-if="ci === 0 || col.key === primaryKey || col.alwaysShow" class="card-label">{{ col.label }}</span>
|
||||
<span class="card-value">
|
||||
<slot :name="`cell-${col.key}`" :row="row" :col="col">
|
||||
{{ col.formatter ? col.formatter(row[col.key], row) : row[col.key] }}
|
||||
</slot>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="card-actions" @click.stop>
|
||||
<slot name="actions" :row="row" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!rows.length" class="card-empty text-mute">
|
||||
<slot name="empty">{{ emptyText }}</slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* MobileCardList — 桌面端表格 / 移动端卡片 自动切换
|
||||
*
|
||||
* props:
|
||||
* columns: Array<{ key, label, thClass?, tdClass?, formatter?, alwaysShow? }>
|
||||
* - key: 字段名
|
||||
* - label: 列头 / 卡片小标签
|
||||
* - formatter: (value, row) => string
|
||||
* - alwaysShow: 卡片里也显示(默认 primary 显示,其余 hidden 防信息过载)
|
||||
* rows: Array
|
||||
* rowKey: 主键字段(默认用 index)
|
||||
* rowClass: 行 class
|
||||
* emptyText: 空状态文字
|
||||
* primaryKey: 卡片主行(默认第一列)
|
||||
* isSelected: (row) => boolean
|
||||
*
|
||||
* slots:
|
||||
* cell-{key}: 自定义单元格渲染
|
||||
* checkbox: 行内复选框(行首)
|
||||
* actions: 行内操作(行末)
|
||||
* empty: 空状态
|
||||
*/
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
columns: { type: Array, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
rowKey: { type: String, default: '' },
|
||||
rowClass: { type: String, default: '' },
|
||||
emptyText: { type: String, default: '暂无数据' },
|
||||
primaryKey: { type: String, default: '' },
|
||||
isSelected: { type: Function, default: null },
|
||||
clickable: { type: Boolean, default: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['row-click', 'cell-click']);
|
||||
|
||||
const windowWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440);
|
||||
const isMobile = computed(() => windowWidth.value < 768);
|
||||
|
||||
function onResize() {
|
||||
windowWidth.value = window.innerWidth;
|
||||
}
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', onResize, { passive: true });
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
});
|
||||
|
||||
function onRowClick(row, e) {
|
||||
if (!props.clickable) return;
|
||||
// 避免在 checkbox / actions 区域触发
|
||||
if (e.target.closest('.check-col, .row-actions, .card-check, .card-actions, a, button')) return;
|
||||
emit('row-click', row);
|
||||
}
|
||||
function onCellClick(row, col, e) {
|
||||
emit('cell-click', { row, col, event: e });
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table.data { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
table.data th, table.data td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
||||
table.data th { font-weight: 500; color: var(--text-soft); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
table.data tr:hover td { background: var(--bg-soft); }
|
||||
table.data tr:last-child td { border-bottom: 0; }
|
||||
.check-col { width: 36px; padding-left: 16px; padding-right: 0; }
|
||||
.row-actions { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
|
||||
|
||||
/* === 移动端:卡片 === */
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.card-item {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
padding: 14px 14px 12px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
transition: box-shadow .15s, transform .1s;
|
||||
position: relative;
|
||||
}
|
||||
.card-item:active { transform: scale(0.99); }
|
||||
.card-item.selected {
|
||||
background: linear-gradient(0deg, var(--bg-soft), var(--bg-soft)), var(--card);
|
||||
box-shadow: 0 0 0 2px var(--brand-soft), var(--card-shadow);
|
||||
}
|
||||
.card-check {
|
||||
padding-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
min-width: 0;
|
||||
}
|
||||
.card-row.primary {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.card-row.primary .card-label {
|
||||
display: none; /* 主行只显值,节省空间 */
|
||||
}
|
||||
.card-row.secondary {
|
||||
color: var(--text-soft);
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-label {
|
||||
color: var(--text-mute);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
min-width: 56px;
|
||||
}
|
||||
.card-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.card-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
align-self: center;
|
||||
}
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 767px) {
|
||||
/* 卡片样式下,按钮变得更易点 */
|
||||
.card-actions :deep(.btn) {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card-actions :deep(.btn-link) {
|
||||
padding: 6px 8px;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,218 @@
|
||||
<template>
|
||||
<div class="pwa-toast-stack" role="status" aria-live="polite">
|
||||
<!-- 新版本可用 -->
|
||||
<transition name="slide-up">
|
||||
<div v-if="pwa.needRefresh" class="pwa-toast pwa-toast--update" role="alert">
|
||||
<span class="pwa-toast__icon">🔄</span>
|
||||
<div class="pwa-toast__body">
|
||||
<strong>新版本可用</strong>
|
||||
<span>点击刷新以加载最新内容</span>
|
||||
</div>
|
||||
<button class="pwa-toast__btn pwa-toast__btn--primary" @click="pwa.applyUpdate()">刷新</button>
|
||||
<button class="pwa-toast__btn" @click="pwa.dismissNeedRefresh()" aria-label="稍后">×</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- 离线就绪 -->
|
||||
<transition name="slide-up">
|
||||
<div v-if="pwa.offlineReady" class="pwa-toast pwa-toast--offline">
|
||||
<span class="pwa-toast__icon">📦</span>
|
||||
<div class="pwa-toast__body">
|
||||
<strong>已可离线使用</strong>
|
||||
<span>无网络时仍能打开</span>
|
||||
</div>
|
||||
<button class="pwa-toast__btn" @click="pwa.dismissOfflineReady()" aria-label="知道了">×</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- Android/桌面安装引导 -->
|
||||
<transition name="slide-up">
|
||||
<div
|
||||
v-if="pwa.installPromptEvent && !pwa.isInstalled"
|
||||
class="pwa-toast pwa-toast--install"
|
||||
role="dialog"
|
||||
aria-label="安装应用"
|
||||
>
|
||||
<span class="pwa-toast__icon">⬇️</span>
|
||||
<div class="pwa-toast__body">
|
||||
<strong>安装 CarLog</strong>
|
||||
<span>添加到主屏幕,像 App 一样使用</span>
|
||||
</div>
|
||||
<button class="pwa-toast__btn pwa-toast__btn--primary" @click="onInstall">安装</button>
|
||||
<button class="pwa-toast__btn" @click="dismissInstall" aria-label="稍后">×</button>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<!-- iOS Safari 引导 -->
|
||||
<transition name="slide-up">
|
||||
<div
|
||||
v-if="showIosHint"
|
||||
class="pwa-toast pwa-toast--ios"
|
||||
role="dialog"
|
||||
aria-label="iOS 安装提示"
|
||||
>
|
||||
<span class="pwa-toast__icon">📱</span>
|
||||
<div class="pwa-toast__body">
|
||||
<strong>添加到主屏幕</strong>
|
||||
<span>点击底部分享 <span class="pwa-ios-share">⬆</span>,选「添加到主屏幕」</span>
|
||||
</div>
|
||||
<button class="pwa-toast__btn" @click="dismissIos" aria-label="知道了">×</button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||
import { usePwaStore } from '../stores/pwa';
|
||||
|
||||
const pwa = usePwaStore();
|
||||
|
||||
// 用 sessionStorage 标记这次会话已关过 iOS 提示,避免反复弹
|
||||
const IOS_HINT_KEY = 'pwa.iosHint.dismissed';
|
||||
const showIosHint = ref(false);
|
||||
const dismissed = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
// iOS Safari + 未安装 + 没关过 → 弹一次
|
||||
if (pwa.isIosSafari && !pwa.isInstalled) {
|
||||
dismissed.value = sessionStorage.getItem(IOS_HINT_KEY) === '1';
|
||||
showIosHint.value = !dismissed.value;
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {});
|
||||
|
||||
const canShowIos = computed(() => showIosHint.value && !dismissed.value);
|
||||
void canShowIos;
|
||||
|
||||
function dismissInstall() {
|
||||
// Pinia 自动解包 ref,直接赋值即可,不要用 .value
|
||||
// 原写法 pwa.installPromptEvent.value = null 会抛 TypeError(对 null)或静默无效(对 Event)
|
||||
pwa.installPromptEvent = null;
|
||||
}
|
||||
function dismissIos() {
|
||||
sessionStorage.setItem(IOS_HINT_KEY, '1');
|
||||
showIosHint.value = false;
|
||||
dismissed.value = true;
|
||||
}
|
||||
async function onInstall() {
|
||||
const accepted = await pwa.promptInstall();
|
||||
if (!accepted) {
|
||||
// 用户拒绝,3 天内不再弹
|
||||
const ts = Date.now();
|
||||
localStorage.setItem('pwa.install.dismissedAt', String(ts));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pwa-toast-stack {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(420px, calc(100vw - 24px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.pwa-toast {
|
||||
pointer-events: auto;
|
||||
background: var(--bg-elev, #fff);
|
||||
color: var(--text, #1f2937);
|
||||
border: 1px solid var(--border, #e5e7eb);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.18);
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.pwa-toast--update {
|
||||
border-color: var(--brand, #1b6ef3);
|
||||
}
|
||||
.pwa-toast--install {
|
||||
border-color: #10b981;
|
||||
}
|
||||
.pwa-toast__icon {
|
||||
font-size: 22px;
|
||||
flex: 0 0 22px;
|
||||
text-align: center;
|
||||
}
|
||||
.pwa-toast__body {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.pwa-toast__body strong {
|
||||
font-weight: 600;
|
||||
color: var(--text-strong, #111827);
|
||||
}
|
||||
.pwa-toast__body span {
|
||||
color: var(--text-soft, #6b7280);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.pwa-toast__btn {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-soft, #6b7280);
|
||||
font-size: 18px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
flex: 0 0 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
.pwa-toast__btn:hover {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.pwa-toast__btn--primary {
|
||||
background: var(--brand, #1b6ef3);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
width: auto;
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.pwa-toast__btn--primary:hover {
|
||||
background: #1858c4;
|
||||
}
|
||||
.pwa-ios-share {
|
||||
display: inline-block;
|
||||
transform: translateY(1px);
|
||||
}
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
}
|
||||
.slide-up-enter-from,
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.pwa-toast {
|
||||
background: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #f3f4f6;
|
||||
}
|
||||
.pwa-toast__body strong {
|
||||
color: #f9fafb;
|
||||
}
|
||||
.pwa-toast__body span {
|
||||
color: #9ca3af;
|
||||
}
|
||||
.pwa-toast__btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div class="stat-card card">
|
||||
<div class="head">
|
||||
<span class="label">{{ title }}</span>
|
||||
<span v-if="icon" class="icon">{{ icon }}</span>
|
||||
</div>
|
||||
<div class="value">{{ value }}</div>
|
||||
<div v-if="hint" class="hint" :class="trendClass">{{ hint }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
const props = defineProps({
|
||||
title: String, value: [String, Number], hint: String,
|
||||
icon: { type: String, default: '' },
|
||||
trend: { type: String, default: 'neutral' }, // up/down/neutral
|
||||
});
|
||||
const trendClass = computed(() => ({ up: 'text-green', down: 'text-danger' }[props.trend] || 'text-mute'));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-card { padding: 20px 22px; }
|
||||
.head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.label { color: var(--text-soft); font-size: 13px; }
|
||||
.icon { font-size: 18px; opacity: .8; }
|
||||
.value { font-size: 28px; font-weight: 600; margin-top: 8px; letter-spacing: -0.02em; }
|
||||
.hint { font-size: 12px; margin-top: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,125 @@
|
||||
// client/src/composables/useAiRecognize.js
|
||||
// 通用 AI 截图识别 composable — 5 个表单复用
|
||||
// 用法:
|
||||
// const ai = useAiRecognize();
|
||||
// <button @click="onAiRecognize" :disabled="ai.busy.value">📷 AI 识别</button>
|
||||
// <AiFallbackModal
|
||||
// :show="ai.showFallback.value"
|
||||
// :image-url="ai.fallback.value?.preview_url"
|
||||
// @cancel="ai.cancelFallback()"
|
||||
// @confirm="onManualConfirm"
|
||||
// >
|
||||
// <!-- 这里放手动填表字段 -->
|
||||
// </AiFallbackModal>
|
||||
//
|
||||
// 调用流程:
|
||||
// open(type, onSuccess) — 成功 → onSuccess(data)
|
||||
// — 失败 → 打开 AiFallbackModal(不再 alert)
|
||||
//
|
||||
// 兜底数据:fallback.value = { image_id, preview_url, type, error }
|
||||
// 调用方在 @confirm 里读 fallback.value 并把它当作成功数据使用(手动填的字段)即可。
|
||||
|
||||
import { ref } from 'vue';
|
||||
import * as aiApi from '../api/ai';
|
||||
|
||||
export function useAiRecognize() {
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
const showFallback = ref(false);
|
||||
const fallback = ref(null); // { image_id, preview_url, type, error }
|
||||
|
||||
function pickFile() {
|
||||
return new Promise((resolve) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e) => resolve(e.target.files[0] || null);
|
||||
input.click();
|
||||
});
|
||||
}
|
||||
|
||||
function previewUrlFor(imageId) {
|
||||
// 上传目录固定在 /api/uploads/ai/
|
||||
return `/api/uploads/ai/${imageId}`;
|
||||
}
|
||||
|
||||
async function open(type, onSuccess) {
|
||||
error.value = '';
|
||||
fallback.value = null;
|
||||
showFallback.value = false;
|
||||
const file = await pickFile();
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
let uploadedId = null;
|
||||
let uploadedUrl = null;
|
||||
try {
|
||||
// 1) 上传
|
||||
const upR = await aiApi.uploadImage(file);
|
||||
uploadedId = upR.data.image_id;
|
||||
uploadedUrl = upR.data.url || previewUrlFor(uploadedId);
|
||||
// 2) 识别
|
||||
const recR = await aiApi.recognize(uploadedId, type);
|
||||
const data = recR.data.data || {};
|
||||
// 3) 回调
|
||||
onSuccess?.(data, recR.data);
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.error?.message || e.message;
|
||||
error.value = msg;
|
||||
if (uploadedId) {
|
||||
// 至少上传成功了,弹出兜底 modal 让用户对着图填
|
||||
fallback.value = {
|
||||
image_id: uploadedId,
|
||||
preview_url: uploadedUrl,
|
||||
type,
|
||||
error: msg,
|
||||
};
|
||||
showFallback.value = true;
|
||||
} else {
|
||||
// 上传就挂了 — 只能 alert
|
||||
const isConfig = msg.includes('未配置 AI API key') || msg.includes('请先');
|
||||
alert(
|
||||
'AI 识别失败:' + msg + (isConfig ? '' :
|
||||
'\n\n可能原因:\n1. AI API key 未配置或无效(设置 → AI 截图识别)\n2. 网络无法访问 AI provider\n3. 文件过大或格式不支持'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function recognizeFromFile(file, type, onSuccess) {
|
||||
error.value = '';
|
||||
fallback.value = null;
|
||||
showFallback.value = false;
|
||||
if (!file) return;
|
||||
busy.value = true;
|
||||
let uploadedId = null;
|
||||
let uploadedUrl = null;
|
||||
try {
|
||||
const upR = await aiApi.uploadImage(file);
|
||||
uploadedId = upR.data.image_id;
|
||||
uploadedUrl = upR.data.url || previewUrlFor(uploadedId);
|
||||
const recR = await aiApi.recognize(uploadedId, type);
|
||||
const data = recR.data.data || {};
|
||||
onSuccess?.(data, recR.data);
|
||||
} catch (e) {
|
||||
const msg = e.response?.data?.error?.message || e.message;
|
||||
error.value = msg;
|
||||
if (uploadedId) {
|
||||
fallback.value = { image_id: uploadedId, preview_url: uploadedUrl, type, error: msg };
|
||||
showFallback.value = true;
|
||||
} else {
|
||||
alert('AI 识别失败:' + msg);
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelFallback() {
|
||||
showFallback.value = false;
|
||||
fallback.value = null;
|
||||
}
|
||||
|
||||
return { open, recognizeFromFile, busy, error, showFallback, fallback, cancelFallback };
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// client/src/main.js — 入口
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import { useDebugStore } from './stores/debug';
|
||||
import { usePwaStore } from './stores/pwa';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import './style.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
// PWA Service Worker 注册
|
||||
if ('serviceWorker' in navigator) {
|
||||
const pwa = usePwaStore();
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() {
|
||||
console.info('[PWA] 新版本可用');
|
||||
pwa.triggerNeedRefresh();
|
||||
},
|
||||
onOfflineReady() {
|
||||
console.info('[PWA] 离线缓存就绪');
|
||||
pwa.triggerOfflineReady();
|
||||
},
|
||||
onRegisterError(err) {
|
||||
console.warn('[PWA] SW 注册失败', err);
|
||||
},
|
||||
});
|
||||
pwa.bindRegisterSw(updateSW);
|
||||
// 暴露到 window 方便调试 / 强制更新
|
||||
window.__pwaUpdate = () => updateSW(true);
|
||||
}
|
||||
|
||||
// 全局错误捕获 → 调试面板
|
||||
app.config.errorHandler = (err, instance, info) => {
|
||||
const debug = useDebugStore();
|
||||
debug.log({
|
||||
kind: 'vue',
|
||||
title: `[${info}] ${err?.message || err}`,
|
||||
detail: {
|
||||
message: err?.message,
|
||||
stack: err?.stack,
|
||||
info,
|
||||
component: instance?.$options?.name || instance?.$options?.__name || '<anonymous>',
|
||||
},
|
||||
});
|
||||
console.error('[vue error]', err, info);
|
||||
};
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const debug = useDebugStore();
|
||||
debug.log({
|
||||
kind: 'promise',
|
||||
title: `未捕获的 Promise 异常: ${e.reason?.message || e.reason}`,
|
||||
detail: {
|
||||
message: e.reason?.message || String(e.reason),
|
||||
stack: e.reason?.stack,
|
||||
},
|
||||
});
|
||||
console.error('[unhandledrejection]', e.reason);
|
||||
});
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
const debug = useDebugStore();
|
||||
if (e.error) {
|
||||
debug.log({
|
||||
kind: 'runtime',
|
||||
title: `全局错误: ${e.message}`,
|
||||
detail: {
|
||||
message: e.message,
|
||||
filename: e.filename,
|
||||
lineno: e.lineno,
|
||||
colno: e.colno,
|
||||
stack: e.error?.stack,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.mount('#app');
|
||||
@@ -0,0 +1,69 @@
|
||||
// client/src/router/index.js — 路由 + 守卫(路由级 code-split)
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
// 路由级别懒加载 → 每个 view 独立 chunk,首屏只下载 Home + Login
|
||||
const Login = () => import(/* webpackChunkName: "v-login" */ '../views/Login.vue');
|
||||
const Home = () => import(/* webpackChunkName: "v-home" */ '../views/Home.vue');
|
||||
const Offline = () => import(/* webpackChunkName: "v-offline" */ '../views/Offline.vue');
|
||||
const WashesList = () => import('../views/WashesList.vue');
|
||||
const WashNew = () => import('../views/WashNew.vue');
|
||||
const WashShow = () => import('../views/WashShow.vue');
|
||||
const ChemicalsList = () => import('../views/ChemicalsList.vue');
|
||||
const ChemicalNew = () => import('../views/ChemicalNew.vue');
|
||||
const BatchPurchase = () => import('../views/BatchPurchase.vue');
|
||||
const ChemicalDetail = () => import('../views/ChemicalDetail.vue');
|
||||
const VehiclesList = () => import('../views/VehiclesList.vue');
|
||||
const VehicleForm = () => import('../views/VehicleForm.vue');
|
||||
const VehicleDetail = () => import('../views/VehicleDetail.vue');
|
||||
const MaintenanceList = () => import('../views/MaintenanceList.vue');
|
||||
const RefuelList = () => import('../views/RefuelList.vue');
|
||||
const ChargingList = () => import('../views/ChargingList.vue');
|
||||
const InsuranceList = () => import('../views/InsuranceList.vue');
|
||||
const Stats = () => import('../views/Stats.vue');
|
||||
const Settings = () => import('../views/Settings.vue');
|
||||
const OperationLogs = () => import('../views/OperationLogs.vue');
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', name: 'login', component: Login, meta: { public: true } },
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/washes', name: 'washes', component: WashesList },
|
||||
{ path: '/washes/new', name: 'wash-new', component: WashNew },
|
||||
{ path: '/washes/:id', name: 'wash-show', component: WashShow },
|
||||
{ path: '/chemicals', name: 'chemicals', component: ChemicalsList },
|
||||
{ path: '/chemicals/new', name: 'chemical-new', component: ChemicalNew },
|
||||
{ path: '/chemicals/purchase', name: 'chemical-purchase', component: BatchPurchase },
|
||||
{ path: '/chemicals/:id', name: 'chemical-show', component: ChemicalDetail },
|
||||
{ path: '/vehicles', name: 'vehicles', component: VehiclesList },
|
||||
{ path: '/vehicles/new', name: 'vehicle-new', component: VehicleForm },
|
||||
{ path: '/vehicles/:id', name: 'vehicle-show', component: VehicleDetail },
|
||||
{ path: '/vehicles/:id/edit', name: 'vehicle-edit', component: VehicleForm },
|
||||
{ path: '/maintenances', name: 'maintenances', component: MaintenanceList },
|
||||
{ path: '/refuels', name: 'refuels', component: RefuelList },
|
||||
{ path: '/chargings', name: 'chargings', component: ChargingList },
|
||||
{ path: '/insurances', name: 'insurances', component: InsuranceList },
|
||||
{ path: '/stats', name: 'stats', component: Stats },
|
||||
{ path: '/settings', name: 'settings', component: Settings },
|
||||
{ path: '/operation-logs', name: 'operation-logs', component: OperationLogs },
|
||||
{ path: '/offline', name: 'offline', component: Offline, meta: { public: true } },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
];
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior: () => ({ top: 0 }),
|
||||
});
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
const auth = useAuthStore();
|
||||
if (!auth.bootstrapped) await auth.refresh();
|
||||
if (!to.meta.public && !auth.user) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } };
|
||||
}
|
||||
if (to.name === 'login' && auth.user) {
|
||||
return { name: 'home' };
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,44 @@
|
||||
// client/src/stores/auth.js
|
||||
import { defineStore } from 'pinia';
|
||||
import * as authApi from '../api/auth';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: null,
|
||||
csrfToken: '',
|
||||
bootstrapped: false,
|
||||
}),
|
||||
actions: {
|
||||
async refresh() {
|
||||
try {
|
||||
const me = await authApi.me();
|
||||
this.user = me.data?.user || me.data;
|
||||
if (!this.user) throw new Error('no user');
|
||||
} catch {
|
||||
this.user = null;
|
||||
}
|
||||
await this.refreshCsrf();
|
||||
this.bootstrapped = true;
|
||||
},
|
||||
async refreshCsrf() {
|
||||
try {
|
||||
const r = await authApi.csrf();
|
||||
this.csrfToken = r.data?.csrf_token || '';
|
||||
} catch {
|
||||
this.csrfToken = '';
|
||||
}
|
||||
},
|
||||
async login(username, password) {
|
||||
const r = await authApi.login(username, password);
|
||||
this.user = r.data?.user || null;
|
||||
await this.refreshCsrf();
|
||||
return r.data;
|
||||
},
|
||||
async logout() {
|
||||
try { await authApi.logout(); } catch {}
|
||||
this.user = null;
|
||||
this.csrfToken = '';
|
||||
},
|
||||
clear() { this.user = null; },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// client/src/stores/debug.js — 调试模式 + 错误/调用日志
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const LS_KEY = 'carwash:debug';
|
||||
|
||||
function loadLS() {
|
||||
try { return localStorage.getItem(LS_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
function saveLS(v) { try { localStorage.setItem(LS_KEY, v ? '1' : '0'); } catch {} }
|
||||
|
||||
export const useDebugStore = defineStore('debug', {
|
||||
state: () => ({
|
||||
enabled: loadLS(),
|
||||
errors: [], // { id, ts, kind, title, detail }
|
||||
calls: [], // { id, ts, method, url, status, ms, body }
|
||||
collapsed: false,
|
||||
tab: 'errors', // 'errors' | 'calls'
|
||||
}),
|
||||
getters: {
|
||||
count: (s) => s.errors.length,
|
||||
callCount: (s) => s.calls.length,
|
||||
latest: (s) => s.errors[s.errors.length - 1],
|
||||
},
|
||||
actions: {
|
||||
toggle() {
|
||||
this.enabled = !this.enabled;
|
||||
saveLS(this.enabled);
|
||||
},
|
||||
set(v) {
|
||||
this.enabled = !!v;
|
||||
saveLS(this.enabled);
|
||||
},
|
||||
log(err) {
|
||||
if (!this.enabled) return;
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
this.errors.push({
|
||||
id,
|
||||
ts: new Date().toISOString(),
|
||||
kind: err.kind || 'error',
|
||||
title: err.title || '未知错误',
|
||||
detail: err.detail || {},
|
||||
});
|
||||
if (this.errors.length > 50) this.errors = this.errors.slice(-50);
|
||||
this.collapsed = false;
|
||||
},
|
||||
logCall(call) {
|
||||
if (!this.enabled) return;
|
||||
this.calls.push({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
ts: new Date().toISOString(),
|
||||
...call,
|
||||
});
|
||||
if (this.calls.length > 80) this.calls = this.calls.slice(-80);
|
||||
},
|
||||
clear() { this.errors = []; this.calls = []; },
|
||||
setTab(t) { this.tab = t; },
|
||||
togglePanel() { this.collapsed = !this.collapsed; },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// client/src/stores/pwa.js — PWA 状态:更新提示 / 安装提示 / 离线
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const usePwaStore = defineStore('pwa', () => {
|
||||
/** true = 已有新版本 SW 等激活,需要刷新 */
|
||||
const needRefresh = ref(false);
|
||||
/** true = 资源已缓存完,可离线用 */
|
||||
const offlineReady = ref(false);
|
||||
/** 浏览器/桌面触发安装的事件(Android/Desktop Chrome) */
|
||||
const installPromptEvent = ref(null);
|
||||
/** 已安装(standalone 模式运行) */
|
||||
const isInstalled = computed(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return (
|
||||
window.matchMedia('(display-mode: standalone)').matches ||
|
||||
window.navigator.standalone === true // iOS Safari
|
||||
);
|
||||
});
|
||||
/** iOS 设备 + Safari + 未安装 → 引导走"分享 → 添加到主屏幕" */
|
||||
const isIosSafari = computed(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const ua = window.navigator.userAgent;
|
||||
return /iPad|iPhone|iPod/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS|EdgiOS/.test(ua);
|
||||
});
|
||||
|
||||
let updateFn = null;
|
||||
|
||||
function bindRegisterSw(registerFn) {
|
||||
updateFn = registerFn;
|
||||
}
|
||||
|
||||
function triggerNeedRefresh() {
|
||||
needRefresh.value = true;
|
||||
}
|
||||
function triggerOfflineReady() {
|
||||
offlineReady.value = true;
|
||||
// 5s 后自动收起
|
||||
setTimeout(() => (offlineReady.value = false), 5000);
|
||||
}
|
||||
async function applyUpdate() {
|
||||
if (updateFn) {
|
||||
await updateFn(true);
|
||||
needRefresh.value = false;
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
function dismissNeedRefresh() {
|
||||
needRefresh.value = false;
|
||||
}
|
||||
function dismissOfflineReady() {
|
||||
offlineReady.value = false;
|
||||
}
|
||||
async function promptInstall() {
|
||||
const e = installPromptEvent.value;
|
||||
if (!e) return false;
|
||||
e.prompt();
|
||||
const choice = await e.userChoice;
|
||||
installPromptEvent.value = null;
|
||||
return choice.outcome === 'accepted';
|
||||
}
|
||||
function captureInstallPrompt(e) {
|
||||
e.preventDefault();
|
||||
installPromptEvent.value = e;
|
||||
}
|
||||
// 全局监听 beforeinstallprompt
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeinstallprompt', captureInstallPrompt);
|
||||
window.addEventListener('appinstalled', () => {
|
||||
installPromptEvent.value = null;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
needRefresh,
|
||||
offlineReady,
|
||||
installPromptEvent,
|
||||
isInstalled,
|
||||
isIosSafari,
|
||||
bindRegisterSw,
|
||||
triggerNeedRefresh,
|
||||
triggerOfflineReady,
|
||||
applyUpdate,
|
||||
dismissNeedRefresh,
|
||||
dismissOfflineReady,
|
||||
promptInstall,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
/* client/src/style.css — EstateHub 设计令牌 + 全局样式 */
|
||||
:root {
|
||||
--bg: #E8F4F9;
|
||||
--bg-soft: #F2F8FB;
|
||||
--card: #FFFFFF;
|
||||
--card-shadow: 0 2px 8px rgba(40, 80, 110, 0.06);
|
||||
--card-shadow-hover: 0 4px 16px rgba(40, 80, 110, 0.10);
|
||||
--text: #0F2233;
|
||||
--text-soft: #5A6F80;
|
||||
--text-mute: #8A9CAB;
|
||||
--line: #E1ECF2;
|
||||
--accent: #0F2233;
|
||||
--accent-soft: #1A3A55;
|
||||
--brand: #1E5B8A;
|
||||
--brand-soft: #2C7AB0;
|
||||
--green: #4DBA9A;
|
||||
--green-soft: #7CD0B5;
|
||||
--warn: #E8A33D;
|
||||
--danger: #D9695C;
|
||||
--info: #5AA8D8;
|
||||
--radius: 14px;
|
||||
--radius-sm: 8px;
|
||||
--radius-lg: 22px;
|
||||
--pill: 999px;
|
||||
--font: 'Outfit', system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
|
||||
/* === 响应式断点(4 档)===
|
||||
* --bp-sm: 小屏手机 (< 480px) → 极简布局
|
||||
* --bp-md: 大屏手机/小平板 (480~767) → 紧凑布局
|
||||
* --bp-lg: 平板 (768~1023) → 双列/抽屉式
|
||||
* --bp-xl: 桌面 (1024~1439) → 标准布局
|
||||
* --bp-2xl: 大屏桌面 (≥1440) → 宽屏布局
|
||||
*/
|
||||
--bp-sm: 480px;
|
||||
--bp-md: 768px;
|
||||
--bp-lg: 1024px;
|
||||
--bp-xl: 1440px;
|
||||
|
||||
/* iOS 安全区 + Android 导航条适配 */
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-left: env(safe-area-inset-left, 0px);
|
||||
--safe-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-feature-settings: 'ss01', 'cv11';
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { font-family: inherit; cursor: pointer; }
|
||||
|
||||
/* === 工具类 === */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: var(--radius-sm);
|
||||
font-size: 14px; font-weight: 500; border: 0; transition: all .15s;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--accent-soft); }
|
||||
.btn-ghost { background: transparent; color: var(--text); border: 1px solid var(--line); }
|
||||
.btn-ghost:hover { background: var(--bg-soft); }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition: box-shadow .2s;
|
||||
}
|
||||
.card:hover { box-shadow: var(--card-shadow-hover); }
|
||||
.card-pad { padding: 24px; }
|
||||
|
||||
.input, .select, .textarea {
|
||||
width: 100%; padding: 10px 14px;
|
||||
border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||
font-size: 14px; font-family: inherit; background: #fff; color: var(--text);
|
||||
transition: border .15s;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus { outline: 0; border-color: var(--brand-soft); }
|
||||
.textarea { resize: vertical; min-height: 80px; }
|
||||
.label { display: block; font-size: 13px; color: var(--text-soft); margin-bottom: 6px; font-weight: 500; }
|
||||
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 10px; border-radius: var(--pill);
|
||||
font-size: 12px; font-weight: 500;
|
||||
}
|
||||
.pill-blue { background: #E0F0FA; color: var(--brand); }
|
||||
.pill-green { background: #DEF4EC; color: #2E8A6B; }
|
||||
.pill-warn { background: #FBEED9; color: #8B6510; }
|
||||
.pill-danger { background: #FBE3DF; color: #A33B30; }
|
||||
.pill-gray { background: #EEF2F5; color: var(--text-soft); }
|
||||
|
||||
.text-soft { color: var(--text-soft); }
|
||||
.text-mute { color: var(--text-mute); }
|
||||
.text-green { color: var(--green); }
|
||||
.text-danger { color: var(--danger); }
|
||||
.text-brand { color: var(--brand); }
|
||||
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-2 { gap: 8px; }
|
||||
.gap-3 { gap: 12px; }
|
||||
.gap-4 { gap: 16px; }
|
||||
.gap-6 { gap: 24px; }
|
||||
.mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; } .mt-6 { margin-top: 24px; }
|
||||
.mb-2 { margin-bottom: 8px; } .mb-3 { margin-bottom: 12px; } .mb-4 { margin-bottom: 16px; } .mb-6 { margin-bottom: 24px; }
|
||||
|
||||
table.data { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||||
table.data th, table.data td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
||||
table.data th { font-weight: 500; color: var(--text-soft); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
table.data tr:hover td { background: var(--bg-soft); }
|
||||
table.data tr:last-child td { border-bottom: 0; }
|
||||
|
||||
/* 渐入动画 */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* === 移动端全局响应式工具 === */
|
||||
.mobile-only { display: none; }
|
||||
.desktop-only { display: inline-flex; }
|
||||
@media (max-width: 767px) {
|
||||
.mobile-only { display: inline-flex; }
|
||||
.desktop-only { display: none; }
|
||||
}
|
||||
|
||||
/* === 移动端表格兜底:未转 MobileCardList 的 <table> 横向滚动 === */
|
||||
@media (max-width: 767px) {
|
||||
.card > table.data,
|
||||
.card-pad > table.data {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
white-space: nowrap;
|
||||
}
|
||||
table.data th, table.data td { font-size: 13px; padding: 8px 10px; }
|
||||
/* 标记移动端可滚动提示 */
|
||||
.card:has(> table.data[role="scroll"]) { position: relative; }
|
||||
}
|
||||
|
||||
/* === 移动端全局输入优化(防 iOS 缩放) === */
|
||||
@media (max-width: 767px) {
|
||||
input, select, textarea { font-size: 16px; }
|
||||
.input, .select, .textarea { padding: 10px 12px; }
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// client/src/utils/formDraft.js — 表单草稿暂存(sessionStorage 版)
|
||||
// 用途:session 过期被 401 拦截器打回登录页时,把用户填的表单数据存住,
|
||||
// 登录成功回到原页后能恢复,不丢工。
|
||||
//
|
||||
// 用法(推荐,5 行接入):
|
||||
// const draft = useFormDraft('washes/new');
|
||||
// const restored = draft.load();
|
||||
// if (restored) Object.assign(form, restored); // 恢复草稿
|
||||
// watch(form, (v) => draft.save(v), { deep: true }); // 自动保存
|
||||
// await onSubmit();
|
||||
// draft.clear(); // 提交成功清掉
|
||||
//
|
||||
// 401 触发:window.dispatchEvent(new CustomEvent('form-draft:flush-all'))
|
||||
// ↓ 监听器把每个已注册草稿的 latest 立即写盘
|
||||
// ↓ 然后 axios 拦截器跳 /login
|
||||
// ↓ 登录成功回到原页 → 组件 mount → load() 恢复
|
||||
|
||||
const PREFIX = 'formDraft:';
|
||||
const DEBOUNCE_MS = 250;
|
||||
|
||||
function key(name) { return PREFIX + name; }
|
||||
|
||||
export function useFormDraft(name) {
|
||||
let latest = null;
|
||||
let timer = null;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(key(name));
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
function saveNow(value) {
|
||||
try { sessionStorage.setItem(key(name), JSON.stringify(value)); } catch {}
|
||||
}
|
||||
|
||||
function save(value) {
|
||||
latest = value;
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => saveNow(value), DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
if (latest !== null) saveNow(latest);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (timer) { clearTimeout(timer); timer = null; }
|
||||
latest = null;
|
||||
try { sessionStorage.removeItem(key(name)); } catch {}
|
||||
}
|
||||
|
||||
return { load, save, flush, clear };
|
||||
}
|
||||
|
||||
/* ----- 全局 flush 注册(401 用)----- */
|
||||
const registered = new Set();
|
||||
let listenerInstalled = false;
|
||||
function installListener() {
|
||||
if (listenerInstalled) return;
|
||||
listenerInstalled = true;
|
||||
window.addEventListener('form-draft:flush-all', () => {
|
||||
for (const fn of registered) {
|
||||
try { fn(); } catch {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 让一个草稿实例参与全局 flush:返回 unregister 函数 */
|
||||
export function registerDraftForFlush(flushFn) {
|
||||
installListener();
|
||||
registered.add(flushFn);
|
||||
return () => registered.delete(flushFn);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">批量采购入库</h1>
|
||||
<p class="subtitle text-soft">一次性往 Grocy 入库多个产品(采购/自制/盘点)</p>
|
||||
</div>
|
||||
<router-link to="/chemicals" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad">
|
||||
<div class="toolbar">
|
||||
<input v-model="search" type="text" class="input search-input" placeholder="搜产品名 / ID 添加到清单…" />
|
||||
<span class="text-mute sm">{{ filteredProducts.length }} 个产品可选</span>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="picker">
|
||||
<h3 class="section-title">产品列表</h3>
|
||||
<div class="prod-list">
|
||||
<button
|
||||
v-for="p in filteredProducts"
|
||||
:key="p.grocy_product_id"
|
||||
class="prod-item"
|
||||
:class="{ active: inCart(p.grocy_product_id) }"
|
||||
@click="addToCart(p)"
|
||||
:disabled="inCart(p.grocy_product_id)"
|
||||
>
|
||||
<span class="prod-id">#{{ p.grocy_product_id }}</span>
|
||||
<span class="prod-name">{{ p.name }}</span>
|
||||
<span class="prod-meta text-mute sm">{{ p.category_display || '—' }} · 当前 {{ p.current_amount }} {{ p.unit || '' }}</span>
|
||||
<span v-if="inCart(p.grocy_product_id)" class="text-mute sm">已加入</span>
|
||||
<span v-else class="text-brand sm">+ 加入</span>
|
||||
</button>
|
||||
<div v-if="!filteredProducts.length" class="empty">没匹配的产品</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cart">
|
||||
<h3 class="section-title">采购清单 ({{ cart.length }} 项)</h3>
|
||||
<div v-if="!cart.length" class="empty">左侧点击产品加入清单</div>
|
||||
<div v-else class="cart-list">
|
||||
<div v-for="(item, idx) in cart" :key="item.grocy_product_id" class="cart-item">
|
||||
<div class="ci-head">
|
||||
<strong>{{ item.name }}</strong>
|
||||
<button class="ci-x" @click="cart.splice(idx, 1)">×</button>
|
||||
</div>
|
||||
<div class="grid3">
|
||||
<div>
|
||||
<label class="label">数量 *</label>
|
||||
<input v-model.number="item.amount" type="number" step="0.01" min="0.01" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">单价 ¥</label>
|
||||
<input v-model.number="item.price" type="number" step="0.01" min="0" class="input" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">类型</label>
|
||||
<select v-model="item.transaction_type" class="select">
|
||||
<option value="purchase">采购</option>
|
||||
<option value="self_production">自制</option>
|
||||
<option value="inventory">盘点</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div>
|
||||
<label class="label">最佳赏味期</label>
|
||||
<input v-model="item.best_before_date" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">备注</label>
|
||||
<input v-model="item.note" class="input" placeholder="供应商、单号等" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="cart.length" class="actions">
|
||||
<span class="text-mute">共 {{ cart.length }} 项 · 总额 ¥{{ totalCost.toFixed(2) }}</span>
|
||||
<button class="btn btn-primary" :disabled="busy || !hasValid" @click="onSubmit">
|
||||
{{ busy ? '提交中…' : '批量入库 → Grocy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="results" class="card card-pad mt-4">
|
||||
<h3 class="section-title">执行结果</h3>
|
||||
<table class="data">
|
||||
<thead><tr><th>产品</th><th>数量</th><th>Grocy</th><th>本地</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in results" :key="r.grocy_product_id">
|
||||
<td>{{ r.name }}</td>
|
||||
<td>{{ r.amount }} {{ r.unit || '' }}</td>
|
||||
<td>
|
||||
<span v-if="r.grocy === 'ok'" class="pill pill-green">✓ 已扣减</span>
|
||||
<span v-else class="pill pill-danger">✗ {{ r.grocy_error }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.local === 'queued'" class="pill pill-warn">排队中</span>
|
||||
<span v-else-if="r.local === 'synced'" class="pill pill-green">已同步</span>
|
||||
<span v-else-if="r.local === 'failed'" class="pill pill-danger">失败</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, reactive, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import * as chemicalsApi from '../api/chemicals';
|
||||
import { asArray } from '../api/client';
|
||||
const router = useRouter();
|
||||
|
||||
const products = ref([]);
|
||||
const search = ref('');
|
||||
const cart = ref([]);
|
||||
const busy = ref(false);
|
||||
const results = ref(null);
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
if (!search.value) return products.value.slice(0, 30);
|
||||
const q = search.value.toLowerCase();
|
||||
return products.value.filter(p =>
|
||||
(p.name || '').toLowerCase().includes(q) ||
|
||||
String(p.grocy_product_id).includes(q)
|
||||
).slice(0, 50);
|
||||
});
|
||||
|
||||
const hasValid = computed(() => cart.value.some(c => c.amount > 0));
|
||||
const totalCost = computed(() => cart.value.reduce((a, c) => a + (Number(c.amount) || 0) * (Number(c.price) || 0), 0));
|
||||
|
||||
function inCart(id) { return cart.value.some(c => c.grocy_product_id === String(id)); }
|
||||
|
||||
function addToCart(p) {
|
||||
if (inCart(p.grocy_product_id)) return;
|
||||
cart.value.push({
|
||||
grocy_product_id: p.grocy_product_id,
|
||||
name: p.name,
|
||||
unit: p.unit,
|
||||
amount: 1,
|
||||
price: 0,
|
||||
best_before_date: '',
|
||||
transaction_type: 'purchase',
|
||||
note: '',
|
||||
grocy: null, // 提交后状态
|
||||
local: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!hasValid.value) return;
|
||||
busy.value = true;
|
||||
results.value = null;
|
||||
const items = cart.value.filter(c => c.amount > 0);
|
||||
const r = [];
|
||||
for (const it of items) {
|
||||
try {
|
||||
await chemicalsApi.addStock(it.grocy_product_id, {
|
||||
amount: it.amount,
|
||||
price: it.price,
|
||||
best_before_date: it.best_before_date || null,
|
||||
transaction_type: it.transaction_type,
|
||||
note: it.note || null,
|
||||
});
|
||||
r.push({ ...it, grocy: 'ok', local: 'queued', grocy_error: null });
|
||||
} catch (e) {
|
||||
r.push({ ...it, grocy: 'fail', local: 'failed', grocy_error: e.response?.data?.message || e.message });
|
||||
}
|
||||
}
|
||||
results.value = r;
|
||||
busy.value = false;
|
||||
// 等几秒让后台同步跑完,刷新本地缓存
|
||||
setTimeout(async () => {
|
||||
try { await chemicalsApi.sync(); } catch {}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const r = await chemicalsApi.list();
|
||||
products.value = asArray(r.data, 'chemicals');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.toolbar { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; }
|
||||
.search-input { max-width: 360px; }
|
||||
.sm { font-size: 12px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 24px; }
|
||||
.section-title { font-size: 13px; font-weight: 600; color: var(--text-soft); margin: 0 0 10px; }
|
||||
.prod-list { max-height: 480px; overflow-y: auto; border: 1px solid var(--line); border-radius: var(--radius-sm); }
|
||||
.prod-item {
|
||||
width: 100%; text-align: left; background: transparent; border: 0;
|
||||
border-bottom: 1px solid var(--line); padding: 8px 12px; cursor: pointer;
|
||||
display: grid; grid-template-columns: 50px 1fr auto; gap: 4px 8px; align-items: center;
|
||||
transition: background .1s;
|
||||
}
|
||||
.prod-item:last-child { border-bottom: 0; }
|
||||
.prod-item:hover:not(:disabled) { background: var(--bg-soft); }
|
||||
.prod-item.active { background: var(--bg-soft); }
|
||||
.prod-item:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.prod-id { color: var(--text-mute); font-size: 11px; font-family: monospace; grid-row: 1 / 3; }
|
||||
.prod-name { font-size: 13px; font-weight: 500; grid-column: 2 / 3; grid-row: 1; }
|
||||
.prod-meta { grid-column: 2 / 3; grid-row: 2; }
|
||||
.prod-item > :nth-child(4) { grid-row: 1 / 3; grid-column: 3; }
|
||||
|
||||
.cart-list { max-height: 480px; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding-right: 4px; }
|
||||
.cart-item {
|
||||
background: var(--bg-soft); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.ci-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.ci-x {
|
||||
background: transparent; border: 1px solid var(--line); color: var(--text-soft);
|
||||
width: 24px; height: 24px; border-radius: 4px; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.ci-x:hover { background: #FBE3DF; color: var(--danger); border-color: var(--danger); }
|
||||
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1.5fr; gap: 8px; margin-top: 6px; }
|
||||
.empty { padding: 24px; text-align: center; color: var(--text-mute); font-size: 13px; }
|
||||
.actions {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--line);
|
||||
}
|
||||
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } .grid3, .grid2 { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head .actions { display: flex; flex-direction: column; width: 100%; }
|
||||
.head .actions > * { width: 100%; justify-content: center; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); flex-direction: column; }
|
||||
.actions > * { width: 100%; justify-content: center; min-height: 44px; }
|
||||
.summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">充电记录</h1>
|
||||
<p class="subtitle text-soft">家充 / 快充 / 慢充,每次电量 + 电耗自动计算</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="openNew">+ 新建充电</button>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad filters">
|
||||
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||
<option value="">全部车辆</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
<input v-model="filters.from" type="date" class="input sm" @change="load" />
|
||||
<span class="text-soft">至</span>
|
||||
<input v-model="filters.to" type="date" class="input sm" @change="load" />
|
||||
<div class="stats-pills">
|
||||
<span class="pill pill-blue">{{ data.total || 0 }} 条</span>
|
||||
<span class="pill pill-green">¥{{ (data.stats?.total_cost || 0).toFixed(2) }} 总花费</span>
|
||||
<span class="pill pill-gray" v-if="avgKwh">{{ avgKwh }} kWh/100km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有充电记录</div>
|
||||
<MobileCardList
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="data.rows"
|
||||
row-key="id"
|
||||
empty-text="还没有充电记录"
|
||||
>
|
||||
<template #cell-date="{ row }">{{ row.charge_date }}</template>
|
||||
<template #cell-vehicle="{ row }">
|
||||
<div>{{ row.vehicle_name }}</div>
|
||||
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||
</template>
|
||||
<template #cell-type="{ row }">
|
||||
<span class="pill pill-blue">{{ CHARGE_LABEL[row.charge_type] || row.charge_type || '—' }}</span>
|
||||
</template>
|
||||
<template #cell-odo="{ row }">{{ row.odometer_km ? row.odometer_km + ' km' : '—' }}</template>
|
||||
<template #cell-kwh="{ row }"><strong>{{ row.kwh }} kWh</strong></template>
|
||||
<template #cell-soc="{ row }">
|
||||
<span v-if="row.start_soc != null && row.end_soc != null">{{ row.start_soc }}% → {{ row.end_soc }}%</span>
|
||||
<span v-else class="text-mute sm">—</span>
|
||||
</template>
|
||||
<template #cell-price="{ row }">¥{{ row.price_per_kwh || 0 }}</template>
|
||||
<template #cell-cost="{ row }">
|
||||
<strong class="text-brand">¥{{ (row.total_cost || 0).toFixed(2) }}</strong>
|
||||
</template>
|
||||
<template #cell-kwh100="{ row }">
|
||||
<span v-if="row.kwh_per_100km" class="pill pill-blue">{{ row.kwh_per_100km.toFixed(2) }} kWh/100km</span>
|
||||
<span v-else class="text-mute sm" :title="row.consumption_skip_reason">{{ row.consumption_skip_reason || '需里程' }}</span>
|
||||
</template>
|
||||
<template #cell-station="{ row }">{{ row.station || '—' }}</template>
|
||||
<template #actions="{ row }">
|
||||
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="showDelete"
|
||||
v-model="showDelete"
|
||||
title="删除充电记录"
|
||||
:message="`确认删除 ${deleteTarget?.charge_date} 的充电记录?`"
|
||||
mode="type"
|
||||
confirm-label="确认删除"
|
||||
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||
:busy="deleteBusy"
|
||||
:error="deleteError"
|
||||
@confirm="doDelete"
|
||||
@cancel="showDelete = false"
|
||||
/>
|
||||
|
||||
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||
<div class="modal card card-pad">
|
||||
<div class="modal-head">
|
||||
<h3 class="section-title">{{ form.id ? '编辑充电' : '新建充电' }}</h3>
|
||||
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别订单' }}</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSave">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.vehicle_id" class="select" required>
|
||||
<option :value="null">— 请选择 —</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">日期 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.charge_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">里程 (km)</label>
|
||||
<input v-model.number="form.odometer_km" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">充电类型</label>
|
||||
<select v-model="form.charge_type" class="select">
|
||||
<option v-for="(label, v) in CHARGE_LABEL" :key="v" :value="v">{{ label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">度数 (kWh) <span class="text-danger">*</span></label>
|
||||
<input v-model.number="form.kwh" type="number" step="0.01" min="0.01" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">单价 (¥/kWh)</label>
|
||||
<input v-model.number="form.price_per_kwh" type="number" step="0.01" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">总价 <span class="text-danger">*</span></label>
|
||||
<input v-model.number="form.total_cost" type="number" step="0.01" min="0" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">起止 SOC (%)</label>
|
||||
<div class="soc-row">
|
||||
<input v-model.number="form.start_soc" type="number" min="0" max="100" class="input sm" placeholder="20" />
|
||||
<span>→</span>
|
||||
<input v-model.number="form.end_soc" type="number" min="0" max="100" class="input sm" placeholder="90" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">地点 / 充电站</label>
|
||||
<input v-model="form.station" class="input" placeholder="如 国家电网 / 特来电 / 家" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||
</div>
|
||||
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import { chargingApi } from '../api/logs';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
|
||||
const CHARGE_LABEL = { home: '家充', slow: '慢充', fast: '快充', super: '超充' };
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||
{ key: 'type', label: '类型' },
|
||||
{ key: 'odo', label: '里程' },
|
||||
{ key: 'kwh', label: '度数', alwaysShow: true },
|
||||
{ key: 'soc', label: 'SOC' },
|
||||
{ key: 'price', label: '单价' },
|
||||
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||
{ key: 'kwh100', label: '电耗' },
|
||||
{ key: 'station', label: '地点' },
|
||||
];
|
||||
|
||||
const vehicles = ref([]);
|
||||
const data = ref({ rows: [], total: 0, stats: {} });
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ vehicle_id: '', from: '', to: '' });
|
||||
|
||||
const showForm = ref(false);
|
||||
const form = reactive({ id: null, vehicle_id: null, charge_date: today(), odometer_km: null, kwh: null, price_per_kwh: null, total_cost: null, charge_type: 'home', start_soc: null, end_soc: null, station: '', notes: '' });
|
||||
const formBusy = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// 401 草稿
|
||||
const draft = useFormDraft('charges/new');
|
||||
const restored = draft.load();
|
||||
if (restored) Object.assign(form, restored);
|
||||
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
// AI 识别
|
||||
const ai = useAiRecognize();
|
||||
const aiBusy = ai.busy;
|
||||
async function onAiRecognize() {
|
||||
await ai.open('charge', (data) => {
|
||||
if (data.charge_date) form.charge_date = data.charge_date;
|
||||
if (data.kwh != null) form.kwh = data.kwh;
|
||||
if (data.price_per_kwh != null) form.price_per_kwh = data.price_per_kwh;
|
||||
if (data.total_cost != null) form.total_cost = data.total_cost;
|
||||
if (data.charge_type) form.charge_type = data.charge_type;
|
||||
if (data.start_soc != null) form.start_soc = data.start_soc;
|
||||
if (data.end_soc != null) form.end_soc = data.end_soc;
|
||||
if (data.station) form.station = data.station;
|
||||
});
|
||||
}
|
||||
|
||||
const avgKwh = computed(() => {
|
||||
const xs = data.value.rows?.filter(r => r.kwh_per_100km > 0).map(r => r.kwh_per_100km) || [];
|
||||
if (!xs.length) return null;
|
||||
return (xs.reduce((s, x) => s + x, 0) / xs.length).toFixed(2);
|
||||
});
|
||||
|
||||
// 自动算总价
|
||||
watch(() => [form.kwh, form.price_per_kwh], ([k, p]) => {
|
||||
if (k && p && !form.total_cost) form.total_cost = Math.round(k * p * 100) / 100;
|
||||
});
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10); }
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {};
|
||||
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||
if (filters.from) params.from = filters.from;
|
||||
if (filters.to) params.to = filters.to;
|
||||
const r = await chargingApi.list(params);
|
||||
data.value = r.data;
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadVehicles() {
|
||||
const r = await vehiclesApi.list();
|
||||
vehicles.value = r.data || [];
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
Object.assign(form, { id: null, vehicle_id: null, charge_date: today(), odometer_km: null, kwh: null, price_per_kwh: null, total_cost: null, charge_type: 'home', start_soc: null, end_soc: null, station: '', notes: '' });
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(r) {
|
||||
Object.assign(form, r);
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function closeForm() { showForm.value = false; }
|
||||
|
||||
async function onSave() {
|
||||
formError.value = '';
|
||||
formBusy.value = true;
|
||||
try {
|
||||
const body = {
|
||||
vehicle_id: form.vehicle_id,
|
||||
charge_date: form.charge_date,
|
||||
odometer_km: form.odometer_km || null,
|
||||
kwh: form.kwh,
|
||||
price_per_kwh: form.price_per_kwh || null,
|
||||
total_cost: form.total_cost,
|
||||
charge_type: form.charge_type || null,
|
||||
start_soc: form.start_soc ?? null,
|
||||
end_soc: form.end_soc ?? null,
|
||||
station: form.station || null,
|
||||
notes: form.notes || null,
|
||||
};
|
||||
if (form.id) await chargingApi.update(form.id, body);
|
||||
else await chargingApi.create(body);
|
||||
draft.clear();
|
||||
closeForm();
|
||||
await load();
|
||||
} catch (e) {
|
||||
const errs = e.response?.data?.error?.errors;
|
||||
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||
} finally { formBusy.value = false; }
|
||||
}
|
||||
|
||||
async function onDelete(r) {
|
||||
deleteTarget.value = r;
|
||||
deleteError.value = '';
|
||||
showDelete.value = true;
|
||||
}
|
||||
|
||||
const showDelete = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteBusy = ref(false);
|
||||
const deleteError = ref('');
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
deleteBusy.value = true;
|
||||
deleteError.value = '';
|
||||
try {
|
||||
await chargingApi.remove(deleteTarget.value.id);
|
||||
showDelete.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||
} finally {
|
||||
deleteBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadVehicles(); load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
|
||||
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||
.filters { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.stats-pills { margin-left:auto; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:8px; flex-wrap:wrap; }
|
||||
.modal-head .section-title { margin:0; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||
.col-span-2 { grid-column: span 2; }
|
||||
.soc-row { display:flex; gap:6px; align-items:center; }
|
||||
.soc-row span { color:var(--text-soft); }
|
||||
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.r { text-align:right; }
|
||||
.mt-3 { margin-top:12px; }
|
||||
.text-soft { color:var(--text-soft); }
|
||||
.text-mute { color:var(--text-mute); }
|
||||
.text-danger { color:var(--danger); }
|
||||
.text-brand { color:var(--brand); }
|
||||
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 1023px) {
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
.col-span-2 { grid-column: span 2; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; }
|
||||
.head .btn { width: 100%; justify-content: center; }
|
||||
.filters { padding: 12px 16px; }
|
||||
.stats-pills { width: 100%; margin-left: 0; }
|
||||
.modal-mask { align-items: flex-end; padding: 0; }
|
||||
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.col-span-2 { grid-column: 1; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||
<div v-else>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">
|
||||
{{ data.name }}
|
||||
<span v-if="data.source === 'grocy'" class="pill pill-blue ml-2" title="数据来源于 Grocy">Grocy</span>
|
||||
<span v-else-if="data.source === 'seed'" class="pill pill-gray ml-2">演示</span>
|
||||
<span v-else class="pill pill-warn ml-2">本地</span>
|
||||
</h1>
|
||||
<p class="subtitle text-soft">
|
||||
<router-link to="/chemicals" class="text-soft">← 返回汽车用品</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button v-if="data.source === 'grocy'" class="btn btn-ghost btn-sm" @click="load" :disabled="loading">从 Grocy 重拉详情</button>
|
||||
<button v-if="data.source === 'grocy'" class="btn btn-primary" @click="showAddStock = true">+ 采购入库</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 采购入库弹窗 -->
|
||||
<div v-if="showAddStock" class="modal-mask" @click.self="showAddStock = false">
|
||||
<div class="modal card card-pad">
|
||||
<h3 class="section-title">采购入库 → Grocy</h3>
|
||||
<p class="text-soft sm mb-3">这会直接调用 Grocy POST /api/stock/products/{{ data.grocy_product_id }}/add</p>
|
||||
<form @submit.prevent="onAddStock">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">数量 <span class="text-danger">*</span></label>
|
||||
<input v-model.number="stockForm.amount" type="number" step="0.01" min="0.01" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">单价 (¥)</label>
|
||||
<input v-model.number="stockForm.price" type="number" step="0.01" min="0" class="input" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">最佳赏味期</label>
|
||||
<input v-model="stockForm.best_before_date" type="date" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">交易类型</label>
|
||||
<select v-model="stockForm.transaction_type" class="select">
|
||||
<option value="purchase">采购入库</option>
|
||||
<option value="self_production">自制入库</option>
|
||||
<option value="inventory">盘点修正</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">备注</label>
|
||||
<input v-model="stockForm.note" class="input" placeholder="可选:供应商、单号等" />
|
||||
</div>
|
||||
<p v-if="stockError" class="error mt-3">{{ stockError }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button type="button" class="btn btn-ghost" @click="showAddStock = false">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="stockBusy">{{ stockBusy ? '提交中…' : '提交入库' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="row">
|
||||
<div class="card card-pad main-card">
|
||||
<h3 class="section-title">基本信息</h3>
|
||||
<table class="data">
|
||||
<tbody>
|
||||
<tr><td class="text-soft" style="width:140px">用品 ID</td><td><code>{{ data.grocy_product_id }}</code></td></tr>
|
||||
<tr><td class="text-soft">分类</td><td>{{ data.category_display || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">单位</td><td>{{ data.unit || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">位置</td><td>{{ data.location || '—' }}</td></tr>
|
||||
<tr v-if="data.description"><td class="text-soft">描述</td><td>{{ data.description }}</td></tr>
|
||||
<tr><td class="text-soft">最低库存</td><td>{{ data.min_stock_amount || 0 }} {{ data.unit || '' }}</td></tr>
|
||||
<tr><td class="text-soft">最佳赏味期</td><td>{{ data.best_before_date || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">最后同步</td><td>{{ data.last_synced_at || '—' }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad stock-card">
|
||||
<h3 class="section-title">当前库存</h3>
|
||||
<div class="big-num">{{ data.current_amount }} <span class="unit">{{ data.unit || '' }}</span></div>
|
||||
<div class="text-soft" style="margin-top: 8px">价值</div>
|
||||
<div class="big-amount">¥{{ (data.current_value || 0).toFixed(2) }}</div>
|
||||
<div class="mt-3">
|
||||
<span v-if="data.low_stock" class="pill pill-danger">⚠ 低于最低库存</span>
|
||||
<span v-else-if="data.current_amount > 0" class="pill pill-green">库存正常</span>
|
||||
<span v-else class="pill pill-warn">库存为空</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本系统用量统计 -->
|
||||
<div class="card mt-4 card-pad">
|
||||
<h3 class="section-title">本系统使用统计</h3>
|
||||
<div class="stats">
|
||||
<div class="stat">
|
||||
<div class="stat-label">累计使用次数</div>
|
||||
<div class="stat-val">{{ data.usage_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-label">累计使用量</div>
|
||||
<div class="stat-val">{{ (data.total_amount || 0).toFixed(2) }} {{ data.unit || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 换算设置 -->
|
||||
<div class="card mt-4 card-pad">
|
||||
<h3 class="section-title">洗车扣减换算 <span class="text-soft sm" style="font-weight:normal">— 例:1 {{ data.consume_unit_name || '加仑' }} = {{ data.qu_factor }} {{ data.unit }}</span></h3>
|
||||
<p class="text-soft sm mb-3">本系统会把「洗车页面输入的量」乘以此系数,转换成 Grocy 库存单位(<code>{{ data.unit || '—' }}</code>)后再扣库存。Grocy 端扣多少、库存就减多少。</p>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">扣减单位 <span class="text-soft sm">(如 加仑/瓶/个)</span></label>
|
||||
<select v-model="conv.consume_unit_id" class="select" @change="onConsumeUnitChange">
|
||||
<option :value="null">— 与库存单位一致 —</option>
|
||||
<option v-for="u in quantityUnits" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">换算系数 <span class="text-soft sm">(1 扣减单位 = ? 库存单位)</span></label>
|
||||
<input v-model.number="conv.qu_factor" type="number" step="0.0001" min="0" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="convPreview" class="text-soft sm mt-2">预览:洗车页面输入 <strong>1 {{ convPreview.unitName }}</strong> → Grocy 扣 <strong>{{ convPreview.grams }} {{ data.unit }}</strong></p>
|
||||
<p v-if="convError" class="error mt-2">{{ convError }}</p>
|
||||
<p v-if="convOk" class="text-success sm mt-2">{{ convOk }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button class="btn btn-ghost" @click="resetConv">重置</button>
|
||||
<button class="btn btn-primary" @click="saveConv" :disabled="convBusy">{{ convBusy ? '保存中…' : '保存换算设置' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grocy 高级信息(如果有) -->
|
||||
<div v-if="data.grocy_details" class="card mt-4 card-pad">
|
||||
<h3 class="section-title">Grocy 详细数据</h3>
|
||||
<table class="data">
|
||||
<tbody>
|
||||
<tr><td class="text-soft" style="width:200px">Grocy 库存价值</td><td class="text-brand">¥{{ (data.grocy_details.stock_value || 0).toFixed(2) }}</td></tr>
|
||||
<tr><td class="text-soft">最近采购</td><td>{{ data.grocy_details.last_purchased || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">最近使用</td><td>{{ data.grocy_details.last_used || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">最近进价</td><td>¥{{ formatGrocyPrice(data.grocy_details.last_price) }}</td></tr>
|
||||
<tr><td class="text-soft">平均价</td><td>¥{{ formatGrocyPrice(data.grocy_details.avg_price) }}</td></tr>
|
||||
<tr><td class="text-soft">下次到期</td><td>{{ data.grocy_details.next_due_date || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">已开库存</td><td>{{ data.grocy_details.stock_amount_opened || 0 }} {{ data.unit }}</td></tr>
|
||||
<tr><td class="text-soft">未开库存</td><td>{{ (data.current_amount - (data.grocy_details.stock_amount_opened || 0)) }} {{ data.unit }}</td></tr>
|
||||
<tr v-if="data.grocy_details.average_shelf_life_days"><td class="text-soft">平均保质期</td><td>{{ data.grocy_details.average_shelf_life_days }} 天</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 进销存记录 -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-pad list-head">
|
||||
<h3 class="section-title" style="margin:0">进销存记录</h3>
|
||||
<div class="tab-bar">
|
||||
<button :class="['tab', { active: tab==='local' }]" @click="tab='local'">
|
||||
本系统 <span class="badge">{{ (data.usage_rows || []).length }}</span>
|
||||
</button>
|
||||
<button :class="['tab', { active: tab==='grocy' }]" @click="tab='grocy'" :disabled="data.source !== 'grocy'">
|
||||
Grocy 入库批次
|
||||
<span v-if="data.source !== 'grocy'" class="text-mute sm" style="font-weight:normal">(非 Grocy 来源)</span>
|
||||
<span v-else class="badge">{{ (data.grocy_log || []).length }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本系统使用记录 -->
|
||||
<table v-if="tab === 'local'" class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>日期</th>
|
||||
<th>类型</th>
|
||||
<th>用量</th>
|
||||
<th>关联洗车</th>
|
||||
<th>位置</th>
|
||||
<th>同步状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="u in data.usage_rows" :key="u.id">
|
||||
<td>{{ u.usage_date }}</td>
|
||||
<td><span class="pill pill-blue">洗车扣减</span></td>
|
||||
<td><strong>{{ u.amount }}</strong> {{ data.unit }}</td>
|
||||
<td>
|
||||
<router-link v-if="u.wash_id" :to="{ name: 'wash-show', params: { id: u.wash_id } }" class="text-brand">
|
||||
#{{ u.wash_id }} {{ washTypeLabel(u.wash_type) }}
|
||||
</router-link>
|
||||
<span v-else class="text-mute">—</span>
|
||||
</td>
|
||||
<td class="text-soft">{{ u.location || '—' }}</td>
|
||||
<td>
|
||||
<span :class="['pill', u.sync_status === 'synced' ? 'pill-green' : u.sync_status === 'failed' ? 'pill-danger' : 'pill-warn']">
|
||||
{{ u.sync_status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!data.usage_rows?.length">
|
||||
<td colspan="6" class="text-mute" style="text-align:center;padding:24px">本系统暂无使用记录</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Grocy 入库批次 -->
|
||||
<table v-if="tab === 'grocy'" class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>入库日期</th>
|
||||
<th>批次</th>
|
||||
<th>数量</th>
|
||||
<th>价格</th>
|
||||
<th>最佳赏味期</th>
|
||||
<th>状态</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="data.grocy_error">
|
||||
<td colspan="7" class="text-danger" style="padding:12px">拉取失败:{{ data.grocy_error }}</td>
|
||||
</tr>
|
||||
<tr v-for="g in data.grocy_log" :key="g.id">
|
||||
<td>{{ g.purchased_date || g.row_created_timestamp }}</td>
|
||||
<td><code>#{{ g.id }}</code></td>
|
||||
<td><strong>{{ g.amount }}</strong> {{ data.unit }}</td>
|
||||
<td class="text-brand">¥{{ formatGrocyPrice(g.price) }}</td>
|
||||
<td class="text-soft">{{ g.best_before_date || '—' }}</td>
|
||||
<td>
|
||||
<span v-if="g.open" class="pill pill-warn">已开</span>
|
||||
<span v-else class="pill pill-green">未开</span>
|
||||
</td>
|
||||
<td class="text-soft sm">{{ g.note || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!data.grocy_error && !data.grocy_log?.length">
|
||||
<td colspan="7" class="text-mute" style="text-align:center;padding:24px">Grocy 暂无入库批次</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import * as chemicalsApi from '../api/chemicals';
|
||||
import http from '../api/client';
|
||||
|
||||
const route = useRoute();
|
||||
const data = ref({});
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const tab = ref('local');
|
||||
|
||||
// 采购入库弹窗
|
||||
const showAddStock = ref(false);
|
||||
const stockBusy = ref(false);
|
||||
const stockError = ref('');
|
||||
const stockForm = reactive({
|
||||
amount: 1,
|
||||
price: 0,
|
||||
best_before_date: '',
|
||||
transaction_type: 'purchase',
|
||||
note: '',
|
||||
});
|
||||
|
||||
// 换算设置
|
||||
const quantityUnits = ref([]);
|
||||
const conv = reactive({ consume_unit_id: null, qu_factor: 1 });
|
||||
const convBusy = ref(false);
|
||||
const convError = ref('');
|
||||
const convOk = ref('');
|
||||
|
||||
const convPreview = computed(() => {
|
||||
const u = quantityUnits.value.find(x => x.id === conv.consume_unit_id);
|
||||
if (!u || !conv.qu_factor || conv.qu_factor === 1) return null;
|
||||
return { unitName: u.name, grams: (1 * Number(conv.qu_factor || 0)).toFixed(2) };
|
||||
});
|
||||
|
||||
const washTypeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||
const formatGrocyPrice = (v) => (v == null || v === '' || v === 0) ? '—' : Number(v).toFixed(2);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const r = await chemicalsApi.get(route.params.id);
|
||||
data.value = r.data;
|
||||
// 同步到 conv
|
||||
conv.consume_unit_id = r.data.consume_unit_id || null;
|
||||
conv.qu_factor = r.data.qu_factor || 1;
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.response?.data?.code || '加载失败';
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadQuantityUnits() {
|
||||
try {
|
||||
const r = await http.get('/objects/quantity_units');
|
||||
quantityUnits.value = Array.isArray(r.data) ? r.data : [];
|
||||
} catch (e) {
|
||||
console.warn('load quantity_units failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
function onConsumeUnitChange() {
|
||||
if (!conv.consume_unit_id) {
|
||||
conv.qu_factor = 1;
|
||||
return;
|
||||
}
|
||||
// 用户改了单位后建议 1,但提醒用户填系数
|
||||
if (conv.qu_factor === 1) conv.qu_factor = 1;
|
||||
}
|
||||
|
||||
function resetConv() {
|
||||
conv.consume_unit_id = data.value.consume_unit_id || null;
|
||||
conv.qu_factor = data.value.qu_factor || 1;
|
||||
convError.value = '';
|
||||
convOk.value = '';
|
||||
}
|
||||
|
||||
async function saveConv() {
|
||||
convError.value = '';
|
||||
convOk.value = '';
|
||||
convBusy.value = true;
|
||||
try {
|
||||
const u = quantityUnits.value.find(x => x.id === conv.consume_unit_id);
|
||||
const r = await chemicalsApi.update(data.value.grocy_product_id, {
|
||||
qu_factor: conv.qu_factor,
|
||||
consume_unit_id: conv.consume_unit_id,
|
||||
consume_unit_name: u?.name || null,
|
||||
});
|
||||
data.value = r.data;
|
||||
convOk.value = '已保存';
|
||||
setTimeout(() => (convOk.value = ''), 2000);
|
||||
} catch (e) {
|
||||
convError.value = e.response?.data?.message || e.response?.data?.code || '保存失败:' + e.message;
|
||||
} finally { convBusy.value = false; }
|
||||
}
|
||||
|
||||
async function onAddStock() {
|
||||
stockError.value = '';
|
||||
stockBusy.value = true;
|
||||
try {
|
||||
await chemicalsApi.addStock(data.value.grocy_product_id, {
|
||||
amount: stockForm.amount,
|
||||
price: stockForm.price,
|
||||
best_before_date: stockForm.best_before_date || null,
|
||||
transaction_type: stockForm.transaction_type,
|
||||
note: stockForm.note || null,
|
||||
});
|
||||
showAddStock.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
stockError.value = e.response?.data?.message || e.response?.data?.code || '入库失败:' + e.message;
|
||||
} finally { stockBusy.value = false; }
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadQuantityUnits(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.head-actions { display: flex; gap: 8px; }
|
||||
.row { display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; }
|
||||
.section-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 16px; }
|
||||
.big-num { font-size: 36px; font-weight: 700; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
|
||||
.unit { font-size: 18px; color: var(--text-soft); font-weight: 400; }
|
||||
.big-amount { font-size: 20px; font-weight: 600; color: var(--brand); }
|
||||
.stats { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.stat { background: var(--bg-soft); border-radius: var(--radius-sm); padding: 14px; }
|
||||
.stat-label { font-size: 12px; color: var(--text-soft); margin-bottom: 4px; }
|
||||
.stat-val { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.list-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0; }
|
||||
.tab-bar { display: flex; gap: 4px; }
|
||||
.tab {
|
||||
background: transparent; border: 0; padding: 6px 14px; border-radius: var(--pill);
|
||||
font-size: 13px; color: var(--text-soft); cursor: pointer; transition: all .15s;
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.tab:hover:not(:disabled) { background: var(--bg-soft); color: var(--text); }
|
||||
.tab.active { background: var(--accent); color: #fff; }
|
||||
.tab:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.badge { background: rgba(255,255,255,0.2); padding: 1px 6px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||
.tab:not(.active) .badge { background: var(--bg-soft); color: var(--text-soft); }
|
||||
.sm { font-size: 11px; }
|
||||
.ml-2 { margin-left: 8px; }
|
||||
.mb-3 { margin-bottom: 12px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; background: rgba(15, 34, 51, 0.5);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.modal {
|
||||
width: 100%; max-width: 560px; max-height: 90vh; overflow: auto;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.label { display: block; font-size: 12px; font-weight: 600; color: var(--text-soft); margin-bottom: 6px; }
|
||||
.input, .select {
|
||||
width: 100%; padding: 8px 10px; border: 1px solid var(--border, #E5E7EB);
|
||||
border-radius: var(--radius-sm); font: inherit; background: #fff;
|
||||
}
|
||||
.input:focus, .select:focus { outline: 2px solid var(--accent, #00C2A0); outline-offset: -1px; }
|
||||
.text-success { color: var(--accent, #00C2A0); }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.error { color: var(--danger); background: #FBE3DF; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; margin: 0; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
@media (max-width: 800px) { .row { grid-template-columns: 1fr; } .grid { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head .actions { display: flex; flex-wrap: wrap; }
|
||||
.head .actions > * { flex: 1; min-width: 80px; justify-content: center; }
|
||||
.stat-num { font-size: 22px; }
|
||||
.modal-mask { align-items: flex-end; padding: 0; }
|
||||
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<h1 class="title">新建汽车用品 → Grocy</h1>
|
||||
<router-link to="/chemicals" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||||
<p class="text-soft sm mb-3">这个产品会直接创建到你的 Grocy 库存系统</p>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">名称 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.name" class="input" required placeholder="如:化学小子柑橘上光洗车液" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">分类</label>
|
||||
<select v-model="form.product_group_id" class="select">
|
||||
<option :value="null">不选</option>
|
||||
<option v-for="g in options.groups" :key="g.id" :value="g.id">{{ g.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">库存单位</label>
|
||||
<select v-model="form.qu_id_stock" class="select" @change="syncPurchaseUnit">
|
||||
<option v-for="u in options.units" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">采购单位</label>
|
||||
<select v-model="form.qu_id_purchase" class="select">
|
||||
<option v-for="u in options.units" :key="u.id" :value="u.id">{{ u.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">库位</label>
|
||||
<select v-model="form.location_id" class="select">
|
||||
<option v-for="l in options.locations" :key="l.id" :value="l.id">{{ l.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">最低库存</label>
|
||||
<input v-model.number="form.min_stock_amount" type="number" step="0.01" min="0" class="input" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">默认最佳赏味期(天)</label>
|
||||
<input v-model.number="form.default_best_before_days" type="number" min="0" class="input" placeholder="0" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">采购/库存 换算系数 <span class="text-mute sm">(4.6+)</span></label>
|
||||
<input v-model.number="form.qu_factor_purchase_to_stock" type="number" step="0.01" min="0" class="input" placeholder="1" />
|
||||
<div class="text-mute sm mt-1">采购 1 瓶 = 库存 {{ form.qu_factor_purchase_to_stock || 1 }} 瓶(4.5.x 不支持)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="label">描述</label>
|
||||
<textarea v-model="form.description" class="textarea" rows="2" placeholder="可选"></textarea>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||||
<div class="actions mt-6">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '创建中…' : '在 Grocy 创建' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import * as chemicalsApi from '../api/chemicals';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
const router = useRouter();
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
const options = reactive({ groups: [], units: [], locations: [] });
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
product_group_id: null,
|
||||
qu_id_stock: 1,
|
||||
qu_id_purchase: 1,
|
||||
qu_factor_purchase_to_stock: 1,
|
||||
location_id: 1,
|
||||
shopping_location_id: 1,
|
||||
min_stock_amount: 0,
|
||||
default_best_before_days: 0,
|
||||
});
|
||||
|
||||
// 401 草稿
|
||||
const draft = useFormDraft('chemicals/new');
|
||||
const restored = draft.load();
|
||||
if (restored) Object.assign(form, restored);
|
||||
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
function syncPurchaseUnit() { form.qu_id_purchase = form.qu_id_stock; }
|
||||
|
||||
onMounted(async () => {
|
||||
// 从 chemicals 列表里提取唯一的 groups / units / locations
|
||||
try {
|
||||
const chs = await chemicalsApi.list();
|
||||
const arr = chs.data || [];
|
||||
const groupSet = new Map();
|
||||
const unitSet = new Map();
|
||||
const locSet = new Map();
|
||||
for (const c of arr) {
|
||||
if (c.product_group_id && c.category) groupSet.set(c.product_group_id, c.category);
|
||||
if (c.qu_id) unitSet.set(c.qu_id, c.unit);
|
||||
if (c.location_id) locSet.set(c.location_id, c.location);
|
||||
}
|
||||
options.groups = Array.from(groupSet, ([id, name]) => ({ id, name })).sort((a, b) => a.id - b.id);
|
||||
options.units = Array.from(unitSet, ([id, name]) => ({ id, name })).sort((a, b) => a.id - b.id);
|
||||
options.locations = Array.from(locSet, ([id, name]) => ({ id, name })).sort((a, b) => a.id - b.id);
|
||||
// 默认选第一个真实存在的 ID(避免 Grocy 字典 id 不从 1 开始)
|
||||
if (options.units.length && (!form.qu_id_stock || form.qu_id_stock === 1)) {
|
||||
form.qu_id_stock = options.units[0].id;
|
||||
form.qu_id_purchase = options.units[0].id;
|
||||
}
|
||||
if (options.locations.length && (!form.location_id || form.location_id === 1)) {
|
||||
form.location_id = options.locations[0].id;
|
||||
form.shopping_location_id = options.locations[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = '加载选项失败:' + e.message;
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
const r = await chemicalsApi.create(form);
|
||||
const id = r.data?.grocy_product_id;
|
||||
draft.clear();
|
||||
router.push({ name: 'chemical-show', params: { id } });
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.response?.data?.code || '创建失败:' + e.message;
|
||||
} finally { busy.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.form { max-width: 760px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.sm { font-size: 12px; }
|
||||
.mb-3 { margin-bottom: 12px; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.mt-6 { margin-top: 24px; }
|
||||
.error { color: var(--danger); background: #FBE3DF; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head > a, .head > .actions { width: 100%; justify-content: center; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">汽车用品</h1>
|
||||
<p class="subtitle text-soft">
|
||||
共 {{ total }} 项 ·
|
||||
<span v-if="totalValue > 0" class="text-brand">¥{{ totalValue.toFixed(2) }} 库存价值</span>
|
||||
<span v-if="grocyCount" class="ml-3 source-source">
|
||||
<span class="pill pill-blue">{{ grocyCount }} 来自 Grocy</span>
|
||||
</span>
|
||||
<span v-if="lowCount" class="ml-3"><span class="pill pill-danger">{{ lowCount }} 低库存</span></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="load" :disabled="loading">{{ loading ? '刷新中…' : '刷新' }}</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="onSync" :disabled="syncing">
|
||||
{{ syncing ? '同步中…' : '↓ 从 Grocy 同步' }}
|
||||
</button>
|
||||
<router-link to="/chemicals/purchase" class="btn btn-ghost btn-sm">+ 批量采购</router-link>
|
||||
<router-link to="/chemicals/new" class="btn btn-primary">+ 新建</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索框:参照 Grocy 顶部 search bar -->
|
||||
<div class="search-bar card">
|
||||
<span class="search-icon">🔍</span>
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索:名称 / 描述 / 分类 / 库位 / 备注... (或粘贴 Grocy product id)"
|
||||
@keydown.enter="onGrocySearch"
|
||||
/>
|
||||
<button v-if="query" class="search-clear" @click="clearQuery" type="button">×</button>
|
||||
<span v-if="query" class="result-count">
|
||||
本地匹配 <strong>{{ filteredRows.length }}</strong> / {{ rows.length }}
|
||||
</span>
|
||||
<button class="search-grocy" :disabled="!query || grocySearching" @click="onGrocySearch" type="button">
|
||||
{{ grocySearching ? '搜索中…' : 'Grocy 全局搜' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grocy 全局搜索结果(仅在搜过之后显示) -->
|
||||
<div v-if="grocyResults" class="grocy-results card mt-3">
|
||||
<div class="results-head">
|
||||
<strong>Grocy 全局搜索</strong> ·
|
||||
<span class="text-mute sm">在 Grocy 库里搜(不依赖本地缓存)</span>
|
||||
<button class="btn-mini" @click="grocyResults = null">关闭</button>
|
||||
</div>
|
||||
<div v-if="grocyResults.error" class="error mt-2">{{ grocyResults.error }}</div>
|
||||
<div v-else>
|
||||
<p class="text-mute sm mt-2">Grocy 端匹配 <strong>{{ grocyResults.items.length }}</strong> 条</p>
|
||||
<table v-if="grocyResults.items.length" class="data mt-2">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>名称</th><th>分类</th><th>本地缓存</th><th>操作</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="g in grocyResults.items" :key="g.id">
|
||||
<td><code>{{ g.id }}</code></td>
|
||||
<td><strong>{{ g.name }}</strong><span v-if="g.description" class="text-soft sm"> · {{ g.description }}</span></td>
|
||||
<td>{{ groupName(g.product_group_id) }}</td>
|
||||
<td>
|
||||
<span v-if="g.cached" class="pill pill-green">已同步</span>
|
||||
<span v-else class="pill pill-warn">本地无</span>
|
||||
</td>
|
||||
<td>
|
||||
<button v-if="!g.cached" class="btn btn-primary btn-sm" @click="importOne(g.id)" :disabled="importing === g.id">
|
||||
{{ importing === g.id ? '导入中…' : '导入到本地' }}
|
||||
</button>
|
||||
<button v-else class="btn btn-ghost btn-sm" @click="goDetail(g.id)">查看</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="msg" :class="['msg', msgOk ? 'ok' : 'err']">{{ msg }}</p>
|
||||
|
||||
<div class="card">
|
||||
<MobileCardList
|
||||
:columns="columns"
|
||||
:rows="filteredRows"
|
||||
row-key="grocy_product_id"
|
||||
empty-text="暂无数据"
|
||||
>
|
||||
<template #cell-name="{ row, $index }">
|
||||
<div class="card-clickable" @click="$router.push({ name: 'chemical-show', params: { id: row.grocy_product_id } })">
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span v-if="row.description" class="text-soft desc">{{ row.description }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-source="{ row }">
|
||||
<span v-if="row.source === 'grocy'" class="pill pill-blue">Grocy</span>
|
||||
<span v-else-if="row.source === 'seed'" class="pill pill-gray">演示</span>
|
||||
<span v-else class="pill pill-warn">本地</span>
|
||||
</template>
|
||||
<template #cell-category="{ row }">
|
||||
<span class="pill pill-gray">{{ row.category_display || '—' }}</span>
|
||||
</template>
|
||||
<template #cell-amount="{ row }">
|
||||
<span class="amount">{{ row.current_amount }}</span>
|
||||
<span class="text-soft sm"> {{ row.unit || '' }}</span>
|
||||
</template>
|
||||
<template #cell-value="{ row }">
|
||||
<span class="text-brand">{{ formatValue(row.current_value) }}</span>
|
||||
</template>
|
||||
<template #cell-min="{ row }">
|
||||
<span v-if="row.min_stock_amount > 0" class="text-mute sm">{{ row.min_stock_amount }} {{ row.unit || '' }}</span>
|
||||
<span v-else class="text-mute">—</span>
|
||||
</template>
|
||||
<template #cell-status="{ row }">
|
||||
<span v-if="row.low_stock" class="pill pill-danger">低库存</span>
|
||||
<span v-else-if="row.current_amount > 0" class="pill pill-green">正常</span>
|
||||
<span v-else class="pill pill-warn">空</span>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import * as chemicalsApi from '../api/chemicals';
|
||||
import { asArray } from '../api/client';
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'name', label: '名称', primary: true, alwaysShow: true },
|
||||
{ key: 'source', label: '来源', alwaysShow: true },
|
||||
{ key: 'category', label: '分类' },
|
||||
{ key: 'amount', label: '当前库存', alwaysShow: true },
|
||||
{ key: 'value', label: '价值', alwaysShow: true },
|
||||
{ key: 'min', label: '最低' },
|
||||
{ key: 'status', label: '状态', alwaysShow: true },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const rows = ref([]);
|
||||
const loading = ref(false);
|
||||
const syncing = ref(false);
|
||||
const msg = ref('');
|
||||
const msgOk = ref(true);
|
||||
const query = ref(route.query.q || '');
|
||||
|
||||
// Grocy 全局搜索
|
||||
const grocySearching = ref(false);
|
||||
const grocyResults = ref(null); // { items, error }
|
||||
const importing = ref(null);
|
||||
const groupMap = ref({}); // id → name
|
||||
|
||||
// 注意:不要在 watch(query) 里自动 router.replace。
|
||||
// App.vue 用 :key="route.fullPath",URL 变化会卸载重建整个组件,导致输入框失焦。
|
||||
// 搜索词同步 URL 只在 Enter(onGrocySearch)时做一次,保留可分享/收藏能力。
|
||||
|
||||
const total = computed(() => rows.value.length);
|
||||
const totalValue = computed(() => rows.value.reduce((a, c) => a + (c.current_value || 0), 0));
|
||||
const lowCount = computed(() => rows.value.filter(c => c.low_stock).length);
|
||||
const grocyCount = computed(() => rows.value.filter(c => c.source === 'grocy').length);
|
||||
|
||||
function isHighlight(c) {
|
||||
if (!query.value) return false;
|
||||
const terms = queryTokens(query.value);
|
||||
if (!terms.length) return false;
|
||||
const hay = `${c.name || ''} ${c.description || ''} ${c.category_display || ''} ${c.location || ''} ${c.grocy_product_id || ''}`.toLowerCase();
|
||||
return terms.every(t => hay.includes(t));
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
if (!query.value) return rows.value;
|
||||
const terms = queryTokens(query.value);
|
||||
if (!terms.length) return rows.value;
|
||||
return rows.value.filter(c => {
|
||||
const hay = `${c.name || ''} ${c.description || ''} ${c.category_display || ''} ${c.location || ''} ${c.grocy_product_id || ''}`.toLowerCase();
|
||||
return terms.every(t => hay.includes(t));
|
||||
});
|
||||
});
|
||||
|
||||
/** 把搜索词按空白拆成多段,全部命中才算匹配(支持 "化学 玻璃" 同时搜两段) */
|
||||
function queryTokens(q) {
|
||||
return q.toLowerCase().split(/\s+/).map(t => t.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function clearQuery() {
|
||||
query.value = '';
|
||||
grocyResults.value = null;
|
||||
}
|
||||
|
||||
function formatValue(v) {
|
||||
if (!v || v === 0) return '—';
|
||||
return '¥' + v.toFixed(2);
|
||||
}
|
||||
|
||||
function groupName(id) {
|
||||
if (id == null) return '—';
|
||||
return groupMap.value[id] || `group-${id}`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const r = await chemicalsApi.list();
|
||||
rows.value = asArray(r.data, 'chemicals');
|
||||
// 拉 categories 用于 groupName
|
||||
try {
|
||||
const cr = await chemicalsApi.getCategories();
|
||||
const list = Array.isArray(cr.data) ? cr.data : [];
|
||||
for (const c of list) groupMap.value[c.id] = c.is_mapped ? c.name : `group-${c.id}`;
|
||||
} catch {}
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function onSync() {
|
||||
syncing.value = true;
|
||||
msg.value = '';
|
||||
try {
|
||||
const r = await chemicalsApi.sync();
|
||||
const d = r.data || {};
|
||||
msgOk.value = true;
|
||||
msg.value = `✓ 同步完成:拉取 ${d.products_total || d.fetched} 条,新增 ${d.inserted},更新 ${d.updated},去激活 ${d.deactivated || 0},价值 ¥${(d.total_value || 0).toFixed(2)}`;
|
||||
await load();
|
||||
} catch (e) {
|
||||
msgOk.value = false;
|
||||
msg.value = '✗ 同步失败:' + (e.response?.data?.message || e.message);
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
setTimeout(() => { msg.value = ''; }, 8000);
|
||||
}
|
||||
}
|
||||
|
||||
async function onGrocySearch() {
|
||||
if (!query.value.trim()) return;
|
||||
// Enter 时同步一次 URL(不在 watch 里做,避免每次按键都重建组件)
|
||||
const q = query.value.trim();
|
||||
if (route.query.q !== q) {
|
||||
router.replace({ query: { q } });
|
||||
}
|
||||
grocySearching.value = true;
|
||||
grocyResults.value = null;
|
||||
try {
|
||||
const r = await chemicalsApi.grocySearch(q);
|
||||
const items = r.data?.items || [];
|
||||
// 标记哪些在本地有缓存
|
||||
const cachedIds = new Set(rows.value.map(c => String(c.grocy_product_id)));
|
||||
for (const it of items) it.cached = cachedIds.has(String(it.id));
|
||||
grocyResults.value = { items, error: null };
|
||||
} catch (e) {
|
||||
grocyResults.value = { items: [], error: e.response?.data?.message || e.message };
|
||||
} finally {
|
||||
grocySearching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function importOne(id) {
|
||||
importing.value = id;
|
||||
try {
|
||||
await chemicalsApi.sync();
|
||||
await load();
|
||||
// 重新查 grocyResults 把 cached 标记刷新
|
||||
if (grocyResults.value) {
|
||||
const r = await chemicalsApi.grocySearch(query.value);
|
||||
const items = r.data?.items || [];
|
||||
const cachedIds = new Set(rows.value.map(c => String(c.grocy_product_id)));
|
||||
for (const it of items) it.cached = cachedIds.has(String(it.id));
|
||||
grocyResults.value = { items, error: null };
|
||||
}
|
||||
} finally { importing.value = null; }
|
||||
}
|
||||
|
||||
function goDetail(id) {
|
||||
router.push({ name: 'chemical-show', params: { id } });
|
||||
}
|
||||
|
||||
// 进入页面:先把本地缓存展示出来,再后台调一次轻量同步(Grocy 里已删的会被标 is_active=0),
|
||||
// 同步成功后静默重拉一次列表,让"已删除于 Grocy"的项从 UI 上消失。失败/超时/未配置 Grocy 都静默。
|
||||
async function refreshFromGrocyInBackground() {
|
||||
try {
|
||||
const r = await chemicalsApi.refreshIds();
|
||||
const d = r.data || {};
|
||||
if (d.deactivated > 0) {
|
||||
// 有产品被去激活,重拉列表以反映最新状态
|
||||
await load();
|
||||
}
|
||||
} catch { /* 静默:未配置 Grocy 或网络抖动都不应影响列表展示 */ }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
refreshFromGrocyInBackground();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.name-cell { display: flex; flex-direction: column; gap: 2px; }
|
||||
.desc { font-size: 11px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.amount { font-variant-numeric: tabular-nums; font-weight: 600; font-size: 15px; }
|
||||
.sm { font-size: 12px; }
|
||||
.mb-3 { margin-bottom: 12px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.mt-3 { margin-top: 12px; }
|
||||
.ml-3 { margin-left: 12px; }
|
||||
.msg { padding: 10px 14px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 16px; }
|
||||
.msg.ok { background: #DEF4EC; color: #2E8A6B; }
|
||||
.msg.err { background: #FBE3DF; color: #A33B30; }
|
||||
.clickable tbody tr { cursor: pointer; }
|
||||
.row-low { background: rgba(217, 105, 92, 0.05); }
|
||||
.row-low:hover { background: rgba(217, 105, 92, 0.1) !important; }
|
||||
|
||||
/* 搜索框:仿 Grocy 风格 */
|
||||
.search-bar {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 14px; margin-bottom: 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(40, 80, 110, 0.06);
|
||||
}
|
||||
.search-icon { font-size: 18px; opacity: .55; }
|
||||
.search-input {
|
||||
flex: 1; border: 0; outline: 0; background: transparent;
|
||||
font-size: 15px; padding: 6px 4px; color: var(--text);
|
||||
font-family: inherit;
|
||||
}
|
||||
.search-input::placeholder { color: var(--text-mute); }
|
||||
.search-clear {
|
||||
background: transparent; border: 0; cursor: pointer; color: var(--text-mute);
|
||||
font-size: 18px; padding: 0 6px;
|
||||
}
|
||||
.search-clear:hover { color: var(--text); }
|
||||
.result-count {
|
||||
color: var(--text-soft); font-size: 12px; padding: 0 8px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.search-grocy {
|
||||
background: var(--accent); color: #fff; border: 0;
|
||||
padding: 6px 14px; border-radius: var(--radius-sm);
|
||||
font-size: 13px; cursor: pointer; font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-grocy:hover:not(:disabled) { background: var(--accent-soft); }
|
||||
.search-grocy:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* 高亮匹配行 */
|
||||
.row-hl { background: rgba(77, 186, 154, 0.08); }
|
||||
.row-hl:hover { background: rgba(77, 186, 154, 0.15) !important; }
|
||||
|
||||
/* Grocy 搜索结果 */
|
||||
.grocy-results { padding: 0; }
|
||||
.results-head {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 10px 16px; background: var(--bg-soft);
|
||||
border-radius: var(--radius) var(--radius) 0 0; border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.results-head strong { font-size: 14px; }
|
||||
.btn-mini {
|
||||
background: transparent; border: 1px solid var(--line); color: var(--text-soft);
|
||||
padding: 2px 10px; border-radius: 4px; font-size: 12px; cursor: pointer; margin-left: auto;
|
||||
}
|
||||
.btn-mini:hover { background: var(--card); }
|
||||
.error { color: var(--danger); font-size: 13px; }
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 1023px) {
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.actions { flex-direction: column; }
|
||||
.actions > * { width: 100%; justify-content: center; }
|
||||
.search-bar { padding: 8px 12px; flex-wrap: wrap; }
|
||||
.search-input { width: 100%; min-width: 0; }
|
||||
.search-grocy { width: 100%; margin-top: 8px; }
|
||||
.result-count { width: 100%; }
|
||||
.card-clickable { cursor: pointer; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="page">
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">概览</h1>
|
||||
<p class="subtitle text-soft">{{ greeting }}</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<router-link to="/washes/new" class="btn btn-primary">+ 新建洗车记录</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||
<template v-else>
|
||||
<!-- 低库存预警(仅当有产品 low stock) -->
|
||||
<div v-if="lowStock.length" class="low-stock-alert">
|
||||
<div class="alert-head">
|
||||
<span class="alert-icon">⚠️</span>
|
||||
<strong>低库存预警</strong>
|
||||
<span class="alert-count">{{ lowStock.length }} 个 Grocy 产品低于最低库存</span>
|
||||
<router-link to="/chemicals" class="alert-link">查看全部 →</router-link>
|
||||
</div>
|
||||
<div class="alert-list">
|
||||
<router-link
|
||||
v-for="p in lowStock.slice(0, 5)"
|
||||
:key="p.grocy_product_id"
|
||||
:to="{ name: 'chemical-show', params: { id: p.grocy_product_id } }"
|
||||
class="alert-item"
|
||||
>
|
||||
<span class="item-name">{{ p.name }}</span>
|
||||
<span class="item-meta">
|
||||
当前 <strong class="text-danger">{{ p.current_amount }}</strong> / 最低 {{ p.min_stock_amount }} {{ p.unit || '' }}
|
||||
</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI 卡片 -->
|
||||
<div class="kpi-grid">
|
||||
<StatCard title="本月洗车次数" :value="kpi.washes" :hint="kpi.washesHint" :trend="kpi.washesTrend" />
|
||||
<StatCard title="本月花费" :value="'¥ ' + kpi.cost" :hint="kpi.costHint" :trend="kpi.costTrend" />
|
||||
<StatCard title="平均间隔" :value="kpi.interval + ' 天'" hint="两次洗车之间" />
|
||||
<StatCard title="使用车辆" :value="kpi.vehicles" :hint="kpi.vehiclesHint" />
|
||||
</div>
|
||||
|
||||
<!-- 图表 -->
|
||||
<div class="row mt-6">
|
||||
<div class="card card-pad chart-card">
|
||||
<h3 class="chart-title">最近 30 天洗车频次</h3>
|
||||
<div class="chart-wrap"><ChartBlock type="bar" :data="freqData" :options="freqOptions" /></div>
|
||||
</div>
|
||||
<div class="card card-pad chart-card">
|
||||
<h3 class="chart-title">洗车类型分布</h3>
|
||||
<div class="chart-wrap"><ChartBlock type="doughnut" :data="typeData" :options="typeOptions" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 今日天气 -->
|
||||
<div class="card card-pad mt-6">
|
||||
<div class="weather-head">
|
||||
<h3 class="chart-title" style="margin:0">今日天气 · {{ weather.city || cfg.app.city || 'Beijing' }}</h3>
|
||||
<span class="pill pill-blue">{{ weather.provider || '—' }}</span>
|
||||
</div>
|
||||
<div v-if="!weather.fetched_at" class="text-mute mt-2" style="font-size:13px">
|
||||
尚未拉取。配置天气 API key 后在「设置」页点「拉取今日天气」或运行 <code>npm run weather</code>。
|
||||
</div>
|
||||
<div v-else class="weather-grid">
|
||||
<div class="metric"><div class="m-value">{{ weather.temp_c }}℃</div><div class="m-label">气温</div></div>
|
||||
<div class="metric"><div class="m-value">{{ weather.weather_desc }}</div><div class="m-label">天气</div></div>
|
||||
<div class="metric"><div class="m-value">{{ weather.humidity }}%</div><div class="m-label">湿度</div></div>
|
||||
<div class="metric"><div class="m-value">{{ weather.wind_kph }}</div><div class="m-label">风速 km/h</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最近洗车 -->
|
||||
<div class="card mt-6">
|
||||
<div class="card-pad list-head">
|
||||
<h3 class="chart-title" style="margin:0">最近洗车</h3>
|
||||
<router-link to="/washes" class="text-brand" style="font-size:13px">查看全部 →</router-link>
|
||||
</div>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr><th>日期</th><th>类型</th><th>车辆</th><th>位置</th><th>花费</th><th>天气</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in recent" :key="r.id" class="row-link" @click="goTo(r.id)">
|
||||
<td>{{ r.wash_date }}</td>
|
||||
<td><span class="pill" :class="typePill(r.wash_type)">{{ washTypeLabel(r.wash_type) }}</span></td>
|
||||
<td>{{ r.vehicle_name || '—' }}</td>
|
||||
<td>{{ r.location || '—' }}</td>
|
||||
<td>¥ {{ r.cost }}</td>
|
||||
<td class="text-soft">{{ r.weather_desc || '—' }} · {{ r.temp_c ?? '—' }}℃</td>
|
||||
</tr>
|
||||
<tr v-if="!recent.length"><td colspan="6" class="text-mute" style="text-align:center; padding:24px">暂无记录</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 最近用车记录(保养/加油/充电) -->
|
||||
<div class="card mt-6">
|
||||
<div class="card-pad list-head">
|
||||
<h3 class="chart-title" style="margin:0">最近用车记录</h3>
|
||||
<span class="text-soft sm">保养 · 加油 · 充电</span>
|
||||
</div>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr><th>日期</th><th>类型</th><th>车辆</th><th>概要</th><th class="r">花费</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in recentLogs" :key="r.type + r.id" class="row-link" @click="goLog(r)">
|
||||
<td>{{ r.date }}</td>
|
||||
<td><span class="pill" :class="logPill(r.type)">{{ logTypeLabel(r.type) }}</span></td>
|
||||
<td>{{ r.vehicle_name || '—' }}</td>
|
||||
<td class="text-soft sm">{{ r.summary }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ (r.cost || 0).toFixed(2) }}</strong></td>
|
||||
</tr>
|
||||
<tr v-if="!recentLogs.length"><td colspan="5" class="text-mute" style="text-align:center; padding:24px">还没记录,去 <router-link to="/maintenances">保养</router-link> / <router-link to="/refuels">加油</router-link> / <router-link to="/chargings">充电</router-link> 添加</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, reactive, computed, nextTick } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import StatCard from '../components/StatCard.vue';
|
||||
import ChartBlock from '../components/ChartBlock.vue';
|
||||
import * as settingsApi from '../api/settings';
|
||||
import * as washesApi from '../api/washes';
|
||||
import { maintApi, refuelApi, chargingApi } from '../api/logs';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const overview = ref({});
|
||||
const recent = ref([]);
|
||||
const recentLogs = ref([]);
|
||||
const weather = ref({});
|
||||
const cfg = ref({ app: { city: 'Beijing' } });
|
||||
const lowStock = ref([]);
|
||||
|
||||
// 图表数据:响应式,ChartBlock 内部监听更新
|
||||
const freqData = ref({ labels: [], datasets: [{ data: [], backgroundColor: '#7CD0B5', borderRadius: 6, barThickness: 12 }] });
|
||||
const freqOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: true } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 0 } },
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } }, grid: { color: '#E1ECF2' } },
|
||||
},
|
||||
});
|
||||
const typeData = ref({ labels: ['暂无数据'], datasets: [{ data: [1], backgroundColor: ['#E1ECF2'], borderWidth: 0 }] });
|
||||
const typeOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 12 }, usePointStyle: true, boxWidth: 8 } },
|
||||
tooltip: { enabled: true },
|
||||
},
|
||||
cutout: '65%',
|
||||
});
|
||||
|
||||
const kpi = reactive({
|
||||
washes: 0, washesHint: '', washesTrend: 'neutral',
|
||||
cost: 0, costHint: '', costTrend: 'neutral',
|
||||
interval: '—',
|
||||
vehicles: 0, vehiclesHint: '',
|
||||
});
|
||||
|
||||
const greeting = computed(() => {
|
||||
const h = new Date().getHours();
|
||||
const t = h < 6 ? '凌晨好' : h < 12 ? '早上好' : h < 18 ? '下午好' : '晚上好';
|
||||
return `${t},${auth.user?.username || ''}`;
|
||||
});
|
||||
|
||||
const typeMap = { quick: '快速', full: '标准', detail: '精洗', other: '其他' };
|
||||
const washTypeLabel = (t) => typeMap[t] || t || '—';
|
||||
const typePill = (t) => ({ quick: 'pill-blue', full: 'pill-green', detail: 'pill-warn' }[t] || 'pill-gray');
|
||||
|
||||
function goTo(id) { router.push({ name: 'wash-show', params: { id } }); }
|
||||
function goLog(r) {
|
||||
if (r.type === 'maint') router.push('/maintenances');
|
||||
else if (r.type === 'refuel') router.push('/refuels');
|
||||
else if (r.type === 'charge') router.push('/chargings');
|
||||
}
|
||||
const logTypeLabel = (t) => ({ maint: '保养', refuel: '加油', charge: '充电' }[t] || t);
|
||||
const logPill = (t) => ({ maint: 'pill-warn', refuel: 'pill-blue', charge: 'pill-green' }[t] || 'pill-gray');
|
||||
function buildLogSummary(r) {
|
||||
if (r.type === 'maint') {
|
||||
const items = (r.items || []).slice(0, 3).map(x => x.name).filter(Boolean).join('、');
|
||||
return items ? `项目:${items}` : (r.shop ? `店:${r.shop}` : '保养');
|
||||
}
|
||||
if (r.type === 'refuel') {
|
||||
const tag = r.is_full ? '加满' : '补油';
|
||||
return `${r.liters}L ${r.fuel_type || ''} ${tag} · ${r.station || '—'}`;
|
||||
}
|
||||
if (r.type === 'charge') {
|
||||
const soc = r.start_soc != null && r.end_soc != null ? ` ${r.start_soc}→${r.end_soc}%` : '';
|
||||
return `${r.kwh} kWh${soc} · ${r.station || r.charge_type || '—'}`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildChartData(freq, type) {
|
||||
const hasFreq = freq && freq.some(d => d.count > 0);
|
||||
const hasType = type && type.length > 0;
|
||||
freqData.value = {
|
||||
labels: freq ? freq.map(d => d.date) : [],
|
||||
datasets: [{ data: freq ? freq.map(d => d.count) : [], backgroundColor: '#7CD0B5', borderRadius: 6, barThickness: 12 }],
|
||||
};
|
||||
freqOptions.value = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: hasFreq } },
|
||||
scales: {
|
||||
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxRotation: 0 } },
|
||||
y: { beginAtZero: true, ticks: { stepSize: 1, font: { size: 10 } }, grid: { color: '#E1ECF2' } },
|
||||
},
|
||||
};
|
||||
typeData.value = {
|
||||
labels: hasType ? type.map(d => washTypeLabel(d.type)) : ['暂无数据'],
|
||||
datasets: [{
|
||||
data: hasType ? type.map(d => d.count) : [1],
|
||||
backgroundColor: hasType ? ['#1E5B8A', '#4DBA9A', '#E8A33D', '#8A9CAB'] : ['#E1ECF2'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
};
|
||||
typeOptions.value = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { size: 12 }, usePointStyle: true, boxWidth: 8 } },
|
||||
tooltip: { enabled: hasType },
|
||||
},
|
||||
cutout: '65%',
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [ov, ex, list, mR, rR, cR] = await Promise.all([
|
||||
settingsApi.overview(),
|
||||
settingsApi.dashboardExtra(),
|
||||
washesApi.list({ limit: 6 }),
|
||||
maintApi.list({ limit: 3 }),
|
||||
refuelApi.list({ limit: 3 }),
|
||||
chargingApi.list({ limit: 3 }),
|
||||
]);
|
||||
overview.value = ov.data?.overview || {};
|
||||
lowStock.value = ov.data?.low_stock_products || [];
|
||||
weather.value = ex.data?.weather || {};
|
||||
cfg.value = ex.data?.config || cfg.value;
|
||||
const recentRows = list.data?.rows || [];
|
||||
recent.value = recentRows;
|
||||
// 合并三类最近记录
|
||||
const merged = [];
|
||||
for (const r of (mR.data?.rows || [])) merged.push({ type: 'maint', id: r.id, date: r.maint_date, cost: r.total_cost, vehicle_name: r.vehicle_name, items: r.items, shop: r.shop });
|
||||
for (const r of (rR.data?.rows || [])) merged.push({ type: 'refuel', id: r.id, date: r.refuel_date, cost: r.total_cost, vehicle_name: r.vehicle_name, liters: r.liters, fuel_type: r.fuel_type, is_full: r.is_full, station: r.station });
|
||||
for (const r of (cR.data?.rows || [])) merged.push({ type: 'charge', id: r.id, date: r.charge_date, cost: r.total_cost, vehicle_name: r.vehicle_name, kwh: r.kwh, start_soc: r.start_soc, end_soc: r.end_soc, station: r.station, charge_type: r.charge_type });
|
||||
merged.sort((a, b) => (b.date > a.date ? 1 : b.date < a.date ? -1 : 0));
|
||||
recentLogs.value = merged.slice(0, 6).map(r => ({ ...r, summary: buildLogSummary(r) }));
|
||||
// KPI
|
||||
kpi.washes = overview.value.washes_this_month || 0;
|
||||
kpi.washesHint = overview.value.washes_change != null
|
||||
? `${overview.value.washes_change > 0 ? '+' : ''}${overview.value.washes_change}% 较上月`
|
||||
: '本月初至今日';
|
||||
kpi.washesTrend = (overview.value.washes_change || 0) > 0 ? 'up' : (overview.value.washes_change < 0 ? 'down' : 'neutral');
|
||||
kpi.cost = (overview.value.cost_this_month || 0).toFixed(2);
|
||||
kpi.costHint = overview.value.cost_change != null
|
||||
? `${overview.value.cost_change > 0 ? '+' : ''}${overview.value.cost_change}% 较上月`
|
||||
: '本月累计';
|
||||
kpi.costTrend = (overview.value.cost_change || 0) > 0 ? 'up' : (overview.value.cost_change < 0) < 0 ? 'down' : 'neutral';
|
||||
kpi.interval = overview.value.avg_interval_days || '—';
|
||||
kpi.vehicles = overview.value.active_vehicles || 0;
|
||||
kpi.vehiclesHint = `${overview.value.total_vehicles || 0} 辆总数`;
|
||||
|
||||
// 关键顺序:先 loading=false 让 chart-card 进入 DOM,再 nextTick 等挂载完成,
|
||||
// 然后才能画图。之前在 loading=false 之前 await nextTick 是无效的。
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
buildChartData(ov.data?.freq_30d || [], ov.data?.type_dist || []);
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page { display: flex; flex-direction: column; gap: 0; }
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 24px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||
.row { display: grid; grid-template-columns: 1.4fr 1fr; gap: 18px; }
|
||||
.chart-card { display: flex; flex-direction: column; }
|
||||
.chart-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 16px; }
|
||||
.chart-wrap { position: relative; height: 180px; width: 100%; }
|
||||
.list-head { display: flex; justify-content: space-between; align-items: center; padding-bottom: 0; }
|
||||
.row-link { cursor: pointer; }
|
||||
.weather-head { display: flex; justify-content: space-between; align-items: center; }
|
||||
.weather-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-top: 16px; }
|
||||
.metric { background: var(--bg-soft); border-radius: 10px; padding: 14px; text-align: center; }
|
||||
.m-value { font-size: 22px; font-weight: 600; }
|
||||
.m-label { font-size: 12px; color: var(--text-soft); margin-top: 4px; }
|
||||
|
||||
/* 低库存预警红条 */
|
||||
.low-stock-alert {
|
||||
background: #FEF2F2;
|
||||
border: 1px solid #FECACA;
|
||||
border-left: 4px solid var(--danger);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 18px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.alert-head { display: flex; align-items: center; gap: 10px; font-size: 14px; }
|
||||
.alert-icon { font-size: 18px; }
|
||||
.alert-count { color: var(--text-soft); font-size: 13px; }
|
||||
.alert-link { margin-left: auto; color: var(--danger); font-size: 13px; font-weight: 500; }
|
||||
.alert-list { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; margin-top: 10px; }
|
||||
.alert-item {
|
||||
background: #fff; border: 1px solid #FEE2E2; border-radius: var(--radius-sm);
|
||||
padding: 8px 12px; display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 13px; transition: all .15s;
|
||||
}
|
||||
.alert-item:hover { background: #FFF1F2; border-color: #FCA5A5; }
|
||||
.item-name { font-weight: 500; }
|
||||
.item-meta { color: var(--text-soft); font-size: 12px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.weather-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.alert-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head .btn { width: 100%; justify-content: center; }
|
||||
.kpi-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.chart-wrap { height: 160px; }
|
||||
.low-stock-alert { padding: 12px 16px; }
|
||||
.alert-head { flex-wrap: wrap; gap: 6px; }
|
||||
.alert-count { width: 100%; }
|
||||
.alert-link { margin-left: 0; }
|
||||
.list-head { flex-direction: column; align-items: flex-start; gap: 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.kpi-grid { grid-template-columns: 1fr; }
|
||||
.weather-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,452 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">保险记录</h1>
|
||||
<p class="subtitle text-soft">交强 / 商业 / 三责 / 车损… 保单附件图片/PDF 直接在线看</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="openNew">+ 新建保单</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI 卡片 -->
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi-card kpi-blue">
|
||||
<div class="kpi-label">总保单</div>
|
||||
<div class="kpi-val">{{ data.stats?.total || 0 }}</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-green">
|
||||
<div class="kpi-label">有效中</div>
|
||||
<div class="kpi-val">{{ data.stats?.active_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-warn">
|
||||
<div class="kpi-label">30 天内到期</div>
|
||||
<div class="kpi-val">{{ data.stats?.expiring_count || 0 }}</div>
|
||||
</div>
|
||||
<div class="kpi-card kpi-gray">
|
||||
<div class="kpi-label">累计保费</div>
|
||||
<div class="kpi-val">¥{{ (data.stats?.total_premium || 0).toFixed(0) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 过滤 -->
|
||||
<div class="card card-pad filters mt-3">
|
||||
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||
<option value="">全部车辆</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
<select v-model="filters.status" class="select sm" @change="load">
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">有效</option>
|
||||
<option value="expiring">30 天内到期</option>
|
||||
<option value="expired">已过期</option>
|
||||
</select>
|
||||
<div class="stats-pills">
|
||||
<span class="pill pill-blue">展示 {{ data.rows?.length || 0 }} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="card mt-3">
|
||||
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有保单记录</div>
|
||||
<MobileCardList
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="data.rows"
|
||||
row-key="id"
|
||||
empty-text="还没有保单记录"
|
||||
>
|
||||
<template #cell-status="{ row }">
|
||||
<span :class="['pill', statusPill(row.status)]">
|
||||
{{ statusLabel(row.status) }}
|
||||
<span v-if="row.status === 'expiring'" class="sm">· {{ row.days_to_expire }}d</span>
|
||||
<span v-else-if="row.status === 'expired'" class="sm">· {{ -row.days_to_expire }}d</span>
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-type="{ row }"><strong>{{ row.insurance_type }}</strong></template>
|
||||
<template #cell-vehicle="{ row }">
|
||||
<div>{{ row.vehicle_name }}</div>
|
||||
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||
</template>
|
||||
<template #cell-company="{ row }">{{ row.company || '—' }}</template>
|
||||
<template #cell-policy="{ row }" class="sm">{{ row.policy_no || '—' }}</template>
|
||||
<template #cell-period="{ row }">
|
||||
<div>{{ row.start_date }}</div>
|
||||
<div class="text-soft sm">→ {{ row.end_date }}</div>
|
||||
</template>
|
||||
<template #cell-premium="{ row }">
|
||||
<strong class="text-brand">¥{{ (row.premium || 0).toFixed(0) }}</strong>
|
||||
</template>
|
||||
<template #cell-attachment="{ row }">
|
||||
<span v-if="row.attachment_path">
|
||||
<a :href="`/api/${row.attachment_path}`" target="_blank" class="btn btn-ghost btn-sm" @click.stop>查看</a>
|
||||
</span>
|
||||
<span v-else class="text-mute sm">无</span>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-ghost btn-sm" @click.stop="openUpload(row)">上传</button>
|
||||
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="showDelete"
|
||||
v-model="showDelete"
|
||||
title="删除保单"
|
||||
:message="`确认删除「${deleteTarget?.insurance_type}」保单(${deleteTarget?.start_date} → ${deleteTarget?.end_date})?`"
|
||||
mode="type"
|
||||
confirm-label="确认删除"
|
||||
:tips="['已删除保单可在「操作日志」中恢复']"
|
||||
:busy="deleteBusy"
|
||||
:error="deleteError"
|
||||
@confirm="doDelete"
|
||||
@cancel="showDelete = false"
|
||||
/>
|
||||
|
||||
<!-- 表单弹窗 -->
|
||||
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||
<div class="modal card card-pad">
|
||||
<div class="modal-head">
|
||||
<h3 class="section-title">{{ form.id ? '编辑保单' : '新建保单' }}</h3>
|
||||
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别保单' }}</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSave">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.vehicle_id" class="select" required>
|
||||
<option :value="null">— 请选择 —</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">险种 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.insurance_type" class="select" required>
|
||||
<option v-for="t in data.types || TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">保险公司</label>
|
||||
<input v-model="form.company" class="input" placeholder="人保 / 平安 / 太保 / 中华 / …" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">保单号</label>
|
||||
<input v-model="form.policy_no" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">生效日 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.start_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">到期日 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.end_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">保费 (¥)</label>
|
||||
<input v-model.number="form.premium" type="number" step="0.01" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">保额 (¥)</label>
|
||||
<input v-model.number="form.coverage_amount" type="number" step="0.01" min="0" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||
</div>
|
||||
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传弹窗 -->
|
||||
<div v-if="showUpload" class="modal-mask" @click.self="showUpload = false">
|
||||
<div class="modal card card-pad">
|
||||
<h3 class="section-title">上传保单附件</h3>
|
||||
<p class="text-soft sm mb-3">支持图片 (jpg/png/webp/heic) 和 PDF,最大 10MB</p>
|
||||
<form @submit.prevent="onUpload">
|
||||
<div class="upload-zone" :class="{ dragover: dragOver }" @dragover.prevent="dragOver = true" @dragleave="dragOver = false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept="image/*,application/pdf" @change="onFileChange" style="display:none" />
|
||||
<div v-if="!formFile" class="upload-empty" @click="fileInput.click()">
|
||||
<div class="upload-icon">📎</div>
|
||||
<div>点击或拖拽文件到此处</div>
|
||||
<div class="text-soft sm">jpg / png / webp / heic / pdf</div>
|
||||
</div>
|
||||
<div v-else class="upload-filled">
|
||||
<div class="upload-filename">{{ formFile.name }}</div>
|
||||
<div class="text-soft sm">{{ formatSize(formFile.size) }} · {{ formFile.type || 'unknown' }}</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm mt-2" @click="formFile = null; fileInput.value = ''">换文件</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="uploadError" class="error mt-3">{{ uploadError }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button type="button" class="btn btn-ghost" @click="showUpload = false">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="uploadBusy || !formFile">{{ uploadBusy ? '上传中…' : '上传' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import * as insuranceApi from '../api/insurance';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
|
||||
const TYPES = ['交强险', '商业险', '车损险', '三责险', '座位险', '不计免赔', '玻璃险', '划痕险', '自燃险', '涉水险'];
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'status', label: '状态', alwaysShow: true },
|
||||
{ key: 'type', label: '险种', primary: true, alwaysShow: true },
|
||||
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||
{ key: 'company', label: '保险公司' },
|
||||
{ key: 'policy', label: '保单号' },
|
||||
{ key: 'period', label: '生效 → 到期', alwaysShow: true },
|
||||
{ key: 'premium', label: '保费', alwaysShow: true },
|
||||
{ key: 'attachment', label: '附件' },
|
||||
];
|
||||
|
||||
const vehicles = ref([]);
|
||||
const data = ref({ rows: [], total: 0, stats: {}, types: TYPES });
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ vehicle_id: '', status: '' });
|
||||
|
||||
const showForm = ref(false);
|
||||
const form = reactive({ id: null, vehicle_id: null, insurance_type: '交强险', company: '', policy_no: '', start_date: '', end_date: '', premium: null, coverage_amount: null, notes: '' });
|
||||
const formBusy = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// 401 草稿
|
||||
const draft = useFormDraft('insurances/new');
|
||||
const restored = draft.load();
|
||||
if (restored) Object.assign(form, restored);
|
||||
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
// AI 识别
|
||||
const ai = useAiRecognize();
|
||||
const aiBusy = ai.busy;
|
||||
async function onAiRecognize() {
|
||||
await ai.open('insurance', (data) => {
|
||||
if (data.insurance_type) form.insurance_type = data.insurance_type;
|
||||
if (data.company) form.company = data.company;
|
||||
if (data.policy_no) form.policy_no = data.policy_no;
|
||||
if (data.start_date) form.start_date = data.start_date;
|
||||
if (data.end_date) form.end_date = data.end_date;
|
||||
if (data.premium != null) form.premium = data.premium;
|
||||
if (data.coverage_amount != null) form.coverage_amount = data.coverage_amount;
|
||||
});
|
||||
}
|
||||
|
||||
const showUpload = ref(false);
|
||||
const formFile = ref(null);
|
||||
const fileInput = ref(null);
|
||||
const uploadTargetId = ref(null);
|
||||
const uploadBusy = ref(false);
|
||||
const uploadError = ref('');
|
||||
const dragOver = ref(false);
|
||||
|
||||
const statusLabel = (s) => ({ active: '有效', expiring: '即将到期', expired: '已过期' }[s] || s);
|
||||
const statusPill = (s) => ({ active: 'pill-green', expiring: 'pill-warn', expired: 'pill-gray' }[s] || 'pill-gray');
|
||||
const formatSize = (b) => {
|
||||
if (!b) return '';
|
||||
if (b < 1024) return b + ' B';
|
||||
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
|
||||
return (b / 1024 / 1024).toFixed(2) + ' MB';
|
||||
};
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {};
|
||||
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||
if (filters.status) params.status = filters.status;
|
||||
const r = await insuranceApi.list(params);
|
||||
data.value = r.data;
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadVehicles() {
|
||||
const r = await vehiclesApi.list();
|
||||
vehicles.value = r.data || [];
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
Object.assign(form, { id: null, vehicle_id: null, insurance_type: '交强险', company: '', policy_no: '', start_date: today(), end_date: '', premium: null, coverage_amount: null, notes: '' });
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(r) {
|
||||
Object.assign(form, r);
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function closeForm() { showForm.value = false; }
|
||||
|
||||
async function onSave() {
|
||||
formError.value = '';
|
||||
formBusy.value = true;
|
||||
try {
|
||||
const body = {
|
||||
vehicle_id: form.vehicle_id,
|
||||
insurance_type: form.insurance_type,
|
||||
company: form.company || null,
|
||||
policy_no: form.policy_no || null,
|
||||
start_date: form.start_date,
|
||||
end_date: form.end_date,
|
||||
premium: form.premium ?? null,
|
||||
coverage_amount: form.coverage_amount ?? null,
|
||||
notes: form.notes || null,
|
||||
};
|
||||
if (form.id) await insuranceApi.update(form.id, body);
|
||||
else await insuranceApi.create(body);
|
||||
draft.clear();
|
||||
closeForm();
|
||||
await load();
|
||||
} catch (e) {
|
||||
const errs = e.response?.data?.error?.errors;
|
||||
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||
} finally { formBusy.value = false; }
|
||||
}
|
||||
|
||||
function openUpload(r) {
|
||||
uploadTargetId.value = r.id;
|
||||
formFile.value = null;
|
||||
uploadError.value = '';
|
||||
showUpload.value = true;
|
||||
}
|
||||
|
||||
function onFileChange(e) { formFile.value = e.target.files[0] || null; }
|
||||
function onDrop(e) {
|
||||
dragOver.value = false;
|
||||
const f = e.dataTransfer.files[0];
|
||||
if (f) formFile.value = f;
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
if (!formFile.value) return;
|
||||
uploadError.value = '';
|
||||
uploadBusy.value = true;
|
||||
try {
|
||||
await insuranceApi.upload(uploadTargetId.value, formFile.value);
|
||||
showUpload.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
uploadError.value = e.response?.data?.error?.message || e.message || '上传失败';
|
||||
} finally { uploadBusy.value = false; }
|
||||
}
|
||||
|
||||
async function onDelete(r) {
|
||||
deleteTarget.value = r;
|
||||
deleteError.value = '';
|
||||
showDelete.value = true;
|
||||
}
|
||||
|
||||
const showDelete = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteBusy = ref(false);
|
||||
const deleteError = ref('');
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
deleteBusy.value = true;
|
||||
deleteError.value = '';
|
||||
try {
|
||||
await insuranceApi.remove(deleteTarget.value.id);
|
||||
showDelete.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||
} finally {
|
||||
deleteBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10); }
|
||||
|
||||
onMounted(() => { loadVehicles(); load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; }
|
||||
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||
|
||||
.kpi-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
|
||||
.kpi-card { background:var(--card); border-radius:var(--radius); padding:16px 20px; }
|
||||
.kpi-label { font-size:12px; color:var(--text-soft); }
|
||||
.kpi-val { font-size:28px; font-weight:700; letter-spacing:-0.02em; font-variant-numeric:tabular-nums; margin-top:4px; }
|
||||
.kpi-blue .kpi-val { color:#1E5B8A; }
|
||||
.kpi-green .kpi-val { color:#10B981; }
|
||||
.kpi-warn .kpi-val { color:#E8A33D; }
|
||||
.kpi-gray .kpi-val { color:var(--text-soft); }
|
||||
|
||||
.filters { display:flex; gap:10px; align-items:center; }
|
||||
.stats-pills { margin-left:auto; display:flex; gap:8px; }
|
||||
|
||||
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
.modal-head .section-title { margin:0; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.r { text-align:right; }
|
||||
.mt-2 { margin-top:8px; }
|
||||
.mt-3 { margin-top:12px; }
|
||||
.text-soft { color:var(--text-soft); }
|
||||
.text-mute { color:var(--text-mute); }
|
||||
.text-danger { color:var(--danger); }
|
||||
.text-brand { color:var(--brand); }
|
||||
.sm { font-size:11px; }
|
||||
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||
|
||||
.upload-zone { border:2px dashed var(--border,#E5E7EB); border-radius:var(--radius); padding:32px; text-align:center; transition:all .15s; cursor:pointer; }
|
||||
.upload-zone.dragover { border-color:var(--accent); background:rgba(0,194,160,0.05); }
|
||||
.upload-empty { display:flex; flex-direction:column; gap:6px; align-items:center; color:var(--text-soft); }
|
||||
.upload-icon { font-size:36px; }
|
||||
.upload-filled { display:flex; flex-direction:column; gap:4px; align-items:center; }
|
||||
.upload-filename { font-weight:600; word-break:break-all; }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.kpi-grid { grid-template-columns:repeat(2,1fr); }
|
||||
.grid { grid-template-columns:1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.head { flex-direction: column; align-items: stretch; }
|
||||
.head .btn { width: 100%; justify-content: center; }
|
||||
.filters { padding: 12px 16px; }
|
||||
.stats-pills { width: 100%; margin-left: 0; }
|
||||
.modal-mask { align-items: flex-end; padding: 0; }
|
||||
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.kpi-grid { grid-template-columns: 1fr 1fr; }
|
||||
.kpi-val { font-size: 22px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="card login-card">
|
||||
<div class="brand">
|
||||
<span class="logo">CL</span>
|
||||
<span class="brand-name">CarLog</span>
|
||||
<span class="brand-sub">车记</span>
|
||||
</div>
|
||||
<h1 class="title">欢迎回来</h1>
|
||||
<p class="subtitle">{{ subtitleText }}</p>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="form">
|
||||
<div class="field">
|
||||
<label class="label" for="login-username">用户名</label>
|
||||
<input
|
||||
id="login-username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="input"
|
||||
autocomplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label" for="login-password">密码</label>
|
||||
<input
|
||||
id="login-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
class="input"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<p v-if="error" class="error">
|
||||
<span class="error-msg">{{ error.message }}</span>
|
||||
<span v-if="error.locked" class="error-meta">
|
||||
⏱ 锁定至 <strong>{{ formatTime(error.lockedUntil) }}</strong>,还有 <strong>{{ formatRemain(error.retryAfter) }}</strong> 解锁
|
||||
</span>
|
||||
<span v-else-if="error.failCount > 0" class="error-meta">
|
||||
⚠️ 已错 <strong>{{ error.failCount }}</strong> 次(上限 {{ error.failMax }}),
|
||||
还剩 <strong>{{ error.failRemaining }}</strong> 次,再错将锁定 <strong>{{ error.lockMinutes }}</strong> 分钟
|
||||
</span>
|
||||
</p>
|
||||
<button class="btn btn-primary submit" :disabled="busy">
|
||||
{{ busy ? '登录中…' : '登录' }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="hint">首次使用?默认账号 <code>admin</code> / 密码 <code>carwash2026</code>,登录后请到「设置 → 账户」修改。</p>
|
||||
</div>
|
||||
<div class="deco">
|
||||
<div class="bubble b1"></div>
|
||||
<div class="bubble b2"></div>
|
||||
<div class="bubble b3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const username = ref('admin');
|
||||
const password = ref('');
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
// 401 重定向过来会带 ?reason=expired&redirect=/原页
|
||||
const subtitleText = computed(() => {
|
||||
if (route.query.reason === 'expired') return '登录已过期,重新登录后会自动回到原页面';
|
||||
return '登录管理你的爱车';
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = null;
|
||||
busy.value = true;
|
||||
try {
|
||||
await auth.login(username.value, password.value);
|
||||
const next = route.query.redirect || '/';
|
||||
router.push(next);
|
||||
} catch (e) {
|
||||
const r = e.response?.data?.error || {};
|
||||
const code = r.code;
|
||||
if (code === 'LOCKED') {
|
||||
error.value = {
|
||||
message: r.message || '登录失败次数过多,账号已锁定',
|
||||
locked: true,
|
||||
lockedUntil: r.locked_until,
|
||||
retryAfter: r.retry_after,
|
||||
};
|
||||
} else if (code === 'BAD_CREDENTIALS') {
|
||||
error.value = {
|
||||
message: r.message || '用户名或密码错误',
|
||||
locked: false,
|
||||
failCount: r.fail_count || 0,
|
||||
failMax: r.fail_max || 5,
|
||||
failRemaining: r.fail_remaining || 0,
|
||||
lockMinutes: r.lock_minutes || 10,
|
||||
};
|
||||
} else {
|
||||
error.value = { message: r.message || e.message || '登录失败', locked: false };
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function formatRemain(seconds) {
|
||||
if (!seconds || seconds <= 0) return '0 秒';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m === 0) return `${s} 秒`;
|
||||
if (s === 0) return `${m} 分钟`;
|
||||
return `${m} 分 ${s} 秒`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
background: var(--bg); position: relative; overflow: hidden; padding: 24px;
|
||||
}
|
||||
.login-card {
|
||||
width: 100%; max-width: 420px; padding: 40px 36px;
|
||||
box-shadow: 0 12px 40px rgba(40, 80, 110, 0.10);
|
||||
position: relative; z-index: 2;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
|
||||
.logo {
|
||||
width: 40px; height: 40px; border-radius: 10px;
|
||||
background: var(--accent); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 16px; font-weight: 700; letter-spacing: -0.02em;
|
||||
}
|
||||
.brand-name { font-size: 18px; font-weight: 600; }
|
||||
.brand-sub { font-size: 14px; color: var(--text-soft); font-weight: 500; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0 0 6px; letter-spacing: -0.02em; }
|
||||
.subtitle { color: var(--text-soft); margin: 0 0 28px; font-size: 14px; }
|
||||
.form { display: flex; flex-direction: column; gap: 14px; }
|
||||
.field { display: flex; flex-direction: column; }
|
||||
.submit { margin-top: 8px; height: 44px; font-size: 15px; }
|
||||
.error {
|
||||
color: var(--danger); font-size: 13px;
|
||||
background: #FBE3DF; padding: 10px 14px; border-radius: var(--radius-sm);
|
||||
margin: 0;
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.error-msg { font-weight: 500; }
|
||||
.error-meta {
|
||||
font-size: 12px;
|
||||
color: #8B3530;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-weight: 400;
|
||||
}
|
||||
.error-meta strong { font-weight: 700; color: var(--danger); }
|
||||
.hint {
|
||||
color: var(--text-soft); font-size: 12px; margin-top: 22px; line-height: 1.6;
|
||||
}
|
||||
.hint code {
|
||||
background: var(--bg-soft); padding: 1px 6px; border-radius: 4px;
|
||||
font-size: 12px; color: var(--text);
|
||||
}
|
||||
.deco { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
|
||||
.bubble { position: absolute; border-radius: 50%; opacity: .35; }
|
||||
.b1 { width: 320px; height: 320px; background: var(--brand-soft); top: -80px; right: -80px; }
|
||||
.b2 { width: 200px; height: 200px; background: var(--green-soft); bottom: 40px; left: -40px; }
|
||||
.b3 { width: 140px; height: 140px; background: #B8E0D4; bottom: 200px; right: 200px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.login-page { padding: 16px; }
|
||||
.login-card { padding: 28px 22px; border-radius: 14px; }
|
||||
.login-title { font-size: 22px; }
|
||||
.login-sub { font-size: 13px; }
|
||||
.input { font-size: 16px; } /* 防止 iOS 自动缩放 */
|
||||
.login-btn { min-height: 48px; font-size: 15px; }
|
||||
.b1 { width: 220px; height: 220px; }
|
||||
.b2 { width: 140px; height: 140px; }
|
||||
.b3 { width: 100px; height: 100px; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.login-card { padding: 24px 18px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,369 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">保养记录</h1>
|
||||
<p class="subtitle text-soft">机油、机滤、刹车油、轮胎… 每次保养 + 下次保养里程</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button class="btn btn-primary" @click="openNew">+ 新建保养</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 过滤条 -->
|
||||
<div class="card card-pad filters">
|
||||
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||
<option value="">全部车辆</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
<input v-model="filters.from" type="date" class="input sm" @change="load" />
|
||||
<span class="text-soft">至</span>
|
||||
<input v-model="filters.to" type="date" class="input sm" @change="load" />
|
||||
<div class="stats-pills">
|
||||
<span class="pill pill-blue">{{ data.total || 0 }} 条</span>
|
||||
<span class="pill pill-green">¥{{ (data.stats?.total_cost || 0).toFixed(2) }} 总花费</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 列表 -->
|
||||
<div class="card mt-3">
|
||||
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有保养记录,点右上「+ 新建保养」开始</div>
|
||||
<MobileCardList
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="data.rows"
|
||||
row-key="id"
|
||||
empty-text="还没有保养记录"
|
||||
>
|
||||
<template #cell-date="{ row }">{{ row.maint_date }}</template>
|
||||
<template #cell-vehicle="{ row }">
|
||||
<div>{{ row.vehicle_name || '—' }}</div>
|
||||
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||
</template>
|
||||
<template #cell-items="{ row }">
|
||||
<span v-for="(it, i) in row.items" :key="i" class="pill pill-gray sm mr-1">{{ it.name }}</span>
|
||||
</template>
|
||||
<template #cell-odo="{ row }">{{ row.odometer_km ? row.odometer_km + ' km' : '—' }}</template>
|
||||
<template #cell-evhev="{ row }">
|
||||
<span v-if="row.ev_km != null" class="pill pill-green sm">EV {{ row.ev_km }}</span>
|
||||
<span v-if="row.hev_km != null" class="pill pill-blue sm">HEV {{ row.hev_km }}</span>
|
||||
<span v-if="row.ev_km == null && row.hev_km == null" class="text-mute sm">—</span>
|
||||
</template>
|
||||
<template #cell-shop="{ row }">{{ row.shop || '—' }}</template>
|
||||
<template #cell-next="{ row }">{{ row.next_due_km ? row.next_due_km + ' km' : '—' }}</template>
|
||||
<template #cell-cost="{ row }">
|
||||
<strong class="text-brand">¥{{ (row.total_cost || 0).toFixed(2) }}</strong>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="showDelete"
|
||||
v-model="showDelete"
|
||||
title="删除保养记录"
|
||||
:message="`确认删除 ${deleteTarget?.maint_date} 的保养记录?`"
|
||||
mode="type"
|
||||
confirm-label="确认删除"
|
||||
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||
:busy="deleteBusy"
|
||||
:error="deleteError"
|
||||
@confirm="doDelete"
|
||||
@cancel="showDelete = false"
|
||||
/>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||
<div class="modal card card-pad big-modal">
|
||||
<div class="modal-head">
|
||||
<h3 class="section-title">{{ form.id ? '编辑保养' : '新建保养' }}</h3>
|
||||
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别小票' }}</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSave">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.vehicle_id" class="select" required>
|
||||
<option :value="null">— 请选择 —</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">日期 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.maint_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">总里程 (km)</label>
|
||||
<input v-model.number="form.odometer_km" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">EV 里程 (km) <span class="text-soft sm">纯电</span></label>
|
||||
<input v-model.number="form.ev_km" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">HEV 里程 (km) <span class="text-soft sm">混动</span></label>
|
||||
<input v-model.number="form.hev_km" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">店名</label>
|
||||
<input v-model="form.shop" class="input" placeholder="如 途虎养车 / 4S店" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">下次保养里程 (km)</label>
|
||||
<input v-model.number="form.next_due_km" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">下次保养日期</label>
|
||||
<input v-model="form.next_due_date" type="date" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="section-title mt-3">保养项目</h4>
|
||||
<table class="data">
|
||||
<thead><tr><th style="width:50%">项目</th><th>费用 ¥</th><th>间隔 km</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="(it, i) in form.items" :key="i">
|
||||
<td>
|
||||
<input v-model="it.name" class="input sm" placeholder="如 机油 5W-30" :list="`maint-presets`" />
|
||||
<datalist id="maint-presets">
|
||||
<option v-for="p in MAINT_PRESETS" :key="p" :value="p"></option>
|
||||
</datalist>
|
||||
</td>
|
||||
<td><input v-model.number="it.cost" type="number" step="0.01" min="0" class="input sm" /></td>
|
||||
<td><input v-model.number="it.interval_km" type="number" min="0" class="input sm" placeholder="5000" /></td>
|
||||
<td><button type="button" class="btn btn-ghost btn-sm" @click="form.items.splice(i,1)">×</button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" class="btn btn-ghost btn-sm mt-2" @click="form.items.push({name:'',cost:0,interval_km:null})">+ 加项目</button>
|
||||
<div class="text-soft sm mt-2">合计 ¥{{ itemsTotal.toFixed(2) }}</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import { maintApi } from '../api/logs';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||
{ key: 'items', label: '项目' },
|
||||
{ key: 'odo', label: '总里程' },
|
||||
{ key: 'evhev', label: 'EV/HEV' },
|
||||
{ key: 'shop', label: '店名' },
|
||||
{ key: 'next', label: '下次里程' },
|
||||
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||
];
|
||||
|
||||
const MAINT_PRESETS = [
|
||||
'机油 5W-30','机油 5W-40','机滤','空滤','空调滤','汽油滤','火花塞',
|
||||
'刹车油','变速箱油','防冻液','电瓶','刹车片','刹车盘','轮胎',
|
||||
'四轮定位','动平衡','更换雨刮','添加玻璃水','底盘装甲'
|
||||
];
|
||||
|
||||
const vehicles = ref([]);
|
||||
const data = ref({ rows: [], total: 0, stats: {} });
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ vehicle_id: '', from: '', to: '' });
|
||||
|
||||
const showForm = ref(false);
|
||||
const form = reactive({ id: null, vehicle_id: null, maint_date: today(), odometer_km: null, shop: '', next_due_km: null, next_due_date: '', items: [], notes: '' });
|
||||
const formBusy = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// 401 草稿
|
||||
const draft = useFormDraft('maints/new');
|
||||
const restored = draft.load();
|
||||
if (restored) {
|
||||
Object.assign(form, restored);
|
||||
if (!Array.isArray(form.items)) form.items = [];
|
||||
}
|
||||
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
// AI 识别
|
||||
const ai = useAiRecognize();
|
||||
const aiBusy = ai.busy;
|
||||
async function onAiRecognize() {
|
||||
await ai.open('maint', (data) => {
|
||||
if (data.maint_date) form.maint_date = data.maint_date;
|
||||
if (data.total_cost != null) form.total_cost = data.total_cost;
|
||||
if (data.shop) form.shop = data.shop;
|
||||
if (data.odometer_km) form.odometer_km = data.odometer_km;
|
||||
if (data.next_due_km) form.next_due_km = data.next_due_km;
|
||||
if (Array.isArray(data.items) && data.items.length) {
|
||||
form.items = data.items.filter(x => x.name).map(x => ({ name: x.name, cost: Number(x.cost || 0), interval_km: 5000 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const itemsTotal = computed(() => form.items.reduce((s, x) => s + Number(x.cost || 0), 0));
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10); }
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {};
|
||||
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||
if (filters.from) params.from = filters.from;
|
||||
if (filters.to) params.to = filters.to;
|
||||
const r = await maintApi.list(params);
|
||||
data.value = r.data;
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadVehicles() {
|
||||
const r = await vehiclesApi.list();
|
||||
vehicles.value = r.data || [];
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
Object.assign(form, { id: null, vehicle_id: null, maint_date: today(), odometer_km: null, ev_km: null, hev_km: null, shop: '', next_due_km: null, next_due_date: '', items: [{name:'',cost:0,interval_km:5000}], notes: '' });
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(r) {
|
||||
Object.assign(form, { ...r, items: r.items?.length ? r.items.map(x => ({...x})) : [{name:'',cost:0,interval_km:5000}] });
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function closeForm() { showForm.value = false; }
|
||||
|
||||
async function onSave() {
|
||||
formError.value = '';
|
||||
if (!form.vehicle_id) { formError.value = '请选择车辆'; return; }
|
||||
if (form.items.length) {
|
||||
const sum = form.items.reduce((s, x) => s + Number(x.cost || 0), 0);
|
||||
form.total_cost = Math.round(sum * 100) / 100;
|
||||
}
|
||||
formBusy.value = true;
|
||||
try {
|
||||
const body = {
|
||||
vehicle_id: form.vehicle_id,
|
||||
maint_date: form.maint_date,
|
||||
odometer_km: form.odometer_km || null,
|
||||
ev_km: form.ev_km || null,
|
||||
hev_km: form.hev_km || null,
|
||||
total_cost: form.total_cost || 0,
|
||||
shop: form.shop || null,
|
||||
items_json: JSON.stringify(form.items.filter(x => x.name)),
|
||||
next_due_date: form.next_due_date || null,
|
||||
next_due_km: form.next_due_km || null,
|
||||
notes: form.notes || null,
|
||||
};
|
||||
if (form.id) await maintApi.update(form.id, body);
|
||||
else await maintApi.create(body);
|
||||
draft.clear();
|
||||
closeForm();
|
||||
await load();
|
||||
} catch (e) {
|
||||
const errs = e.response?.data?.error?.errors;
|
||||
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||
} finally { formBusy.value = false; }
|
||||
}
|
||||
|
||||
async function onDelete(r) {
|
||||
deleteTarget.value = r;
|
||||
deleteError.value = '';
|
||||
showDelete.value = true;
|
||||
}
|
||||
|
||||
const showDelete = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteBusy = ref(false);
|
||||
const deleteError = ref('');
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
deleteBusy.value = true;
|
||||
deleteError.value = '';
|
||||
try {
|
||||
await maintApi.remove(deleteTarget.value.id);
|
||||
showDelete.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||
} finally {
|
||||
deleteBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadVehicles(); load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; }
|
||||
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||
.filters { display:flex; gap:10px; align-items:center; }
|
||||
.stats-pills { margin-left:auto; display:flex; gap:8px; }
|
||||
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
|
||||
.modal-head .section-title { margin:0; }
|
||||
.big-modal { max-width:800px; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.r { text-align:right; }
|
||||
.mr-1 { margin-right:4px; }
|
||||
.mt-2 { margin-top:8px; }
|
||||
.mt-3 { margin-top:12px; }
|
||||
.text-soft { color:var(--text-soft); }
|
||||
.text-mute { color:var(--text-mute); }
|
||||
.text-danger { color:var(--danger); }
|
||||
.text-brand { color:var(--brand); }
|
||||
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 1023px) {
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; }
|
||||
.head .btn { width: 100%; justify-content: center; }
|
||||
.filters { padding: 12px 16px; }
|
||||
.stats-pills { width: 100%; margin-left: 0; }
|
||||
.modal-mask { align-items: flex-end; padding: 0; }
|
||||
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="offline">
|
||||
<div class="icon">📡</div>
|
||||
<h1>暂时无法连接到网络</h1>
|
||||
<p class="text-soft">离线模式下,已缓存的页面仍可继续浏览。恢复网络后可使用完整功能。</p>
|
||||
<button class="btn btn-primary" @click="onRetry">重新连接</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const onRetry = () => {
|
||||
if (navigator.onLine) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert('设备仍处于离线状态,请检查网络后重试。');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.offline {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
background: var(--bg, #f5f8fc);
|
||||
color: var(--text, #1f2937);
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
.icon {
|
||||
font-size: 80px;
|
||||
margin-bottom: 24px;
|
||||
filter: grayscale(0.3);
|
||||
}
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
p {
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
margin: 0 0 28px;
|
||||
max-width: 400px;
|
||||
}
|
||||
.btn {
|
||||
padding: 12px 28px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
background: #1b6ef3;
|
||||
color: #fff;
|
||||
}
|
||||
.btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">操作日志</h1>
|
||||
<p class="subtitle text-soft">记录所有"会改变数据"的操作 · 共 {{ total }} 条</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计 -->
|
||||
<div class="stats-row" v-if="stats && stats.by_action.length">
|
||||
<div v-for="s in stats.by_action" :key="s.action" class="stat-card">
|
||||
<div class="stat-num">{{ s.c }}</div>
|
||||
<div class="stat-label">{{ actionLabel(s.action) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card filter">
|
||||
<div class="filter-row">
|
||||
<div>
|
||||
<label class="label">操作类型</label>
|
||||
<select v-model="filters.action" class="select" @change="reload">
|
||||
<option value="">全部</option>
|
||||
<option v-for="a in actionOptions" :key="a.value" :value="a.value">{{ a.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">对象类型</label>
|
||||
<select v-model="filters.target_type" class="select" @change="reload">
|
||||
<option value="">全部</option>
|
||||
<option v-for="t in targetOptions" :key="t.value" :value="t.value">{{ t.label }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">操作人</label>
|
||||
<input v-model="filters.username" class="input" placeholder="用户名" @change="reload" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">开始</label>
|
||||
<input v-model="filters.from" type="date" class="input" @change="reload" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">结束</label>
|
||||
<input v-model="filters.to" type="date" class="input" @change="reload" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<MobileCardList
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
row-key="id"
|
||||
:clickable="false"
|
||||
empty-text="暂无日志"
|
||||
>
|
||||
<template #cell-time="{ row }">
|
||||
<span class="text-soft sm">{{ row.created_at }}</span>
|
||||
</template>
|
||||
<template #cell-user="{ row }">{{ row.username || '—' }}</template>
|
||||
<template #cell-action="{ row }">
|
||||
<span class="pill" :class="actionPill(row.action)">{{ row.action_label }}</span>
|
||||
</template>
|
||||
<template #cell-target="{ row }">{{ row.target_label }}</template>
|
||||
<template #cell-summary="{ row }">
|
||||
<div>{{ row.target_summary || '—' }}</div>
|
||||
<button v-if="row.detail" class="btn-link" @click="toggleDetail(row.id)" style="font-size:12px; margin-top:4px">
|
||||
{{ expanded.has(row.id) ? '收起详情' : '查看详情' }}
|
||||
</button>
|
||||
<div v-if="expanded.has(row.id) && row.detail" class="detail-box">
|
||||
<div class="detail-meta">
|
||||
<span>IP: {{ row.ip || '—' }}</span>
|
||||
<span>UA: {{ row.user_agent || '—' }}</span>
|
||||
</div>
|
||||
<pre>{{ formatDetail(row.detail) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button
|
||||
v-if="row.recoverable"
|
||||
class="btn btn-ghost btn-sm text-recover"
|
||||
@click.stop="askRecover(row)"
|
||||
>恢复</button>
|
||||
<span v-else-if="row.recovered_at" class="badge badge-green">已恢复</span>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
|
||||
<div v-if="total > limit" class="pager">
|
||||
<button class="btn btn-ghost" :disabled="page <= 1" @click="page--; reload()">‹ 上一页</button>
|
||||
<span class="text-soft">{{ page }} / {{ total_pages }}</span>
|
||||
<button class="btn btn-ghost" :disabled="page >= total_pages" @click="page++; reload()">下一页 ›</button>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<!-- 恢复确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="showRecover"
|
||||
v-model="showRecover"
|
||||
title="恢复记录"
|
||||
:message="`确认要恢复这条「${recoverTarget?.action_label}」操作吗?`"
|
||||
mode="type"
|
||||
confirm-label="确认恢复"
|
||||
confirm-word="恢复"
|
||||
danger-type="recover"
|
||||
:tips="[
|
||||
'恢复后记录将重新出现在对应列表中',
|
||||
'仅恢复本次操作,不影响其他数据'
|
||||
]"
|
||||
:busy="recoverBusy"
|
||||
:error="recoverError"
|
||||
@confirm="doRecover"
|
||||
@cancel="showRecover = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import * as oplogApi from '../api/operationLogs';
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'time', label: '时间', primary: true, alwaysShow: true },
|
||||
{ key: 'user', label: '操作人' },
|
||||
{ key: 'action', label: '操作', alwaysShow: true },
|
||||
{ key: 'target', label: '对象' },
|
||||
{ key: 'summary', label: '摘要' },
|
||||
];
|
||||
|
||||
const rows = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const limit = 50;
|
||||
const total_pages = ref(1);
|
||||
const stats = ref(null);
|
||||
const actionOptions = ref([]);
|
||||
const targetOptions = ref([]);
|
||||
const expanded = ref(new Set());
|
||||
|
||||
const filters = reactive({ action: '', target_type: '', username: '', from: '', to: '' });
|
||||
|
||||
// 恢复
|
||||
const showRecover = ref(false);
|
||||
const recoverTarget = ref(null);
|
||||
const recoverBusy = ref(false);
|
||||
const recoverError = ref('');
|
||||
|
||||
function askRecover(r) {
|
||||
recoverTarget.value = r;
|
||||
recoverError.value = '';
|
||||
showRecover.value = true;
|
||||
}
|
||||
|
||||
async function doRecover() {
|
||||
if (!recoverTarget.value) return;
|
||||
recoverBusy.value = true;
|
||||
recoverError.value = '';
|
||||
try {
|
||||
await oplogApi.recover(recoverTarget.value.id);
|
||||
showRecover.value = false;
|
||||
await reload();
|
||||
} catch (e) {
|
||||
recoverError.value = e.response?.data?.error?.message || e.message || '恢复失败';
|
||||
} finally {
|
||||
recoverBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const actionLabel = (a) => ({ delete: '删除', batch_delete: '批量删除', create: '新建', update: '更新', recover: '恢复' }[a] || a);
|
||||
const actionPill = (a) => ({
|
||||
delete: 'pill-warn',
|
||||
batch_delete: 'pill-warn',
|
||||
create: 'pill-green',
|
||||
update: 'pill-blue',
|
||||
recover: 'pill-green',
|
||||
}[a] || 'pill-gray');
|
||||
|
||||
function toggleDetail(id) {
|
||||
const s = new Set(expanded.value);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
expanded.value = s;
|
||||
}
|
||||
|
||||
function formatDetail(d) {
|
||||
return JSON.stringify(d, null, 2);
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const params = { ...filters, page: page.value, limit };
|
||||
Object.keys(params).forEach(k => !params[k] && delete params[k]);
|
||||
const r = await oplogApi.list(params);
|
||||
const d = r.data;
|
||||
rows.value = d.rows || [];
|
||||
total.value = d.total || 0;
|
||||
total_pages.value = d.total_pages || 1;
|
||||
stats.value = d.stats || null;
|
||||
expanded.value = new Set();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await oplogApi.options();
|
||||
actionOptions.value = r.data.actions || [];
|
||||
targetOptions.value = r.data.target_types || [];
|
||||
} catch {}
|
||||
reload();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.stats-row { display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.stat-card {
|
||||
background: var(--card); border-radius: 10px; padding: 12px 18px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.stat-num { font-size: 22px; font-weight: 700; color: var(--accent); }
|
||||
.stat-label { font-size: 13px; color: var(--text-soft); margin-top: 2px; }
|
||||
.filter { padding: 16px 20px; }
|
||||
.filter-row { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; }
|
||||
.log-row td { vertical-align: top; }
|
||||
.detail-row td { background: var(--bg-soft); padding: 0 16px 12px; }
|
||||
.detail-box { font-size: 12px; }
|
||||
.detail-meta {
|
||||
color: var(--text-soft);
|
||||
margin: 6px 0;
|
||||
display: flex; gap: 16px;
|
||||
}
|
||||
.detail-box pre {
|
||||
margin: 0;
|
||||
background: var(--card);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.btn-link {
|
||||
background: none; border: 0; padding: 2px 4px; cursor: pointer;
|
||||
color: var(--brand); font-size: 13px;
|
||||
}
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.text-recover { color: #10B981; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 600;
|
||||
}
|
||||
.badge-green { background: #D1FAE5; color: #065F46; }
|
||||
.pager {
|
||||
display: flex; align-items: center; justify-content: center; gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
@media (max-width: 800px) { .filter-row { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.stats-row { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.stat-card { padding: 10px 12px; }
|
||||
.stat-num { font-size: 20px; }
|
||||
.filter { padding: 12px 16px; }
|
||||
.filter-row { grid-template-columns: 1fr; }
|
||||
.pager .btn { flex: 1; }
|
||||
.detail-box { padding: 8px; }
|
||||
.detail-box pre { font-size: 11px; max-height: 200px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,374 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">加油记录</h1>
|
||||
<p class="subtitle text-soft">每次加油 + 油耗自动计算(需勾「加满」两次)</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @click="openNew">+ 新建加油</button>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad filters">
|
||||
<select v-model="filters.vehicle_id" class="select sm" @change="load">
|
||||
<option value="">全部车辆</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
<input v-model="filters.from" type="date" class="input sm" @change="load" />
|
||||
<span class="text-soft">至</span>
|
||||
<input v-model="filters.to" type="date" class="input sm" @change="load" />
|
||||
<div class="stats-pills">
|
||||
<span class="pill pill-blue">{{ data.total || 0 }} 条</span>
|
||||
<span class="pill pill-green">¥{{ (data.stats?.total_cost || 0).toFixed(2) }} 总花费</span>
|
||||
<span class="pill pill-gray" v-if="avgConsumption">{{ avgConsumption }} L/100km</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div v-if="loading" class="card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="!data.rows?.length" class="card-pad text-mute" style="text-align:center;padding:48px">还没有加油记录</div>
|
||||
<MobileCardList
|
||||
v-else
|
||||
:columns="columns"
|
||||
:rows="data.rows"
|
||||
row-key="id"
|
||||
empty-text="还没有加油记录"
|
||||
>
|
||||
<template #cell-date="{ row }">
|
||||
{{ row.refuel_date }}
|
||||
</template>
|
||||
<template #cell-vehicle="{ row }">
|
||||
<div>{{ row.vehicle_name }}</div>
|
||||
<div class="text-soft sm">{{ row.vehicle_plate }}</div>
|
||||
</template>
|
||||
<template #cell-fuel="{ row }">
|
||||
<span class="pill pill-gray">{{ row.fuel_type || '—' }}</span>
|
||||
<span v-if="row.is_full" class="pill pill-green sm">加满</span>
|
||||
</template>
|
||||
<template #cell-odo="{ row }">
|
||||
{{ row.odometer_km ? row.odometer_km + ' km' : '—' }}
|
||||
</template>
|
||||
<template #cell-liters="{ row }">
|
||||
<strong>{{ row.liters }} L</strong>
|
||||
</template>
|
||||
<template #cell-price="{ row }">
|
||||
¥{{ row.price_per_liter || 0 }}
|
||||
</template>
|
||||
<template #cell-cost="{ row }">
|
||||
<strong class="text-brand">¥{{ (row.total_cost || 0).toFixed(2) }}</strong>
|
||||
</template>
|
||||
<template #cell-consumption="{ row }">
|
||||
<span v-if="row.consumption_100km" class="pill pill-blue">{{ row.consumption_100km.toFixed(2) }} L/100km</span>
|
||||
<span v-else class="text-mute sm" :title="row.consumption_skip_reason">{{ row.consumption_skip_reason || '需加满+里程' }}</span>
|
||||
</template>
|
||||
<template #cell-station="{ row }">
|
||||
{{ row.station || '—' }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button class="btn btn-ghost btn-sm" @click.stop="openEdit(row)">编辑</button>
|
||||
<button class="btn btn-ghost btn-sm text-danger" @click.stop="onDelete(row)">删除</button>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="showDelete"
|
||||
v-model="showDelete"
|
||||
title="删除加油记录"
|
||||
:message="`确认删除 ${deleteTarget?.refuel_date} 的加油记录?`"
|
||||
mode="type"
|
||||
confirm-label="确认删除"
|
||||
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||
:busy="deleteBusy"
|
||||
:error="deleteError"
|
||||
@confirm="doDelete"
|
||||
@cancel="showDelete = false"
|
||||
/>
|
||||
|
||||
<!-- 弹窗 -->
|
||||
<div v-if="showForm" class="modal-mask" @click.self="closeForm">
|
||||
<div class="modal card card-pad">
|
||||
<div class="modal-head">
|
||||
<h3 class="section-title">{{ form.id ? '编辑加油' : '新建加油' }}</h3>
|
||||
<button v-if="!form.id" type="button" class="btn btn-ghost btn-sm" @click="onAiRecognize" :disabled="aiBusy">{{ aiBusy ? '识别中…' : '📷 AI 识别小票' }}</button>
|
||||
</div>
|
||||
<form @submit.prevent="onSave">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">车辆 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.vehicle_id" class="select" required>
|
||||
<option :value="null">— 请选择 —</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }}({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">日期 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.refuel_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">里程 (km)</label>
|
||||
<input v-model.number="form.odometer_km" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">油号</label>
|
||||
<select v-model="form.fuel_type" class="select">
|
||||
<option v-for="t in FUEL_TYPES" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">升数 <span class="text-danger">*</span></label>
|
||||
<input v-model.number="form.liters" type="number" step="0.01" min="0.01" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">单价 (¥/L)</label>
|
||||
<input v-model.number="form.price_per_liter" type="number" step="0.01" min="0" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">总价 <span class="text-danger">*</span></label>
|
||||
<input v-model.number="form.total_cost" type="number" step="0.01" min="0" class="input" required />
|
||||
</div>
|
||||
<div class="flex-center">
|
||||
<label class="check"><input v-model="form.is_full" type="checkbox" :true-value="1" :false-value="0" /> <span>加满(用于油耗计算)</span></label>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="label">加油站</label>
|
||||
<input v-model="form.station" class="input" placeholder="如 中石化朝阳门店" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="input" rows="2"></textarea>
|
||||
</div>
|
||||
<p v-if="formError" class="error mt-3">{{ formError }}</p>
|
||||
<div class="actions mt-3">
|
||||
<button type="button" class="btn btn-ghost" @click="closeForm">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="formBusy">{{ formBusy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import { refuelApi } from '../api/logs';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
|
||||
const FUEL_TYPES = ['92#', '95#', '98#', '0#柴油', '-10#柴油', 'E92乙醇', 'E95乙醇', 'LPG'];
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||
{ key: 'fuel', label: '油号' },
|
||||
{ key: 'odo', label: '里程' },
|
||||
{ key: 'liters', label: '升数', alwaysShow: true },
|
||||
{ key: 'price', label: '单价' },
|
||||
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||
{ key: 'consumption', label: '油耗' },
|
||||
{ key: 'station', label: '加油站' },
|
||||
];
|
||||
|
||||
const vehicles = ref([]);
|
||||
const data = ref({ rows: [], total: 0, stats: {} });
|
||||
const loading = ref(false);
|
||||
const filters = reactive({ vehicle_id: '', from: '', to: '' });
|
||||
|
||||
const showForm = ref(false);
|
||||
const form = reactive({ id: null, vehicle_id: null, refuel_date: today(), odometer_km: null, liters: null, price_per_liter: null, total_cost: null, fuel_type: '95#', is_full: 1, station: '', notes: '' });
|
||||
const formBusy = ref(false);
|
||||
const formError = ref('');
|
||||
|
||||
// 表单草稿:401 跳转前自动 flush
|
||||
const draft = useFormDraft('refuels/new');
|
||||
const restored = draft.load();
|
||||
if (restored) Object.assign(form, restored);
|
||||
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
// AI 识别
|
||||
const ai = useAiRecognize();
|
||||
const aiBusy = ai.busy;
|
||||
async function onAiRecognize() {
|
||||
await ai.open('refuel', (data) => {
|
||||
if (data.refuel_date) form.refuel_date = data.refuel_date;
|
||||
if (data.liters != null) form.liters = data.liters;
|
||||
if (data.price_per_liter != null) form.price_per_liter = data.price_per_liter;
|
||||
if (data.total_cost != null) form.total_cost = data.total_cost;
|
||||
if (data.fuel_type) form.fuel_type = data.fuel_type;
|
||||
if (data.is_full != null) form.is_full = data.is_full ? 1 : 0;
|
||||
if (data.station) form.station = data.station;
|
||||
if (data.odometer_km) form.odometer_km = data.odometer_km;
|
||||
});
|
||||
}
|
||||
|
||||
const avgConsumption = computed(() => {
|
||||
const xs = data.value.rows?.filter(r => r.consumption_100km > 0).map(r => r.consumption_100km) || [];
|
||||
if (!xs.length) return null;
|
||||
return (xs.reduce((s, x) => s + x, 0) / xs.length).toFixed(2);
|
||||
});
|
||||
|
||||
// 自动算总价 = 升数 × 单价
|
||||
watch(() => [form.liters, form.price_per_liter], ([l, p]) => {
|
||||
if (l && p && !form.total_cost) form.total_cost = Math.round(l * p * 100) / 100;
|
||||
});
|
||||
|
||||
function today() { return new Date().toISOString().slice(0, 10); }
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {};
|
||||
if (filters.vehicle_id) params.vehicle_id = filters.vehicle_id;
|
||||
if (filters.from) params.from = filters.from;
|
||||
if (filters.to) params.to = filters.to;
|
||||
const r = await refuelApi.list(params);
|
||||
data.value = r.data;
|
||||
} finally { loading.value = false; }
|
||||
}
|
||||
|
||||
async function loadVehicles() {
|
||||
const r = await vehiclesApi.list();
|
||||
vehicles.value = r.data || [];
|
||||
}
|
||||
|
||||
function openNew() {
|
||||
Object.assign(form, { id: null, vehicle_id: null, refuel_date: today(), odometer_km: null, liters: null, price_per_liter: null, total_cost: null, fuel_type: '95#', is_full: 1, station: '', notes: '' });
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function openEdit(r) {
|
||||
Object.assign(form, r);
|
||||
formError.value = '';
|
||||
showForm.value = true;
|
||||
}
|
||||
|
||||
function closeForm() { showForm.value = false; }
|
||||
|
||||
async function onSave() {
|
||||
formError.value = '';
|
||||
formBusy.value = true;
|
||||
try {
|
||||
const body = {
|
||||
vehicle_id: form.vehicle_id,
|
||||
refuel_date: form.refuel_date,
|
||||
odometer_km: form.odometer_km || null,
|
||||
liters: form.liters,
|
||||
price_per_liter: form.price_per_liter || null,
|
||||
total_cost: form.total_cost,
|
||||
fuel_type: form.fuel_type || null,
|
||||
is_full: form.is_full ? 1 : 0,
|
||||
station: form.station || null,
|
||||
notes: form.notes || null,
|
||||
};
|
||||
if (form.id) await refuelApi.update(form.id, body);
|
||||
else await refuelApi.create(body);
|
||||
draft.clear();
|
||||
closeForm();
|
||||
await load();
|
||||
} catch (e) {
|
||||
const errs = e.response?.data?.error?.errors;
|
||||
formError.value = errs ? Object.entries(errs).map(([k,v]) => `${k}: ${v}`).join(';') : (e.response?.data?.error?.message || e.message);
|
||||
} finally { formBusy.value = false; }
|
||||
}
|
||||
|
||||
async function onDelete(r) {
|
||||
deleteTarget.value = r;
|
||||
deleteError.value = '';
|
||||
showDelete.value = true;
|
||||
}
|
||||
|
||||
const showDelete = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteBusy = ref(false);
|
||||
const deleteError = ref('');
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
deleteBusy.value = true;
|
||||
deleteError.value = '';
|
||||
try {
|
||||
await refuelApi.remove(deleteTarget.value.id);
|
||||
showDelete.value = false;
|
||||
await load();
|
||||
} catch (e) {
|
||||
deleteError.value = e.response?.data?.error?.message || e.message || '删除失败';
|
||||
} finally {
|
||||
deleteBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { loadVehicles(); load(); });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; gap:12px; flex-wrap:wrap; }
|
||||
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; }
|
||||
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||
.filters { display:flex; gap:10px; align-items:center; flex-wrap:wrap; }
|
||||
.stats-pills { margin-left:auto; display:flex; gap:8px; flex-wrap:wrap; }
|
||||
.modal-mask { position:fixed; inset:0; background:rgba(15,34,51,0.5); display:flex; align-items:center; justify-content:center; z-index:1000; backdrop-filter:blur(2px); }
|
||||
.modal { width:100%; max-width:720px; max-height:90vh; overflow:auto; }
|
||||
.modal-head { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; gap:8px; flex-wrap:wrap; }
|
||||
.modal-head .section-title { margin:0; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:12px; }
|
||||
.col-span-2 { grid-column: span 2; }
|
||||
.flex-center { display:flex; align-items:center; }
|
||||
.check { display:flex; align-items:center; gap:6px; font-size:14px; cursor:pointer; }
|
||||
.label { display:block; font-size:12px; font-weight:600; color:var(--text-soft); margin-bottom:6px; }
|
||||
.input, .select { width:100%; padding:8px 10px; border:1px solid var(--border,#E5E7EB); border-radius:var(--radius-sm); font:inherit; background:#fff; box-sizing:border-box; }
|
||||
.input.sm, .select.sm { padding:4px 8px; font-size:13px; }
|
||||
.error { color:var(--danger); background:#FBE3DF; padding:8px 12px; border-radius:var(--radius-sm); font-size:13px; margin:0; }
|
||||
.actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.r { text-align:right; }
|
||||
.mt-3 { margin-top:12px; }
|
||||
.text-soft { color:var(--text-soft); }
|
||||
.text-mute { color:var(--text-mute); }
|
||||
.text-danger { color:var(--danger); }
|
||||
.text-brand { color:var(--brand); }
|
||||
.btn-sm { padding:4px 10px; font-size:12px; }
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 1023px) {
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
.col-span-2 { grid-column: span 2; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; }
|
||||
.head .btn { width: 100%; justify-content: center; }
|
||||
|
||||
.filters { padding: 12px 16px; }
|
||||
.stats-pills { width: 100%; margin-left: 0; }
|
||||
|
||||
/* modal 改底部 sheet */
|
||||
.modal-mask { align-items: flex-end; padding: 0; }
|
||||
.modal {
|
||||
max-width: none;
|
||||
border-radius: 16px 16px 0 0;
|
||||
max-height: 92vh;
|
||||
animation: sheetUp .25s ease;
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.modal-head { padding: 4px 0 8px; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.col-span-2 { grid-column: 1; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.filters .input, .filters .select { font-size: 14px; }
|
||||
.filters .sm { font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,663 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<h1 class="title">设置</h1>
|
||||
<p class="text-soft" style="margin: 4px 0 24px; font-size:14px">配置账户、天气、Grocy、CSV 导出</p>
|
||||
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||
<div v-else class="settings-grid">
|
||||
<!-- 账户 -->
|
||||
<section class="card card-pad">
|
||||
<h2 class="section-title">账户</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">修改当前用户 <strong>{{ auth.user?.username }}</strong> 的密码</p>
|
||||
<form @submit.prevent="onChangePass" class="form">
|
||||
<div>
|
||||
<label class="label">当前密码</label>
|
||||
<input v-model="pass.current" type="password" class="input" required />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">新密码(至少 6 位)</label>
|
||||
<input v-model="pass.next" type="password" class="input" minlength="6" required />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">确认新密码</label>
|
||||
<input v-model="pass.confirm" type="password" class="input" minlength="6" required />
|
||||
</div>
|
||||
<p v-if="passMsg" class="msg" :class="passOk ? 'ok' : 'err'">{{ passMsg }}</p>
|
||||
<button class="btn btn-primary mt-3" :disabled="busy.pass">{{ busy.pass ? '保存中…' : '更新密码' }}</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- 天气 -->
|
||||
<section class="card card-pad">
|
||||
<h2 class="section-title">天气</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">基于 wttr.in,每天最多请求一次(当天缓存优先)</p>
|
||||
<form @submit.prevent="onSaveSettings('weather', weather)" class="form">
|
||||
<div>
|
||||
<label class="label">默认城市 <span class="text-soft sm">— 永久生效,优先于 IP 定位</span></label>
|
||||
<input v-model="weather.cityDefault" class="input" placeholder="如:库尔勒" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">今天手动定位 <span class="text-soft sm">— 仅今天有效,优先级最高</span></label>
|
||||
<input v-model="weather.city" class="input" placeholder="留空使用默认城市" />
|
||||
<p class="hint" v-if="cityHint">{{ cityHint }}</p>
|
||||
</div>
|
||||
<p v-if="msgs.weather" class="msg ok">{{ msgs.weather }}</p>
|
||||
<div class="row mt-3">
|
||||
<button class="btn btn-primary" :disabled="busy.weather">{{ busy.weather ? '保存中…' : '保存' }}</button>
|
||||
<button type="button" class="btn btn-ghost" @click="onTestWeather" :disabled="busy.testWx">
|
||||
{{ busy.testWx ? '拉取中…' : '拉取今日天气' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Grocy -->
|
||||
<section class="card card-pad">
|
||||
<h2 class="section-title">Grocy</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">化学品同步到 Grocy 库存(同步在「化学品」页面操作)</p>
|
||||
<form @submit.prevent="onSaveSettings('grocy', grocy)" class="form">
|
||||
<div>
|
||||
<label class="label">GROCY_URL</label>
|
||||
<input v-model="grocy.url" class="input" placeholder="https://grocy.example.com" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">用户名</label>
|
||||
<input v-model="grocy.username" type="text" class="input" autocomplete="off" placeholder="Grocy 登录用户名" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">密码</label>
|
||||
<input v-model="grocy.password" type="password" class="input" :placeholder="grocy.has_password ? '•••• 已配置(留空保持)' : 'Grocy 登录密码'" />
|
||||
</div>
|
||||
<p v-if="msgs.grocy" class="msg" :class="msgsGrocyOk ? 'ok' : 'err'">{{ msgs.grocy }}</p>
|
||||
<div class="row mt-3">
|
||||
<button class="btn btn-primary" :disabled="busy.grocy">{{ busy.grocy ? '保存中…' : '保存' }}</button>
|
||||
<button class="btn btn-danger-outline btn-sm" @click="onClearGrocy" :disabled="busy.grocy">清空配置</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 同步历史 -->
|
||||
<div class="mt-4" v-if="grocyLogList.length > 0 || busy.grocyLog">
|
||||
<h3 class="text-soft" style="font-size:13px; margin: 0 0 10px; font-weight:600">同步历史</h3>
|
||||
<div v-if="busy.grocyLog" class="text-soft" style="font-size:13px">加载中…</div>
|
||||
<table v-else class="log-table">
|
||||
<thead>
|
||||
<tr><th>时间</th><th>操作</th><th>状态</th><th>结果</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="log in grocyLogList" :key="log.id">
|
||||
<td>{{ log.started_at.replace('T',' ').slice(0,16) }}</td>
|
||||
<td>{{ log.action === 'pull_products' ? '拉取产品' : log.action }}</td>
|
||||
<td>
|
||||
<span :class="log.status === 'success' ? 'text-ok' : log.status === 'failed' ? 'text-err' : 'text-soft'">
|
||||
{{ log.status === 'success' ? '✓ 成功' : log.status === 'failed' ? '✗ 失败' : '…' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-soft" style="font-size:12px">
|
||||
<template v-if="log.detail">
|
||||
+{{ log.detail.inserted }} / ~{{ log.detail.updated }} / -{{ log.detail.deactivated }}
|
||||
</template>
|
||||
<template v-else-if="log.detail?.error">{{ log.detail.error }}</template>
|
||||
<template v-else>—</template>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AI 截图识别 -->
|
||||
<section class="card card-pad">
|
||||
<h2 class="section-title">📷 AI 截图识别</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">上传小票/订单截图,自动提取日期/金额/油号/度数/保单号等填入表单。</p>
|
||||
<form @submit.prevent="onSaveAi" class="form">
|
||||
<label class="check">
|
||||
<input type="checkbox" v-model="ai.enabled" />
|
||||
<span>启用 AI 截图识别</span>
|
||||
</label>
|
||||
<div class="mt-3">
|
||||
<label class="label">Provider <span class="text-soft sm">— 选择 AI 服务</span></label>
|
||||
<select v-model="ai.provider" class="select" @change="onProviderChange">
|
||||
<option v-for="p in ai.providers" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">Provider URL <span class="text-soft sm">— API base</span></label>
|
||||
<input v-model="ai.provider_url" class="input" :placeholder="ai.provider === 'minimax_vl' ? 'https://api.minimaxi.com/v1' : 'https://api.openai.com/v1'" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">API Key <span class="text-soft sm">— 加密保存</span></label>
|
||||
<input v-model="ai.api_key" type="password" class="input" :placeholder="ai.has_api_key ? '•••• 已配置(留空保持)' : 'sk-...'" />
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">模型 <span class="text-soft sm">— 必须支持多模态</span></label>
|
||||
<input v-model="ai.model" class="input" :placeholder="ai.provider === 'minimax_vl' ? 'MiniMax-M3' : 'gpt-4o-mini / glm-4v-plus / ...'" />
|
||||
</div>
|
||||
<p v-if="msgs.ai" class="msg" :class="msgsAiOk ? 'ok' : 'err'">{{ msgs.ai }}</p>
|
||||
<p v-if="msgs.test" class="msg" :class="msgsTestOk ? 'ok' : 'err'">{{ msgs.test }}</p>
|
||||
<div class="row mt-3">
|
||||
<button class="btn btn-primary" :disabled="busy.ai">{{ busy.ai ? '保存中…' : '保存配置' }}</button>
|
||||
<button type="button" class="btn btn-ghost" @click="onTestAi" :disabled="busy.test">
|
||||
{{ busy.test ? '测试中…' : '测试连接' }}
|
||||
</button>
|
||||
</div>
|
||||
<details class="mt-3 ai-help" open>
|
||||
<summary class="text-soft sm" style="cursor:pointer">Provider 说明</summary>
|
||||
<div class="text-soft sm" style="margin:8px 0 0; line-height:1.7">
|
||||
<p v-if="ai.provider === 'minimax_vl'">
|
||||
<strong>MiniMax M3 多模态:</strong>原生多模态旗舰,支持图片/视频/桌面操作。Plus 套餐自带额度。
|
||||
<br>· API base: <code>https://api.minimaxi.com/v1</code>
|
||||
<br>· 模型: <code>MiniMax-M3</code>
|
||||
<br>· 端点: <code>/chat/completions</code>(OpenAI 兼容协议)
|
||||
<br>· 鉴权: <code>Bearer</code> 头(同 OpenAI)
|
||||
<br>· API key: <a href="https://platform.minimaxi.com/user-center/basic-information/interface-key" target="_blank">platform.minimaxi.com</a> 获取(按量 / Token Plan 都可)
|
||||
</p>
|
||||
<p v-else>
|
||||
<strong>OpenAI 兼容:</strong>支持 OpenAI / 月之暗面 Kimi / 智谱 GLM-4V / DeepSeek-VL2 / Qwen-VL / 本地 Ollama 等。
|
||||
<br>· 模型名: <code>gpt-4o-mini</code> / <code>glm-4v-plus</code> / <code>moonshot-v1-8k-vision</code> 等
|
||||
<br>· 端点: <code>/chat/completions</code>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- 系统信息 -->
|
||||
<section class="card card-pad">
|
||||
<h2 class="section-title">系统信息</h2>
|
||||
<table class="data">
|
||||
<tbody>
|
||||
<tr><td class="text-soft">当前账号</td><td>{{ auth.user?.username }}</td></tr>
|
||||
<tr><td class="text-soft">登录时间</td><td>{{ auth.user?.last_login_at || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">登录 IP</td><td>{{ auth.user?.last_login_ip || '—' }}</td></tr>
|
||||
<tr><td class="text-soft">服务地址</td><td>http://{{ host }}</td></tr>
|
||||
<tr><td class="text-soft">数据目录</td><td><code>MySQL: carlog</code></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- Grocy 分类映射 -->
|
||||
<section v-if="categories.length" class="card card-pad">
|
||||
<h2 class="section-title">Grocy 分类映射</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">
|
||||
你 Grocy 4.5.x 没开放分类 API,化学品列表暂时显示 group-ID。给每个 ID 配个真实名字就能正常显示。
|
||||
</p>
|
||||
<p v-if="msgs.category" class="msg ok">{{ msgs.category }}</p>
|
||||
<table class="data">
|
||||
<thead><tr><th style="width:80px">Grocy ID</th><th>显示名</th><th style="width:160px">操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in categories" :key="c.id">
|
||||
<td><code>group-{{ c.id }}</code></td>
|
||||
<td>
|
||||
<input
|
||||
v-model="categoryDrafts[c.id]"
|
||||
class="input"
|
||||
:placeholder="c.is_mapped ? c.name : `给 group-${c.id} 起个名字`"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" @click="saveCategoryMapping(c.id)" :disabled="busy.category || !(categoryDrafts[c.id]||'').trim()">保存</button>
|
||||
<button v-if="c.is_mapped" class="btn btn-ghost btn-sm" @click="deleteCategoryMapping(c.id)">清空</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<!-- 调试 -->
|
||||
<section class="card card-pad">
|
||||
<h2 class="section-title">调试</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">调试模式开启时,所有 API 错误、Vue 组件错误、Promise 异常会显示在右下角浮层,可一键复制</p>
|
||||
<label class="check">
|
||||
<input type="checkbox" :checked="debug.enabled" @change="debug.toggle()" />
|
||||
<span>启用调试模式</span>
|
||||
<span v-if="debug.enabled" class="pill pill-warn" style="margin-left:8px">{{ debug.count }} 条错误</span>
|
||||
</label>
|
||||
<div v-if="debug.enabled" class="mt-3 flex gap-2">
|
||||
<button class="btn btn-ghost btn-sm" @click="debug.clear()">清空错误日志</button>
|
||||
<span class="text-mute" style="font-size:12px; align-self:center">设置会保存到 localStorage</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 危险操作 -->
|
||||
<section class="card card-pad danger-card">
|
||||
<h2 class="section-title danger-title">⚠️ 危险操作</h2>
|
||||
<p class="text-soft" style="font-size:13px; margin: 0 0 16px">
|
||||
一键清空所有业务数据(车辆、洗车、保养、加油、充电、保险),并可选择重新灌入演示数据。
|
||||
<strong>管理员账户不会被删除。</strong>
|
||||
</p>
|
||||
<div class="danger-row">
|
||||
<div class="danger-btns">
|
||||
<button class="btn btn-danger-outline" @click="openResetModal('clear')" :disabled="busy.reset">
|
||||
{{ busy.reset ? '处理中…' : '🗑 清空数据' }}
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="openResetModal('reset')" :disabled="busy.reset">
|
||||
{{ busy.reset ? '处理中…' : '🔄 重置 + 灌演示数据' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="resetMsg" class="msg" :class="resetOk ? 'ok' : 'err'">{{ resetMsg }}</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 二次确认弹窗 -->
|
||||
<Teleport to="body">
|
||||
<div v-if="showResetModal" class="modal-overlay" @click.self="closeResetModal">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">⚠️ 确认要{{ resetMode === 'reset' ? '重置所有数据' : '清空所有数据' }}吗?</h3>
|
||||
<p class="modal-body-text">
|
||||
<template v-if="resetMode === 'reset'">
|
||||
这将<strong>删除所有车辆、洗车记录、保养、加油、充电、保险数据</strong>,并重新灌入演示数据。
|
||||
</template>
|
||||
<template v-else>
|
||||
这将<strong>删除所有车辆、洗车记录、保养、加油、充电、保险数据</strong>(不可恢复)。
|
||||
</template>
|
||||
<br/>管理员账户 <code>admin2</code> 会保留。
|
||||
</p>
|
||||
<p class="modal-hint">请在下框输入 <strong>RESET-ALL-DATA</strong> 确认:</p>
|
||||
<input
|
||||
v-model="resetConfirmInput"
|
||||
class="input modal-input"
|
||||
:class="{ 'input-error': resetConfirmInput && resetConfirmInput !== 'RESET-ALL-DATA' }"
|
||||
placeholder="RESET-ALL-DATA"
|
||||
@keyup.enter="confirmReset"
|
||||
autofocus
|
||||
/>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="closeResetModal" :disabled="busy.reset">取消</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
@click="confirmReset"
|
||||
:disabled="busy.reset || resetConfirmInput !== 'RESET-ALL-DATA'"
|
||||
>
|
||||
{{ busy.reset ? '处理中…' : '确认执行' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, onMounted, computed } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import { useAuthStore } from '../stores/auth';
|
||||
import { useDebugStore } from '../stores/debug';
|
||||
import * as settingsApi from '../api/settings';
|
||||
import * as authApi from '../api/auth';
|
||||
import * as chemicalsApi from '../api/chemicals';
|
||||
import * as aiApi from '../api/ai';
|
||||
const auth = useAuthStore();
|
||||
const debug = useDebugStore();
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const weather = reactive({ cityDefault: '', city: '' });
|
||||
const cityHint = ref('');
|
||||
const grocy = reactive({ url: '', username: '', password: '', has_password: false });
|
||||
const pass = reactive({ current: '', next: '', confirm: '' });
|
||||
const ai = reactive({
|
||||
provider: 'openai_compat',
|
||||
providers: [
|
||||
{ id: 'openai_compat', name: 'OpenAI 兼容(OpenAI / Kimi / DeepSeek / 硅基流动)' },
|
||||
{ id: 'minimax_vl', name: 'MiniMax M3 多模态' },
|
||||
],
|
||||
provider_url: 'https://api.openai.com/v1',
|
||||
api_key: '',
|
||||
model: 'gpt-4o-mini',
|
||||
enabled: false,
|
||||
has_api_key: false,
|
||||
});
|
||||
|
||||
function onProviderChange() {
|
||||
// 切换 provider 时,自动填默认 URL 和模型(用户没手动改过的话)
|
||||
const defaults = {
|
||||
openai_compat: { url: 'https://api.openai.com/v1', model: 'gpt-4o-mini' },
|
||||
minimax_vl: { url: 'https://api.minimaxi.com/v1', model: 'MiniMax-M3' },
|
||||
};
|
||||
const d = defaults[ai.provider];
|
||||
if (d) {
|
||||
// 只有当前是某个 provider 的默认值时才覆盖,避免覆盖用户改过的
|
||||
const isCurrentDefault =
|
||||
ai.provider_url === 'https://api.openai.com/v1' ||
|
||||
ai.provider_url === 'https://api.minimaxi.com/v1' ||
|
||||
ai.model === 'gpt-4o-mini' ||
|
||||
ai.model === 'MiniMax-M3';
|
||||
if (isCurrentDefault) {
|
||||
ai.provider_url = d.url;
|
||||
ai.model = d.model;
|
||||
}
|
||||
}
|
||||
}
|
||||
const busy = reactive({ weather: false, grocy: false, pass: false, testWx: false, category: false, ai: false, test: false, grocyLog: false, reset: false });
|
||||
const msgs = reactive({ weather: '', grocy: '', category: '', ai: '', test: '' });
|
||||
const msgsAiOk = ref(false);
|
||||
const msgsTestOk = ref(false);
|
||||
const msgsGrocyOk = ref(false);
|
||||
const passMsg = ref('');
|
||||
const passOk = ref(false);
|
||||
const grocyLogList = ref([]);
|
||||
|
||||
// 重置相关
|
||||
const showResetModal = ref(false);
|
||||
const resetMode = ref('reset'); // 'reset' = 清空+灌数据, 'clear' = 只清空
|
||||
const resetConfirmInput = ref('');
|
||||
const resetMsg = ref('');
|
||||
const resetOk = ref(false);
|
||||
|
||||
// 分类映射
|
||||
const categories = ref([]); // [{id, name, is_mapped}]
|
||||
const categoryDrafts = ref({}); // id -> 输入框内容
|
||||
const location_ = window.location;
|
||||
const host = location_.host;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await settingsApi.get();
|
||||
const s = r.data || {};
|
||||
// 扁平 {key: value, ...} → 按 group 拆出子对象
|
||||
Object.assign(weather, {
|
||||
cityDefault: s.app_city_default || '',
|
||||
city: s.app_city || '',
|
||||
});
|
||||
// 加载城市状态
|
||||
try {
|
||||
const cr = await settingsApi.getCity();
|
||||
const cd = cr.data || {};
|
||||
if (cd.is_auto_today) {
|
||||
cityHint.value = cd.default_city
|
||||
? `默认城市「${cd.default_city}」生效`
|
||||
: '将根据 IP 自动定位';
|
||||
} else {
|
||||
cityHint.value = `已保存「${cd.saved_city}」,今日 24:00 前有效`;
|
||||
}
|
||||
} catch {}
|
||||
Object.assign(grocy, {
|
||||
url: s.grocy_url || '',
|
||||
username: s.grocy_username || '',
|
||||
password: '',
|
||||
has_password: !!s.grocy_username,
|
||||
});
|
||||
// 加载 Grocy 同步历史
|
||||
try {
|
||||
grocyLogList.value = (await settingsApi.grocyLogs(20)).data || [];
|
||||
} catch {}
|
||||
// 加载 AI 配置
|
||||
try {
|
||||
const aiR = await aiApi.getConfig();
|
||||
const ac = aiR.data || {};
|
||||
Object.assign(ai, {
|
||||
provider: ac.provider || 'openai_compat',
|
||||
providers: ac.providers || ai.providers,
|
||||
provider_url: ac.provider_url || 'https://api.openai.com/v1',
|
||||
model: ac.model || 'gpt-4o-mini',
|
||||
enabled: !!ac.enabled,
|
||||
has_api_key: !!ac.has_api_key,
|
||||
api_key: '', // 不回显,已配置的话让用户留空保持
|
||||
});
|
||||
} catch {}
|
||||
} catch (e) { error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败'; }
|
||||
finally { loading.value = false; }
|
||||
});
|
||||
|
||||
async function onSaveSettings(group, data) {
|
||||
busy[group] = true;
|
||||
msgs[group] = '';
|
||||
try {
|
||||
// client 字段 → server settings key
|
||||
let settings = {};
|
||||
if (group === 'weather') {
|
||||
settings = {
|
||||
app_city_default: data.cityDefault || '',
|
||||
app_city: data.city || 'auto',
|
||||
};
|
||||
// 保存后刷新提示
|
||||
if (data.city) {
|
||||
cityHint.value = `已保存「${data.city}」,今日 24:00 前有效`;
|
||||
} else if (data.cityDefault) {
|
||||
cityHint.value = `默认城市「${data.cityDefault}」生效`;
|
||||
} else {
|
||||
cityHint.value = '将根据 IP 自动定位';
|
||||
}
|
||||
} else if (group === 'grocy') {
|
||||
settings = {
|
||||
grocy_url: data.url,
|
||||
grocy_username: data.username,
|
||||
};
|
||||
if (data.password) {
|
||||
settings.grocy_password = data.password;
|
||||
grocy.has_password = true;
|
||||
}
|
||||
// 保存后刷新同步历史
|
||||
try { grocyLogList.value = (await settingsApi.grocyLogs(20)).data || []; } catch {}
|
||||
}
|
||||
await settingsApi.update({ group, settings });
|
||||
msgs[group] = `✓ 已保存(${new Date().toLocaleTimeString()})`;
|
||||
setTimeout(() => msgs[group] = '', 3000);
|
||||
} catch (e) { msgs[group] = '✗ ' + (e.response?.data?.message || e.response?.data?.code || e.message || '保存失败'); }
|
||||
finally { busy[group] = false; }
|
||||
}
|
||||
|
||||
async function onChangePass() {
|
||||
passMsg.value = '';
|
||||
passOk.value = false;
|
||||
if (pass.next !== pass.confirm) { passMsg.value = '两次输入的新密码不一致'; return; }
|
||||
if (pass.next.length < 6) { passMsg.value = '新密码至少 6 位'; return; }
|
||||
busy.pass = true;
|
||||
try {
|
||||
await authApi.changeAccount({ current_password: pass.current, new_password: pass.next });
|
||||
passOk.value = true;
|
||||
passMsg.value = '✓ 密码已更新';
|
||||
pass.current = pass.next = pass.confirm = '';
|
||||
} catch (e) {
|
||||
passMsg.value = e.response?.data?.message || e.response?.data?.code || e.message || '修改失败';
|
||||
} finally { busy.pass = false; }
|
||||
}
|
||||
|
||||
async function onTestWeather() {
|
||||
busy.testWx = true;
|
||||
msgs.weather = '';
|
||||
try {
|
||||
// 先保存城市设置
|
||||
await settingsApi.update({ group: 'weather', settings: { app_city: weather.city || 'auto' } });
|
||||
// 再请求天气(后端有当天缓存,不会重复请求 wttr)
|
||||
const r = await settingsApi.getWeather();
|
||||
const w = r.data;
|
||||
if (w) {
|
||||
cityHint.value = `已拉取「${w.city}」今日天气:${w.weather_desc} ${w.temp_c}℃(${w.from_cache ? '来自缓存' : '实时获取'})`;
|
||||
}
|
||||
} catch (e) {
|
||||
msgs.weather = '✗ 拉取失败:' + (e.response?.data?.message || e.message);
|
||||
} finally { busy.testWx = false; }
|
||||
}
|
||||
async function saveCategoryMapping(id) {
|
||||
const name = (categoryDrafts.value[id] || '').trim();
|
||||
if (!name) return;
|
||||
busy.category = true;
|
||||
msgs.category = '';
|
||||
try {
|
||||
// 合并所有 mappings(避免覆盖其他)
|
||||
const all = categories.value.map(c => ({
|
||||
id: c.id,
|
||||
name: c.id === id ? name : (categoryDrafts.value[c.id] || c.name || '').trim(),
|
||||
})).filter(x => x.name);
|
||||
await chemicalsApi.saveCategoryMappings(all);
|
||||
msgs.category = `✓ 已保存「${name}」`;
|
||||
// 刷新
|
||||
const r = await chemicalsApi.getCategories();
|
||||
const list = Array.isArray(r.data) ? r.data : [];
|
||||
categories.value = list;
|
||||
for (const c of list) categoryDrafts.value[c.id] = c.is_mapped ? c.name : '';
|
||||
setTimeout(() => { msgs.category = ''; }, 3000);
|
||||
} catch (e) {
|
||||
msgs.category = '✗ 保存失败:' + (e.response?.data?.message || e.message);
|
||||
} finally { busy.category = false; }
|
||||
}
|
||||
async function deleteCategoryMapping(id) {
|
||||
busy.category = true;
|
||||
try {
|
||||
await chemicalsApi.deleteCategoryMapping(id);
|
||||
const r = await chemicalsApi.getCategories();
|
||||
const list = Array.isArray(r.data) ? r.data : [];
|
||||
categories.value = list;
|
||||
for (const c of list) categoryDrafts.value[c.id] = c.is_mapped ? c.name : '';
|
||||
} finally { busy.category = false; }
|
||||
}
|
||||
|
||||
async function onSaveAi() {
|
||||
busy.ai = true;
|
||||
msgs.ai = '';
|
||||
msgsAiOk.value = false;
|
||||
try {
|
||||
const body = {
|
||||
provider: ai.provider,
|
||||
provider_url: ai.provider_url,
|
||||
model: ai.model,
|
||||
enabled: ai.enabled,
|
||||
};
|
||||
if (ai.api_key) body.api_key = ai.api_key; // 用户填了才更新
|
||||
await aiApi.saveConfig(body);
|
||||
msgsAiOk.value = true;
|
||||
msgs.ai = '✓ 已保存(' + new Date().toLocaleTimeString() + ')';
|
||||
ai.has_api_key = ai.has_api_key || !!ai.api_key;
|
||||
ai.api_key = '';
|
||||
setTimeout(() => msgs.ai = '', 3000);
|
||||
} catch (e) {
|
||||
msgs.ai = '✗ ' + (e.response?.data?.error?.message || e.message || '保存失败');
|
||||
} finally { busy.ai = false; }
|
||||
}
|
||||
|
||||
async function onTestAi() {
|
||||
busy.test = true;
|
||||
msgs.test = '';
|
||||
msgsTestOk.value = false;
|
||||
try {
|
||||
const body = { provider: ai.provider, provider_url: ai.provider_url, model: ai.model };
|
||||
if (ai.api_key) body.api_key = ai.api_key;
|
||||
const r = await aiApi.test(body);
|
||||
msgsTestOk.value = true;
|
||||
msgs.test = `✓ 连通 (provider: ${r.data.provider}, model: ${r.data.model}) · 返「${r.data.reply}」`;
|
||||
} catch (e) {
|
||||
msgs.test = '✗ ' + (e.response?.data?.error?.message || e.message || '测试失败');
|
||||
} finally { busy.test = false; }
|
||||
}
|
||||
|
||||
async function onClearGrocy() {
|
||||
if (!confirm('确定要清空 Grocy 配置吗?化学品页的同步按钮将不可用。')) return;
|
||||
busy.grocy = true;
|
||||
msgs.grocy = '';
|
||||
msgsGrocyOk.value = false;
|
||||
try {
|
||||
await settingsApi.update({ group: 'grocy', settings: { grocy_url: '', grocy_username: '' } });
|
||||
grocy.url = '';
|
||||
grocy.username = '';
|
||||
grocy.has_password = false;
|
||||
grocy.password = '';
|
||||
msgsGrocyOk.value = true;
|
||||
msgs.grocy = '✓ 已清空,化学品同步已禁用';
|
||||
setTimeout(() => { msgs.grocy = ''; }, 4000);
|
||||
} catch (e) {
|
||||
msgs.grocy = '✗ 清空失败:' + (e.response?.data?.message || e.message);
|
||||
} finally { busy.grocy = false; }
|
||||
}
|
||||
|
||||
function openResetModal(mode) {
|
||||
resetMode.value = mode;
|
||||
resetConfirmInput.value = '';
|
||||
resetMsg.value = '';
|
||||
resetOk.value = false;
|
||||
showResetModal.value = true;
|
||||
}
|
||||
function closeResetModal() {
|
||||
if (busy.reset) return;
|
||||
showResetModal.value = false;
|
||||
resetConfirmInput.value = '';
|
||||
}
|
||||
async function confirmReset() {
|
||||
if (resetConfirmInput.value !== 'RESET-ALL-DATA' || busy.reset) return;
|
||||
busy.reset = true;
|
||||
resetMsg.value = '';
|
||||
resetOk.value = false;
|
||||
try {
|
||||
const r = await settingsApi.resetAll('RESET-ALL-DATA', resetMode.value === 'reset');
|
||||
resetOk.value = true;
|
||||
resetMsg.value = r.data.message || '✅ 完成,请刷新页面';
|
||||
setTimeout(() => {
|
||||
showResetModal.value = false;
|
||||
resetConfirmInput.value = '';
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
resetOk.value = false;
|
||||
resetMsg.value = '✗ ' + (e.response?.data?.error?.message || e.response?.data?.code || e.message || '失败');
|
||||
} finally { busy.reset = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.section-title { font-size: 16px; font-weight: 600; margin: 0 0 16px; }
|
||||
.row { display: flex; gap: 8px; }
|
||||
.msg { font-size: 13px; margin: 8px 0 0; }
|
||||
.msg.ok { color: #2E8A6B; }
|
||||
.msg.err { color: var(--danger); }
|
||||
.hint { font-size: 12px; color: var(--text-soft); margin: 6px 0 0; }
|
||||
.text-ok { color: #2E8A6B; }
|
||||
.text-err { color: var(--danger); }
|
||||
.log-table { width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 4px; }
|
||||
.log-table th { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--border); color: var(--text-soft); font-weight: 500; }
|
||||
.log-table td { padding: 5px 8px; border-bottom: 1px solid var(--border); }
|
||||
.log-table tr:last-child td { border-bottom: none; }
|
||||
code { font-size: 12px; background: var(--bg-soft); padding: 2px 6px; border-radius: 4px; }
|
||||
.ai-help { background: var(--bg-soft); border-radius: var(--radius-sm); padding: 10px 14px; }
|
||||
.ai-help summary { user-select: none; }
|
||||
.ai-help ul { list-style: disc; }
|
||||
@media (max-width: 900px) { .settings-grid { grid-template-columns: 1fr; } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.settings-grid { gap: 12px; }
|
||||
.settings-card { padding: 14px 16px; }
|
||||
.form-row { grid-template-columns: 1fr; gap: 8px; }
|
||||
.tabs { flex-wrap: wrap; overflow-x: auto; }
|
||||
.tab { white-space: nowrap; }
|
||||
.modal-mask { align-items: flex-end; padding: 0; }
|
||||
.modal { max-width: none; border-radius: 16px 16px 0 0; max-height: 92vh; animation: sheetUp .25s ease; padding-bottom: var(--safe-bottom); }
|
||||
@keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
|
||||
.modal-actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.modal-actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
<style scoped>
|
||||
/* 危险操作区 */
|
||||
.danger-card { border-color: #C0392B33; }
|
||||
.danger-title { color: #C0392B; }
|
||||
.danger-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.danger-btns { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.btn-danger { background: #C0392B; color: #fff; border: 1px solid #C0392B; padding: 8px 16px; border-radius: var(--radius); font-size: 14px; cursor: pointer; font-weight: 500; transition: background .15s; }
|
||||
.btn-danger:hover:not(:disabled) { background: #A93226; }
|
||||
.btn-danger:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-danger-outline { background: transparent; color: #C0392B; border: 1px solid #C0392B55; padding: 8px 16px; border-radius: var(--radius); font-size: 14px; cursor: pointer; font-weight: 500; transition: all .15s; }
|
||||
.btn-danger-outline:hover:not(:disabled) { background: #C0392B15; border-color: #C0392B; }
|
||||
.btn-danger-outline:disabled { opacity: .5; cursor: not-allowed; }
|
||||
|
||||
/* 二次确认弹窗 */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 9999; backdrop-filter: blur(2px);
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--card-bg); border-radius: 12px; padding: 28px;
|
||||
max-width: 440px; width: 90%; box-shadow: 0 20px 60px rgba(0,0,0,.3);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 600; margin: 0 0 12px; color: #C0392B; }
|
||||
.modal-body-text { font-size: 14px; line-height: 1.6; color: var(--text-soft); margin: 0 0 12px; }
|
||||
.modal-hint { font-size: 13px; color: var(--text-soft); margin: 0 0 8px; }
|
||||
.modal-input { width: 100%; font-size: 16px; letter-spacing: .05em; box-sizing: border-box; }
|
||||
.input-error { border-color: var(--danger) !important; }
|
||||
.modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,414 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<h1 class="title">统计</h1>
|
||||
<p class="text-soft" style="margin: 4px 0 24px; font-size:14px">历史数据汇总</p>
|
||||
|
||||
<!-- 月度报表下载 -->
|
||||
<div class="card card-pad mb-4">
|
||||
<div class="report-head">
|
||||
<div>
|
||||
<h3 class="report-title">月度报表</h3>
|
||||
<p class="text-mute sm" style="margin: 4px 0 0">按月生成 Excel / PDF 报表:洗车、加油、充电、保养、保险 + 化学品 Top</p>
|
||||
</div>
|
||||
<div class="report-actions">
|
||||
<select v-model="reportMonth" class="select" style="min-width: 140px">
|
||||
<option v-for="m in months" :key="m" :value="m">{{ m }}</option>
|
||||
</select>
|
||||
<a :href="excelUrl" class="btn btn-primary" :download="`carwash-${reportMonth}.xlsx`">
|
||||
📊 下载 Excel
|
||||
</a>
|
||||
<a :href="pdfUrl" class="btn btn-ghost" :download="`carwash-${reportMonth}.pdf`">
|
||||
📄 下载 PDF
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<template v-else>
|
||||
<div class="kpi-grid">
|
||||
<StatCard title="总洗车次数" :value="stats.total_washes || 0" :hint="`累计 ${stats.total_washes || 0} 次`" />
|
||||
<StatCard title="总花费" :value="'¥ ' + (stats.total_cost || 0).toFixed(2)" :hint="`日均 ¥ ${(stats.total_cost / Math.max(stats.days, 1)).toFixed(2)}`" />
|
||||
<StatCard title="平均间隔" :value="(stats.avg_interval || 0) + ' 天'" hint="两次洗车之间" />
|
||||
<StatCard title="启用车辆" :value="stats.active_vehicles || 0" :hint="`共 ${stats.total_vehicles || 0} 辆`" />
|
||||
</div>
|
||||
|
||||
<div class="row mt-6">
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">近 12 月洗车频次</h3>
|
||||
<div class="chart-wrap"><canvas ref="monthCanvas"></canvas></div>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">花费趋势</h3>
|
||||
<div class="chart-wrap"><canvas ref="costCanvas"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-6">
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">车辆花费 Top</h3>
|
||||
<table class="data">
|
||||
<thead><tr><th>车辆</th><th>次数</th><th>花费</th><th>占比</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in stats.vehicle_breakdown || []" :key="r.id">
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||
<td>{{ r.count }}</td>
|
||||
<td>¥ {{ (r.cost || 0).toFixed(2) }}</td>
|
||||
<td>
|
||||
<div class="bar-wrap">
|
||||
<div class="bar" :style="{ width: (r.pct || 0) + '%' }"></div>
|
||||
<span class="bar-text">{{ (r.pct || 0).toFixed(1) }}%</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!stats.vehicle_breakdown?.length"><td colspan="4" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">化学品 Top 5</h3>
|
||||
<table class="data">
|
||||
<thead><tr><th>名称</th><th>累计用量</th><th>次数</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="c in stats.chemical_top || []" :key="c.grocy_product_id">
|
||||
<td><strong>{{ c.name }}</strong></td>
|
||||
<td>{{ c.total_amount }} {{ c.unit || '' }}</td>
|
||||
<td>{{ c.count }}</td>
|
||||
</tr>
|
||||
<tr v-if="!stats.chemical_top?.length"><td colspan="3" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3 个真正有用的图:油价 / 年均养护 / 季节 -->
|
||||
<div class="row mt-6">
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">油价趋势 <span class="text-mute sm">(按月)</span></h3>
|
||||
<div class="chart-wrap"><canvas ref="fuelCanvas"></canvas></div>
|
||||
<p class="text-mute sm" style="margin: 8px 0 0">看你是越加越贵还是赶上了降价;连续 3 个月 +5% 就要考虑改加油时机</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">年均养护成本 <span class="text-mute sm">(按车辆)</span></h3>
|
||||
<div class="chart-wrap"><canvas ref="annualCanvas"></canvas></div>
|
||||
<p class="text-mute sm" style="margin: 8px 0 0">洗车+加油+充电+保养+保险 / 持有天数 × 365,看哪台车最费钱</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-6">
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">洗车频率 vs 季节 <span class="text-mute sm">(按月)</span></h3>
|
||||
<div class="chart-wrap"><canvas ref="seasonCanvas"></canvas></div>
|
||||
<p class="text-mute sm" style="margin: 8px 0 0">看你什么时候最勤快:雨季前扎堆 vs 冬天摆烂?</p>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">各车成本明细</h3>
|
||||
<table class="data">
|
||||
<thead><tr><th>车辆</th><th>持有</th><th class="r">终身</th><th class="r">年化</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in extraData.costPerVehicle || []" :key="r.id">
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||
<td>{{ Math.round(r.days_owned) }} 天</td>
|
||||
<td class="r">¥{{ Number(r.lifetime_cost).toFixed(0) }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ Number(r.annual_cost).toFixed(0) }}</strong></td>
|
||||
</tr>
|
||||
<tr v-if="!extraData.costPerVehicle?.length"><td colspan="4" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用车成本(保养/加油/充电)-->
|
||||
<div class="row mt-6">
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">用车成本构成(按车辆)</h3>
|
||||
<table class="data">
|
||||
<thead><tr><th>车辆</th><th class="r">洗车</th><th class="r">保养</th><th class="r">加油</th><th class="r">充电</th><th class="r">合计</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in usageCost" :key="r.id">
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||
<td class="r">¥{{ r.wash.toFixed(0) }}</td>
|
||||
<td class="r">¥{{ r.maint.toFixed(0) }}</td>
|
||||
<td class="r">¥{{ r.refuel.toFixed(0) }}</td>
|
||||
<td class="r">¥{{ r.charge.toFixed(0) }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ r.total.toFixed(0) }}</strong></td>
|
||||
</tr>
|
||||
<tr v-if="!usageCost.length"><td colspan="6" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="chart-title">油耗/电耗(按车辆)</h3>
|
||||
<table class="data">
|
||||
<thead><tr><th>车辆</th><th>加油</th><th>充电</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in consumption" :key="r.id">
|
||||
<td><strong>{{ r.name }}</strong> <span class="text-soft">{{ r.plate }}</span></td>
|
||||
<td>
|
||||
<span v-if="r.l_per_100km">平均 <strong>{{ r.l_per_100km }}</strong> L/100km<br /><span class="text-mute sm">总 {{ r.total_liters }}L</span></span>
|
||||
<span v-else class="text-mute sm">需加满+里程</span>
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.kwh_per_100km">平均 <strong>{{ r.kwh_per_100km }}</strong> kWh/100km<br /><span class="text-mute sm">总 {{ r.total_kwh }} kWh</span></span>
|
||||
<span v-else class="text-mute sm">需里程</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!consumption.length"><td colspan="3" class="text-mute" style="text-align:center; padding:20px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import StatCard from '../components/StatCard.vue';
|
||||
import * as settingsApi from '../api/settings';
|
||||
import { reportMonths, reportExcelUrl, reportPdfUrl } from '../api/settings';
|
||||
import { maintApi, refuelApi, chargingApi } from '../api/logs';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import Chart from 'chart.js/auto';
|
||||
const stats = ref({});
|
||||
const usageCost = ref([]);
|
||||
const consumption = ref([]);
|
||||
const extraData = ref({ fuelTrend: [], costPerVehicle: [], washSeason: [] });
|
||||
const loading = ref(true);
|
||||
const monthCanvas = ref(null);
|
||||
const costCanvas = ref(null);
|
||||
const fuelCanvas = ref(null);
|
||||
const annualCanvas = ref(null);
|
||||
const seasonCanvas = ref(null);
|
||||
let monthChart = null, costChart = null, fuelChart = null, annualChart = null, seasonChart = null;
|
||||
|
||||
// 月度报表
|
||||
const months = ref([]);
|
||||
const reportMonth = ref(new Date().toISOString().slice(0, 7));
|
||||
const excelUrl = computed(() => reportExcelUrl(reportMonth.value));
|
||||
const pdfUrl = computed(() => reportPdfUrl(reportMonth.value));
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// 拉可用月份
|
||||
try {
|
||||
const mR = await reportMonths(12);
|
||||
months.value = mR.data?.months || [];
|
||||
if (months.value.length) reportMonth.value = months.value[0];
|
||||
} catch {}
|
||||
|
||||
const [r, vR, mR, rR, cR, extraR] = await Promise.all([
|
||||
settingsApi.overview(),
|
||||
vehiclesApi.list(),
|
||||
maintApi.list({ limit: 100 }),
|
||||
refuelApi.list({ limit: 100 }),
|
||||
chargingApi.list({ limit: 100 }),
|
||||
// 3 个真正有用的图
|
||||
fetch('/api/stats/extra', { credentials: 'same-origin' }).then(x => x.json()).catch(() => ({ data: { fuelTrend: [], costPerVehicle: [], washSeason: [] } })),
|
||||
]);
|
||||
const d = r.data || {};
|
||||
stats.value = {
|
||||
...(d.overview || {}),
|
||||
monthly_freq: d.monthly_freq || [],
|
||||
monthly_cost: d.monthly_cost || [],
|
||||
vehicle_breakdown: d.vehicle_breakdown || [],
|
||||
chemical_top: d.chemical_top || [],
|
||||
};
|
||||
// 算按车辆成本
|
||||
const vehicles = vR.data || [];
|
||||
const maints = mR.data?.rows || [];
|
||||
const refuels = rR.data?.rows || [];
|
||||
const charges = cR.data?.rows || [];
|
||||
usageCost.value = vehicles.map(v => {
|
||||
const wash = (stats.value.vehicle_breakdown || []).find(x => x.id === v.id)?.cost || 0;
|
||||
const maint = maints.filter(x => x.vehicle_id === v.id).reduce((s, x) => s + (x.total_cost || 0), 0);
|
||||
const refuel = refuels.filter(x => x.vehicle_id === v.id).reduce((s, x) => s + (x.total_cost || 0), 0);
|
||||
const charge = charges.filter(x => x.vehicle_id === v.id).reduce((s, x) => s + (x.total_cost || 0), 0);
|
||||
return { ...v, wash, maint, refuel, charge, total: wash + maint + refuel + charge };
|
||||
}).sort((a, b) => b.total - a.total);
|
||||
// 算油耗/电耗(只算有有效数据的车辆)
|
||||
consumption.value = vehicles.map(v => {
|
||||
const myRefuels = refuels.filter(x => x.vehicle_id === v.id && x.consumption_100km > 0);
|
||||
const myCharges = charges.filter(x => x.vehicle_id === v.id && x.kwh_per_100km > 0);
|
||||
return {
|
||||
id: v.id, name: v.name, plate: v.plate,
|
||||
l_per_100km: myRefuels.length ? (myRefuels.reduce((s,x) => s + x.consumption_100km, 0) / myRefuels.length).toFixed(2) : null,
|
||||
total_liters: refuels.filter(x => x.vehicle_id === v.id).reduce((s,x) => s + (x.liters || 0), 0).toFixed(1),
|
||||
kwh_per_100km: myCharges.length ? (myCharges.reduce((s,x) => s + x.kwh_per_100km, 0) / myCharges.length).toFixed(2) : null,
|
||||
total_kwh: charges.filter(x => x.vehicle_id === v.id).reduce((s,x) => s + (x.kwh || 0), 0).toFixed(1),
|
||||
};
|
||||
});
|
||||
// 关键顺序:先 loading=false 让 chart-card 进入 DOM,再 nextTick 等挂载完成,
|
||||
// 然后才能拿到 canvas ref。
|
||||
loading.value = false;
|
||||
await nextTick();
|
||||
drawMonth(stats.value.monthly_freq || []);
|
||||
drawCost(stats.value.monthly_cost || []);
|
||||
// 3 个新图
|
||||
extraData.value = extraR.data || { fuelTrend: [], costPerVehicle: [], washSeason: [] };
|
||||
drawFuel(extraData.value.fuelTrend || []);
|
||||
drawAnnual(extraData.value.costPerVehicle || []);
|
||||
drawSeason(extraData.value.washSeason || []);
|
||||
} finally { loading.value = false; }
|
||||
});
|
||||
|
||||
function drawMonth(data) {
|
||||
if (!monthCanvas.value) return;
|
||||
if (monthChart) monthChart.destroy();
|
||||
const hasData = data && data.length > 0;
|
||||
monthChart = new Chart(monthCanvas.value, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: hasData ? data.map(d => d.month) : ['暂无数据'],
|
||||
datasets: [{
|
||||
data: hasData ? data.map(d => d.count) : [0],
|
||||
borderColor: '#4DBA9A', backgroundColor: 'rgba(77, 186, 154, 0.12)',
|
||||
tension: 0.3, fill: true, pointRadius: 4, pointBackgroundColor: '#4DBA9A',
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: hasData } },
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
function drawCost(data) {
|
||||
if (!costCanvas.value) return;
|
||||
if (costChart) costChart.destroy();
|
||||
const hasData = data && data.length > 0;
|
||||
costChart = new Chart(costCanvas.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hasData ? data.map(d => d.month) : ['暂无数据'],
|
||||
datasets: [{
|
||||
data: hasData ? data.map(d => d.cost) : [0],
|
||||
backgroundColor: '#1E5B8A', borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: hasData } },
|
||||
scales: { y: { beginAtZero: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 油价趋势(按月)— 优先用 unit_price,没记单价时用 amount/liters 兜底
|
||||
function drawFuel(data) {
|
||||
if (!fuelCanvas.value) return;
|
||||
if (fuelChart) fuelChart.destroy();
|
||||
const hasData = data && data.length > 0;
|
||||
const price = hasData ? data.map(d => d.derived_unit_price || 0) : [0];
|
||||
fuelChart = new Chart(fuelCanvas.value, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: hasData ? data.map(d => d.ym) : ['暂无数据'],
|
||||
datasets: [{
|
||||
label: '元/升',
|
||||
data: price,
|
||||
borderColor: '#E89653', backgroundColor: 'rgba(232, 150, 83, 0.15)',
|
||||
tension: 0.3, fill: true, pointRadius: 4, pointBackgroundColor: '#E89653',
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: hasData, callbacks: { label: (ctx) => `¥${ctx.parsed.y.toFixed(2)}/L` } } },
|
||||
scales: { y: { beginAtZero: false, ticks: { callback: (v) => `¥${v}` } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 年均养护成本(按车辆,水平条形图)
|
||||
function drawAnnual(data) {
|
||||
if (!annualCanvas.value) return;
|
||||
if (annualChart) annualChart.destroy();
|
||||
const hasData = data && data.length > 0;
|
||||
// 画前 8 名,避免标签拥挤
|
||||
const top = hasData ? data.slice(0, 8).reverse() : [];
|
||||
annualChart = new Chart(annualCanvas.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hasData ? top.map(d => `${d.name}${d.plate ? ' (' + d.plate + ')' : ''}`) : ['暂无数据'],
|
||||
datasets: [{
|
||||
label: '年化 ¥',
|
||||
data: hasData ? top.map(d => Number(d.annual_cost) || 0) : [0],
|
||||
backgroundColor: '#4DBA9A', borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
indexAxis: 'y',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: hasData, callbacks: { label: (ctx) => `¥${ctx.parsed.x.toFixed(0)} / 年` } } },
|
||||
scales: { x: { beginAtZero: true, ticks: { callback: (v) => `¥${v}` } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 洗车频率 vs 季节 — 柱状图 + 季度背景色
|
||||
function drawSeason(data) {
|
||||
if (!seasonCanvas.value) return;
|
||||
if (seasonChart) seasonChart.destroy();
|
||||
const hasData = data && data.length > 0;
|
||||
const monthColor = ['#1E5B8A','#1E5B8A','#4DBA9A','#4DBA9A','#4DBA9A','#E89653','#E89653','#E89653','#D17A3A','#D17A3A','#1E5B8A','#1E5B8A'];
|
||||
seasonChart = new Chart(seasonCanvas.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: hasData ? data.map(d => d.ym) : ['暂无数据'],
|
||||
datasets: [{
|
||||
label: '洗车次数',
|
||||
data: hasData ? data.map(d => d.cnt) : [0],
|
||||
backgroundColor: hasData ? data.map(d => monthColor[(d.month || 1) - 1] || '#4DBA9A') : ['#ccc'],
|
||||
borderRadius: 6,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: hasData } },
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-wrap { position: relative; height: 200px; width: 100%; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; }
|
||||
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
.mb-4 { margin-bottom: 18px; }
|
||||
.report-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.report-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0; }
|
||||
.report-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.report-actions .select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; background: var(--bg); }
|
||||
.chart-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 16px; }
|
||||
.bar-wrap { position: relative; height: 18px; background: var(--bg-soft); border-radius: 4px; overflow: hidden; min-width: 100px; }
|
||||
.bar { background: var(--green); height: 100%; border-radius: 4px; }
|
||||
.bar-text { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); font-size: 11px; color: var(--text-soft); }
|
||||
@media (max-width: 900px) {
|
||||
.kpi-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head .btn { width: 100%; justify-content: center; }
|
||||
.kpi-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.kpi-val { font-size: 22px; }
|
||||
.filter-row { grid-template-columns: 1fr; }
|
||||
.chart-wrap { height: 160px; }
|
||||
.breakdown { grid-template-columns: 1fr; }
|
||||
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.kpi-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,379 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||
<div v-else>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">
|
||||
{{ vehicle.name }}
|
||||
<span v-if="!vehicle.is_active" class="pill pill-danger ml-2">停用</span>
|
||||
</h1>
|
||||
<p class="subtitle text-soft">
|
||||
<router-link to="/vehicles" class="text-soft">← 返回车辆列表</router-link>
|
||||
</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<router-link :to="`/vehicles/${vehicle.id}/edit`" class="btn btn-ghost">编辑车辆</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 车辆基本信息 + 累计 -->
|
||||
<div class="row">
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">车辆信息</h3>
|
||||
<table class="data">
|
||||
<tbody>
|
||||
<tr><td class="text-soft" style="width:120px">车牌</td><td><strong>{{ vehicle.plate }}</strong></td></tr>
|
||||
<tr><td class="text-soft">类型</td><td>{{ typeLabel(vehicle.type) }}</td></tr>
|
||||
<tr>
|
||||
<td class="text-soft">动力</td>
|
||||
<td>
|
||||
<span :class="['pill', powertrainPill(vehicle.powertrain)]">
|
||||
{{ powertrainIcon(vehicle.powertrain) }} {{ powertrainLabel(vehicle.powertrain) }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="vehicle.color"><td class="text-soft">颜色</td><td>{{ vehicle.color }}</td></tr>
|
||||
<tr v-if="vehicle.notes"><td class="text-soft">备注</td><td>{{ vehicle.notes }}</td></tr>
|
||||
<tr v-if="health?.current_km">
|
||||
<td class="text-soft">当前里程</td>
|
||||
<td><strong>{{ health.current_km.toLocaleString() }} km</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">累计数据</h3>
|
||||
<div class="big-stats">
|
||||
<div><span class="text-soft sm">洗车</span><strong>{{ health?.totals?.wash_count || 0 }}</strong><span class="text-mute sm">次 · ¥{{ (health?.totals?.wash_cost || 0).toFixed(0) }}</span></div>
|
||||
<div><span class="text-soft sm">保养</span><strong>{{ health?.totals?.maint_count || 0 }}</strong><span class="text-mute sm">次 · ¥{{ (health?.totals?.maint_cost || 0).toFixed(0) }}</span></div>
|
||||
<div><span class="text-soft sm">加油</span><strong>{{ (health?.totals?.refuel_liters || 0).toFixed(0) }}L</strong><span class="text-mute sm">¥{{ (health?.totals?.refuel_cost || 0).toFixed(0) }}</span></div>
|
||||
<div><span class="text-soft sm">充电</span><strong>{{ (health?.totals?.charge_kwh || 0).toFixed(0) }}kWh</strong><span class="text-mute sm">¥{{ (health?.totals?.charge_cost || 0).toFixed(0) }}</span></div>
|
||||
</div>
|
||||
<div class="grand-total mt-3">
|
||||
<span class="text-soft">持有总成本</span>
|
||||
<strong class="text-brand">¥{{ (health?.totals?.grand || 0).toFixed(2) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 健康卡片:油耗/电耗 + 洗车新鲜度 + 保养预测 -->
|
||||
<div class="row mt-4">
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">能耗 / 效率</h3>
|
||||
<div v-if="health?.avg_consumption?.l_per_100km || health?.avg_consumption?.kwh_per_100km" class="eff-grid">
|
||||
<div v-if="health?.avg_consumption?.l_per_100km" class="eff-item">
|
||||
<div class="eff-label">平均油耗</div>
|
||||
<div class="eff-value">{{ health.avg_consumption.l_per_100km }} <small>L/100km</small></div>
|
||||
</div>
|
||||
<div v-if="health?.avg_consumption?.kwh_per_100km" class="eff-item">
|
||||
<div class="eff-label">平均电耗</div>
|
||||
<div class="eff-value">{{ health.avg_consumption.kwh_per_100km }} <small>kWh/100km</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-mute sm">需要至少一次「加满+里程」的加油或充电记录才能计算</div>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">洗车新鲜度</h3>
|
||||
<div v-if="health?.wash_recency">
|
||||
<div class="big">{{ health.wash_recency.last_date }}</div>
|
||||
<div :class="['pill', health.wash_recency.overdue ? 'pill-warn' : 'pill-green']" class="mt-1">
|
||||
{{ health.wash_recency.days_since }} 天没洗
|
||||
<span v-if="health.wash_recency.overdue">· 该洗了</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-mute sm">暂无洗车记录</div>
|
||||
</div>
|
||||
<div class="card card-pad">
|
||||
<h3 class="section-title">下次保养预测</h3>
|
||||
<div v-if="health?.next_maintenance">
|
||||
<div v-if="health.next_maintenance.urgent" class="danger-banner">
|
||||
⚠️ 已超过保养里程,建议尽快保养
|
||||
</div>
|
||||
<div class="big">距 {{ health.next_maintenance.next_due_km }} km</div>
|
||||
<div class="bar-wrap mt-2">
|
||||
<div class="bar" :style="{ width: health.next_maintenance.km_remaining_pct + '%' }"></div>
|
||||
</div>
|
||||
<div class="text-mute sm mt-1">还剩 {{ health.next_maintenance.km_remaining }} km · 上次 {{ health.next_maintenance.last_date }}</div>
|
||||
</div>
|
||||
<div v-else class="text-mute sm">尚未设置下次保养里程(在保养记录里设置「下次里程」即可启用预测)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 月度成本趋势 -->
|
||||
<div class="row mt-4" v-if="health?.monthly?.length">
|
||||
<div class="card card-pad" style="grid-column: 1 / -1">
|
||||
<h3 class="section-title">近 6 月月度成本</h3>
|
||||
<div class="chart-wrap"><canvas ref="monthCanvas"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 记录 tabs -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-pad tabs-head">
|
||||
<h3 class="section-title" style="margin:0">所有记录</h3>
|
||||
<div class="tab-bar">
|
||||
<button :class="['tab', { active: tab === 'wash' }]" @click="tab = 'wash'">洗车 <span class="badge">{{ washes.length }}</span></button>
|
||||
<button :class="['tab', { active: tab === 'maint' }]" @click="tab = 'maint'">保养 <span class="badge">{{ maints.length }}</span></button>
|
||||
<button :class="['tab', { active: tab === 'refuel' }]" @click="tab = 'refuel'">加油 <span class="badge">{{ refuels.length }}</span></button>
|
||||
<button :class="['tab', { active: tab === 'charge' }]" @click="tab = 'charge'">充电 <span class="badge">{{ charges.length }}</span></button>
|
||||
<button :class="['tab', { active: tab === 'ins' }]" @click="tab = 'ins'">保险 <span class="badge">{{ insurances.length }}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table v-if="tab === 'wash'" class="data">
|
||||
<thead><tr><th>日期</th><th>类型</th><th class="r">花费</th><th>用品</th><th>位置</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="w in washes" :key="w.id">
|
||||
<td>{{ w.wash_date }}</td>
|
||||
<td><span class="pill pill-blue">{{ washTypeLabel(w.wash_type) }}</span></td>
|
||||
<td class="r text-brand"><strong>¥{{ (w.cost || 0).toFixed(2) }}</strong></td>
|
||||
<td>
|
||||
<span v-for="(c, i) in (w.chemicals || [])" :key="i" class="pill pill-gray sm mr-1">{{ c.chemical_name }} {{ c.amount }}{{ c.unit || '' }}</span>
|
||||
</td>
|
||||
<td class="text-soft">{{ w.location || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!washes.length"><td colspan="5" class="text-mute" style="text-align:center;padding:24px">暂无洗车记录</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table v-if="tab === 'maint'" class="data">
|
||||
<thead><tr><th>日期</th><th>项目</th><th>总里程</th><th>下次里程</th><th>店名</th><th class="r">花费</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in maints" :key="r.id">
|
||||
<td>{{ r.maint_date }}</td>
|
||||
<td><span v-for="(it, i) in r.items" :key="i" class="pill pill-gray sm mr-1">{{ it.name }}</span></td>
|
||||
<td>{{ r.odometer_km || '—' }} km</td>
|
||||
<td>{{ r.next_due_km || '—' }} km</td>
|
||||
<td>{{ r.shop || '—' }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ (r.total_cost || 0).toFixed(2) }}</strong></td>
|
||||
</tr>
|
||||
<tr v-if="!maints.length"><td colspan="6" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table v-if="tab === 'refuel'" class="data">
|
||||
<thead><tr><th>日期</th><th>油号</th><th>里程</th><th>升数</th><th>单价</th><th class="r">花费</th><th>油耗</th><th>加油站</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in refuels" :key="r.id">
|
||||
<td>{{ r.refuel_date }}</td>
|
||||
<td><span class="pill pill-gray">{{ r.fuel_type || '—' }}</span></td>
|
||||
<td>{{ r.odometer_km || '—' }} km</td>
|
||||
<td><strong>{{ r.liters }}L</strong></td>
|
||||
<td>¥{{ r.price_per_liter || 0 }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ (r.total_cost || 0).toFixed(2) }}</strong></td>
|
||||
<td><span v-if="r.consumption_100km" class="pill pill-blue">{{ r.consumption_100km.toFixed(2) }} L/100km</span><span v-else class="text-mute sm">{{ r.consumption_skip_reason || '需加满+里程' }}</span></td>
|
||||
<td>{{ r.station || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!refuels.length"><td colspan="8" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table v-if="tab === 'charge'" class="data">
|
||||
<thead><tr><th>日期</th><th>类型</th><th>里程</th><th>度数</th><th>SOC</th><th>单价</th><th class="r">花费</th><th>电耗</th><th>地点</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in charges" :key="r.id">
|
||||
<td>{{ r.charge_date }}</td>
|
||||
<td><span class="pill pill-blue">{{ CHARGE_LABEL[r.charge_type] || r.charge_type || '—' }}</span></td>
|
||||
<td>{{ r.odometer_km || '—' }} km</td>
|
||||
<td><strong>{{ r.kwh }} kWh</strong></td>
|
||||
<td><span v-if="r.start_soc != null && r.end_soc != null">{{ r.start_soc }}→{{ r.end_soc }}%</span><span v-else class="text-mute">—</span></td>
|
||||
<td>¥{{ r.price_per_kwh || 0 }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ (r.total_cost || 0).toFixed(2) }}</strong></td>
|
||||
<td><span v-if="r.kwh_per_100km" class="pill pill-blue">{{ r.kwh_per_100km.toFixed(2) }} kWh/100km</span><span v-else class="text-mute sm">{{ r.consumption_skip_reason || '需里程' }}</span></td>
|
||||
<td>{{ r.station || '—' }}</td>
|
||||
</tr>
|
||||
<tr v-if="!charges.length"><td colspan="9" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table v-if="tab === 'ins'" class="data">
|
||||
<thead><tr><th>状态</th><th>险种</th><th>公司</th><th>保单号</th><th>生效日</th><th>到期日</th><th class="r">保费</th><th>附件</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="r in insurances" :key="r.id">
|
||||
<td>
|
||||
<span :class="['pill', insStatusPill(r.status)]">
|
||||
{{ insStatusLabel(r.status) }}
|
||||
<span v-if="r.status === 'expiring'" class="sm">· {{ r.days_to_expire }}d</span>
|
||||
</span>
|
||||
</td>
|
||||
<td><strong>{{ r.insurance_type }}</strong></td>
|
||||
<td>{{ r.company || '—' }}</td>
|
||||
<td class="text-soft sm">{{ r.policy_no || '—' }}</td>
|
||||
<td>{{ r.start_date }}</td>
|
||||
<td>{{ r.end_date }}</td>
|
||||
<td class="r text-brand"><strong>¥{{ (r.premium || 0).toFixed(0) }}</strong></td>
|
||||
<td>
|
||||
<a v-if="r.attachment_path" :href="`/api/${r.attachment_path}`" target="_blank" class="btn btn-ghost btn-sm">查看</a>
|
||||
<span v-else class="text-mute sm">无</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!insurances.length"><td colspan="8" class="text-mute" style="text-align:center;padding:24px">暂无</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import * as washesApi from '../api/washes';
|
||||
import { maintApi, refuelApi, chargingApi } from '../api/logs';
|
||||
import * as insuranceApi from '../api/insurance';
|
||||
import { asArray } from '../api/client';
|
||||
import Chart from 'chart.js/auto';
|
||||
|
||||
const CHARGE_LABEL = { home: '家充', slow: '慢充(交流)', fast: '快充(直流)', public: '公共桩' };
|
||||
|
||||
const route = useRoute();
|
||||
const vehicle = ref({});
|
||||
const health = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const tab = ref('wash');
|
||||
const monthCanvas = ref(null);
|
||||
let monthChart = null;
|
||||
|
||||
const washes = ref([]);
|
||||
const maints = ref([]);
|
||||
const refuels = ref([]);
|
||||
const charges = ref([]);
|
||||
const insurances = ref([]);
|
||||
|
||||
const typeLabel = (t) => ({ car: '轿车', suv: 'SUV', mpv: 'MPV', truck: '货车', other: '其他' }[t] || t || '其他');
|
||||
const washTypeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||
const insStatusLabel = (s) => ({ active: '有效', expiring: '即将到期', expired: '已过期' }[s] || s);
|
||||
const insStatusPill = (s) => ({ active: 'pill-green', expiring: 'pill-warn', expired: 'pill-gray' }[s] || 'pill-gray');
|
||||
const powertrainLabel = (p) => ({ ice: '纯油', hev: '混动', ev: '纯电', erev: '增程' }[p] || p || '纯油');
|
||||
const powertrainIcon = (p) => ({ ice: '🛢️', hev: '⚡🛢️', ev: '⚡', erev: '🔋🛢️' }[p] || '🛢️');
|
||||
const powertrainPill = (p) => ({ ice: 'pill-gray', hev: 'pill-blue', ev: 'pill-green', erev: 'pill-warn' }[p] || 'pill-gray');
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const id = route.params.id;
|
||||
const [vR, hR, wR, mR, rR, cR, iR] = await Promise.all([
|
||||
vehiclesApi.list(),
|
||||
vehiclesApi.health(id).catch(() => ({ data: null })),
|
||||
washesApi.list({ vehicle_id: id, limit: 50 }),
|
||||
maintApi.list({ vehicle_id: id, limit: 50 }),
|
||||
refuelApi.list({ vehicle_id: id, limit: 50 }),
|
||||
chargingApi.list({ vehicle_id: id, limit: 50 }),
|
||||
insuranceApi.list({ vehicle_id: id }),
|
||||
]);
|
||||
vehicle.value = asArray(vR.data, 'vehicles').find(x => String(x.id) === String(id)) || {};
|
||||
health.value = hR.data;
|
||||
washes.value = wR.data?.rows || [];
|
||||
maints.value = mR.data?.rows || [];
|
||||
refuels.value = rR.data?.rows || [];
|
||||
charges.value = cR.data?.rows || [];
|
||||
insurances.value = iR.data?.rows || [];
|
||||
|
||||
await nextTick();
|
||||
drawMonth();
|
||||
} catch (e) {
|
||||
error.value = e.message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function drawMonth() {
|
||||
if (!monthCanvas.value || !health.value?.monthly?.length) return;
|
||||
if (monthChart) monthChart.destroy();
|
||||
const labels = health.value.monthly.map(m => m.month);
|
||||
monthChart = new Chart(monthCanvas.value, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{ label: '洗车', data: health.value.monthly.map(m => Number(m.wash) || 0), backgroundColor: '#4DBA9A' },
|
||||
{ label: '加油', data: health.value.monthly.map(m => Number(m.refuel) || 0), backgroundColor: '#E89B5B' },
|
||||
{ label: '充电', data: health.value.monthly.map(m => Number(m.charge) || 0), backgroundColor: '#5DA5DA' },
|
||||
{ label: '保养', data: health.value.monthly.map(m => Number(m.maint) || 0), backgroundColor: '#9B7FD4' },
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: { legend: { position: 'top' } },
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true, beginAtZero: true, ticks: { callback: v => '¥' + v } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display:flex; justify-content:space-between; align-items:flex-end; margin-bottom:20px; }
|
||||
.title { font-size:24px; font-weight:600; margin:0; letter-spacing:-0.02em; display:flex; align-items:center; gap:8px; }
|
||||
.subtitle { margin:4px 0 0; font-size:14px; }
|
||||
.head-actions { display:flex; gap:8px; }
|
||||
.row { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
|
||||
.row.mt-4 { margin-top: 18px; }
|
||||
.row-3 { display:grid; grid-template-columns: 1fr 1fr 1fr; gap:18px; }
|
||||
.section-title { font-size:14px; font-weight:600; color:var(--text-soft); margin:0 0 16px; }
|
||||
.big-stats { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
|
||||
.big-stats > div { background:var(--bg-soft); border-radius:var(--radius-sm); padding:12px; display:flex; flex-direction:column; gap:2px; }
|
||||
.big-stats strong { font-size:24px; font-weight:700; letter-spacing:-0.02em; font-variant-numeric:tabular-nums; }
|
||||
.grand-total { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; background:#f6f8fa; border-radius:8px; }
|
||||
.grand-total strong { font-size:20px; font-variant-numeric:tabular-nums; }
|
||||
.eff-grid { display:grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.eff-item { background: var(--bg-soft); border-radius: 8px; padding: 12px; }
|
||||
.eff-label { font-size: 12px; color: var(--text-soft); }
|
||||
.eff-value { font-size: 22px; font-weight: 700; margin-top: 4px; }
|
||||
.eff-value small { font-size: 13px; font-weight: 500; color: var(--text-soft); }
|
||||
.big { font-size: 20px; font-weight: 600; margin-top: 2px; font-variant-numeric:tabular-nums; }
|
||||
.danger-banner { padding: 8px 12px; background: #FBE3DF; color: #C0392B; border-radius: 6px; font-size: 13px; margin-bottom: 10px; }
|
||||
.bar-wrap { position: relative; height: 14px; background: var(--bg-soft); border-radius: 7px; overflow: hidden; }
|
||||
.bar { background: var(--green); height: 100%; border-radius: 7px; transition: width .4s; }
|
||||
.chart-wrap { position: relative; height: 240px; width: 100%; }
|
||||
.tabs-head { display:flex; justify-content:space-between; align-items:center; padding-bottom:0; }
|
||||
.tab-bar { display:flex; gap:4px; }
|
||||
.tab { background:transparent; border:0; padding:6px 14px; border-radius:var(--pill); font-size:13px; color:var(--text-soft); cursor:pointer; transition:all .15s; display:flex; align-items:center; gap:6px; }
|
||||
.tab:hover { background:var(--bg-soft); color:var(--text); }
|
||||
.tab.active { background:var(--accent); color:#fff; }
|
||||
.badge { background:rgba(255,255,255,0.2); padding:1px 6px; border-radius:10px; font-size:11px; font-weight:600; }
|
||||
.tab:not(.active) .badge { background:var(--bg-soft); color:var(--text-soft); }
|
||||
.ml-2 { margin-left:8px; }
|
||||
.mt-3 { margin-top:14px; }
|
||||
.mt-4 { margin-top:18px; }
|
||||
.mt-1 { margin-top:4px; }
|
||||
.mt-2 { margin-top:8px; }
|
||||
.r { text-align:right; }
|
||||
.mr-1 { margin-right:4px; }
|
||||
.text-soft { color:var(--text-soft); }
|
||||
.text-mute { color:var(--text-mute); }
|
||||
.text-danger { color:var(--danger); }
|
||||
.text-brand { color:var(--brand); }
|
||||
.sm { font-size:11px; }
|
||||
@media (max-width: 1000px) {
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.row-3 { grid-template-columns: 1fr; }
|
||||
.eff-grid { grid-template-columns: 1fr 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.head-actions > * { flex: 1; min-width: 80px; justify-content: center; }
|
||||
.headline { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.plate { font-size: 22px; }
|
||||
.stat-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.stat-num { font-size: 20px; }
|
||||
.row-3 { grid-template-columns: 1fr; }
|
||||
.eff-grid { grid-template-columns: 1fr 1fr; }
|
||||
.card.card-pad { padding: 14px 16px; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.stat-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<h1 class="title">{{ isEdit ? '编辑车辆' : '新建车辆' }}</h1>
|
||||
<router-link to="/vehicles" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">名称 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.name" class="input" required placeholder="如:我的小车" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">车牌号 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.plate" class="input" required placeholder="如:京A·12345" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">车型</label>
|
||||
<select v-model="form.type" class="select">
|
||||
<option value="car">轿车</option>
|
||||
<option value="suv">SUV</option>
|
||||
<option value="mpv">MPV</option>
|
||||
<option value="truck">货车</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">动力类型 <span class="text-soft sm">— 决定油耗/电耗是否计算</span></label>
|
||||
<select v-model="form.powertrain" class="select">
|
||||
<option value="ice">🛢️ 纯油 (ice)</option>
|
||||
<option value="hev">⚡🛢️ 混动 (hev)</option>
|
||||
<option value="ev">⚡ 纯电 (ev)</option>
|
||||
<option value="erev">🔋🛢️ 增程 (erev)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">颜色</label>
|
||||
<input v-model="form.color" class="input" placeholder="如:白色" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="textarea" rows="3" placeholder="可选"></textarea>
|
||||
</div>
|
||||
<div v-if="isEdit" class="mt-3">
|
||||
<label class="check">
|
||||
<input type="checkbox" v-model="form.is_active" />
|
||||
<span>启用</span>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||||
<div class="actions mt-6">
|
||||
<button v-if="isEdit" type="button" class="btn btn-danger" @click="onRemove" :disabled="busy">删除</button>
|
||||
<div style="flex:1"></div>
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const isEdit = computed(() => !!route.params.id);
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
const form = reactive({ name: '', plate: '', type: 'car', powertrain: 'ice', color: '', notes: '', is_active: true });
|
||||
|
||||
// 401 草稿(编辑模式用 id 区分 key,避免与新建冲突)
|
||||
const draft = useFormDraft(isEdit.value ? `vehicles/${route.params.id}` : 'vehicles/new');
|
||||
const restored = draft.load();
|
||||
if (restored) Object.assign(form, restored);
|
||||
watch(form, (v) => draft.save({ ...v }), { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
onMounted(async () => {
|
||||
if (!isEdit.value) return;
|
||||
try {
|
||||
const r = await vehiclesApi.get(route.params.id);
|
||||
Object.assign(form, r.data);
|
||||
} catch (e) { error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败'; }
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
await vehiclesApi.update(route.params.id, form);
|
||||
} else {
|
||||
await vehiclesApi.create(form);
|
||||
}
|
||||
draft.clear();
|
||||
router.push({ name: 'vehicles' });
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.response?.data?.code || e.message || '保存失败';
|
||||
} finally { busy.value = false; }
|
||||
}
|
||||
|
||||
async function onRemove() {
|
||||
if (!confirm(`确认删除「${form.name}」?该车辆的洗车记录会保留但失去车辆关联。`)) return;
|
||||
busy.value = true;
|
||||
try {
|
||||
await vehiclesApi.remove(route.params.id);
|
||||
router.push({ name: 'vehicles' });
|
||||
} catch (e) { error.value = e.response?.data?.message || e.response?.data?.code || e.message || '删除失败'; }
|
||||
finally { busy.value = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.form { max-width: 720px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.check { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
|
||||
.error { color: var(--danger); background: #FBE3DF; padding: 8px 12px; border-radius: var(--radius-sm); font-size: 13px; }
|
||||
.actions { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head > a, .head > .actions { width: 100%; justify-content: center; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">车辆</h1>
|
||||
<p class="subtitle text-soft">共 {{ vehicles.length }} 辆</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<router-link to="/vehicles/new" class="btn btn-primary">+ 新建</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="!vehicles.length" class="card card-pad empty">
|
||||
<p class="text-soft" style="margin: 0 0 12px">还没有车辆</p>
|
||||
<router-link to="/vehicles/new" class="btn btn-primary">+ 添加第一辆车</router-link>
|
||||
</div>
|
||||
<div v-else class="v-grid">
|
||||
<div v-for="v in vehicles" :key="v.id" class="card v-card">
|
||||
<div class="v-icon">{{ typeIcon(v.type) }}</div>
|
||||
<div class="v-info">
|
||||
<div class="v-name">{{ v.name }}</div>
|
||||
<div class="v-plate">{{ v.plate }}</div>
|
||||
<div class="v-meta">
|
||||
<span class="pill pill-blue">{{ typeLabel(v.type) }}</span>
|
||||
<span :class="['pill', powertrainPill(v.powertrain)]">{{ powertrainIcon(v.powertrain) }} {{ powertrainLabel(v.powertrain) }}</span>
|
||||
<span v-if="v.color" class="pill pill-gray">{{ v.color }}</span>
|
||||
<span v-if="!v.is_active" class="pill pill-danger">停用</span>
|
||||
</div>
|
||||
<div class="v-stats">
|
||||
<div><span class="text-mute">洗车次数</span><strong>{{ v.wash_count || 0 }}</strong></div>
|
||||
<div><span class="text-mute">累计花费</span><strong>¥ {{ (v.total_cost || 0).toFixed(2) }}</strong></div>
|
||||
<div><span class="text-mute">最近</span><strong>{{ v.last_wash_date || '—' }}</strong></div>
|
||||
</div>
|
||||
<div v-if="v.notes" class="v-notes">{{ v.notes }}</div>
|
||||
</div>
|
||||
<div class="v-actions">
|
||||
<router-link :to="`/vehicles/${v.id}`" class="btn btn-ghost btn-sm">详情</router-link>
|
||||
<router-link :to="`/vehicles/${v.id}/edit`" class="btn btn-ghost btn-sm">编辑</router-link>
|
||||
<button class="btn btn-ghost btn-sm text-danger" @click="askDelete(v)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
||||
<ConfirmDangerDialog
|
||||
v-model="showDelete"
|
||||
title="确认删除车辆"
|
||||
:message="`确定要删除「${deleteTarget?.name}」吗?`"
|
||||
mode="type"
|
||||
confirm-label="确认删除"
|
||||
confirm-word="删除"
|
||||
:tips="['已删除车辆可在「操作日志」中恢复']"
|
||||
:busy="deleteBusy"
|
||||
:error="deleteError"
|
||||
@confirm="doDelete"
|
||||
@cancel="showDelete = false"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { asArray } from '../api/client';
|
||||
const vehicles = ref([]);
|
||||
const loading = ref(true);
|
||||
const typeLabel = (t) => ({ car: '轿车', suv: 'SUV', mpv: 'MPV', truck: '货车', other: '其他' }[t] || t || '其他');
|
||||
const typeIcon = (t) => ({ car: '🚗', suv: '🚙', mpv: '🚐', truck: '🛻', other: '🚘' }[t] || '🚘');
|
||||
const POWERTRAINS = { ice: '纯油', hev: '混动', ev: '纯电', erev: '增程' };
|
||||
const POWERTRAIN_ICON = { ice: '🛢️', hev: '⚡🛢️', ev: '⚡', erev: '🔋🛢️' };
|
||||
const powertrainLabel = (p) => POWERTRAINS[p] || p || '纯油';
|
||||
const powertrainIcon = (p) => POWERTRAIN_ICON[p] || '🛢️';
|
||||
const powertrainPill = (p) => ({ ice: 'pill-gray', hev: 'pill-blue', ev: 'pill-green', erev: 'pill-warn' }[p] || 'pill-gray');
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await vehiclesApi.list();
|
||||
vehicles.value = asArray(r.data, 'vehicles');
|
||||
} finally { loading.value = false; }
|
||||
});
|
||||
|
||||
const showDelete = ref(false);
|
||||
const deleteTarget = ref(null);
|
||||
const deleteBusy = ref(false);
|
||||
const deleteError = ref('');
|
||||
|
||||
function askDelete(v) {
|
||||
deleteTarget.value = v;
|
||||
deleteError.value = '';
|
||||
showDelete.value = true;
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleteTarget.value) return;
|
||||
deleteBusy.value = true;
|
||||
deleteError.value = '';
|
||||
try {
|
||||
await vehiclesApi.remove(deleteTarget.value.id);
|
||||
vehicles.value = vehicles.value.filter(v => v.id !== deleteTarget.value.id);
|
||||
showDelete.value = false;
|
||||
deleteTarget.value = null;
|
||||
} catch (err) {
|
||||
deleteError.value = err.message || '删除失败';
|
||||
} finally {
|
||||
deleteBusy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; gap: 12px; flex-wrap: wrap; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.v-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px; }
|
||||
.v-card { padding: 20px; display: grid; grid-template-columns: 56px 1fr auto; gap: 14px; align-items: start; }
|
||||
.v-icon {
|
||||
width: 56px; height: 56px; border-radius: 12px;
|
||||
background: var(--bg-soft); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 30px;
|
||||
}
|
||||
.v-name { font-size: 16px; font-weight: 600; }
|
||||
.v-plate { font-family: monospace; font-size: 12px; color: var(--text-soft); margin-top: 2px; }
|
||||
.v-meta { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
|
||||
.v-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); }
|
||||
.v-stats > div { display: flex; flex-direction: column; gap: 2px; font-size: 12px; }
|
||||
.v-stats strong { font-size: 14px; color: var(--text); }
|
||||
.v-notes { margin-top: 8px; font-size: 12px; color: var(--text-soft); }
|
||||
.v-actions { display: flex; flex-direction: column; gap: 6px; }
|
||||
.empty { text-align: center; padding: 48px 24px; }
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 1023px) {
|
||||
.v-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; }
|
||||
.v-card { padding: 16px; gap: 12px; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head .actions { width: 100%; }
|
||||
.head .actions .btn { width: 100%; justify-content: center; }
|
||||
.v-grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.v-card {
|
||||
grid-template-columns: 48px 1fr;
|
||||
padding: 14px;
|
||||
}
|
||||
.v-icon { width: 48px; height: 48px; font-size: 26px; }
|
||||
.v-info { grid-column: 1 / -1; }
|
||||
.v-actions {
|
||||
grid-column: 1 / -1;
|
||||
flex-direction: row;
|
||||
justify-content: stretch;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.v-actions .btn { flex: 1; justify-content: center; }
|
||||
.v-stats { grid-template-columns: repeat(3, 1fr); gap: 6px; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.v-stats { grid-template-columns: 1fr 1fr; }
|
||||
.v-stats > div:nth-child(3) { grid-column: 1 / -1; flex-direction: row; justify-content: space-between; align-items: center; }
|
||||
.v-stats > div:nth-child(3) strong { font-size: 13px; }
|
||||
.v-actions .btn { padding: 6px 8px; font-size: 12px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<h1 class="title">新建洗车记录</h1>
|
||||
<div class="head-actions">
|
||||
<button type="button" class="btn btn-ghost" @click="onAiRecognize" :disabled="aiBusy">
|
||||
<span v-if="aiBusy">识别中…</span>
|
||||
<span v-else>📷 AI 识别</span>
|
||||
</button>
|
||||
<router-link to="/washes" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiFallbackModal :show="ai.showFallback" :image-url="ai.fallback?.preview_url" @cancel="ai.cancelFallback()" @confirm="onManualConfirm">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">洗车日期 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.wash_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">类型 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.wash_type" class="select" required>
|
||||
<option value="quick">快速(15-30 分钟)</option>
|
||||
<option value="full">标准(30-60 分钟)</option>
|
||||
<option value="detail">精洗(1-2 小时)</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">车辆</label>
|
||||
<select v-model="form.vehicle_id" class="select">
|
||||
<option value="">不指定</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">位置</label>
|
||||
<input v-model="form.location" class="input" placeholder="家 / 公司 / 店名" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">花费 <span class="text-danger">*</span></label>
|
||||
<input v-model.number="form.cost" type="number" step="0.01" min="0" class="input" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="input" rows="2" placeholder="看着图填,看不清的留空"></textarea>
|
||||
</div>
|
||||
</AiFallbackModal>
|
||||
|
||||
<form @submit.prevent="onSubmit" class="card card-pad form">
|
||||
<div class="grid">
|
||||
<div>
|
||||
<label class="label">洗车日期 <span class="text-danger">*</span></label>
|
||||
<input v-model="form.wash_date" type="date" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">类型 <span class="text-danger">*</span></label>
|
||||
<select v-model="form.wash_type" class="select" required>
|
||||
<option value="quick">快速(15-30 分钟)</option>
|
||||
<option value="full">标准(30-60 分钟)</option>
|
||||
<option value="detail">精洗(1-2 小时)</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">车辆</label>
|
||||
<select v-model="form.vehicle_id" class="select">
|
||||
<option value="">不指定</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">位置</label>
|
||||
<input v-model="form.location" class="input" placeholder="家 / 公司 / 店名" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">花费 (¥) <span class="text-danger">*</span></label>
|
||||
<input v-model.number="form.cost" type="number" step="0.01" min="0" class="input" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">耗时 (分钟)</label>
|
||||
<input v-model.number="form.duration_min" type="number" min="0" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="label">备注</label>
|
||||
<textarea v-model="form.notes" class="textarea" rows="2" placeholder="可选"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label class="label">化学品使用(可选)</label>
|
||||
<div class="chem-list">
|
||||
<div v-for="(c, i) in chemRows" :key="i" class="chem-row">
|
||||
<div class="chem-picker-col">
|
||||
<ChemPicker
|
||||
v-model="c.chemical_id"
|
||||
:chemicals="chemicals"
|
||||
:placeholder="availableUnits.length === 0 ? '化学品搜索…' : '搜索化学品(名称/分类)…'"
|
||||
@change="onChemChange(i, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="chem-amount-col">
|
||||
<select v-model="c.unit" class="select chem-unit" :disabled="!c.chemical_id">
|
||||
<option v-for="u in availableUnits" :key="u.id" :value="u.name">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
<input v-model.number="c.amount" type="number" step="0.01" min="0" placeholder="用量" class="input chem-amt" :disabled="!c.chemical_id" />
|
||||
</div>
|
||||
<span class="chem-equiv" v-if="c.chemical_id && c.amount > 0">
|
||||
= {{ computedStockAmount(c) }} {{ stockUnit(c) }}
|
||||
</span>
|
||||
<button type="button" class="btn btn-ghost btn-sm del-btn" @click="chemRows.splice(i, 1)">×</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="chemRows.push({ chemical_id: '', unit: '毫升', amount: 0 })">+ 添加化学品</button>
|
||||
</div>
|
||||
<p class="text-mute sm mt-2">
|
||||
💡 输入单位可任选(如「0.5 加仑」「100 毫升」),系统自动换算成 Grocy 库存单位(最小精度 = 毫升)。
|
||||
<br>
|
||||
💡 单位换算关系来自 Grocy product 的 <code>userfields.qu_factor</code> 字段(默认 1)。修改去 Grocy 后台 → Master data → Products → Userfields。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error mt-3">{{ error }}</p>
|
||||
|
||||
<div class="actions mt-6">
|
||||
<button type="button" class="btn btn-ghost" @click="$router.back()">取消</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="busy">{{ busy ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import ChemPicker from '../components/ChemPicker.vue';
|
||||
import * as washesApi from '../api/washes';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import * as chemicalsApi from '../api/chemicals';
|
||||
import { asArray } from '../api/client';
|
||||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||
import { useFormDraft, registerDraftForFlush } from '../utils/formDraft';
|
||||
import AiFallbackModal from '../components/AiFallbackModal.vue';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const vehicles = ref([]);
|
||||
const chemicals = ref([]);
|
||||
const error = ref('');
|
||||
const busy = ref(false);
|
||||
|
||||
// AI 识别(洗车小票/订单截图)
|
||||
const ai = useAiRecognize();
|
||||
const aiBusy = ai.busy;
|
||||
async function onAiRecognize() {
|
||||
await ai.open('wash', (data) => {
|
||||
if (data.wash_date) form.wash_date = data.wash_date;
|
||||
if (data.wash_type) form.wash_type = data.wash_type;
|
||||
if (data.cost != null) form.cost = data.cost;
|
||||
if (data.location) form.location = data.location;
|
||||
if (data.notes) form.notes = (form.notes ? form.notes + '\n' : '') + data.notes;
|
||||
// 如果识别出 vehicle_hint,提示给用户(不自动选)
|
||||
if (data.vehicle_hint) {
|
||||
console.log('AI 识别到车辆线索:', data.vehicle_hint);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// AI 兜底 modal 提交:用户对着图手填后,确认。把 modal 关闭
|
||||
// (form 字段已经双向绑定到 reactive,无需特殊处理)
|
||||
function onManualConfirm() {
|
||||
ai.cancelFallback();
|
||||
}
|
||||
const form = reactive({
|
||||
wash_date: new Date().toISOString().slice(0, 10),
|
||||
wash_type: 'full',
|
||||
vehicle_id: '',
|
||||
location: '',
|
||||
cost: 0,
|
||||
duration_min: 0,
|
||||
notes: '',
|
||||
});
|
||||
const chemRows = ref([]);
|
||||
|
||||
// Grocy 的所有 quantity_units(用户能选的单位)
|
||||
const availableUnits = ref([]);
|
||||
|
||||
const chemMap = computed(() => {
|
||||
const m = {};
|
||||
for (const c of chemicals.value) m[c.grocy_product_id] = c;
|
||||
return m;
|
||||
});
|
||||
|
||||
function stockUnit(row) {
|
||||
const c = chemMap.value[row.chemical_id];
|
||||
return c?.unit || '—';
|
||||
}
|
||||
|
||||
function computedStockAmount(row) {
|
||||
const c = chemMap.value[row.chemical_id];
|
||||
if (!c) return '—';
|
||||
const quFactor = Number(c.qu_factor || 1);
|
||||
const v = Number(row.amount || 0) * quFactor;
|
||||
// 3 位小数
|
||||
return (Math.round(v * 1000) / 1000).toString();
|
||||
}
|
||||
|
||||
function onChemChange(i, ch) {
|
||||
// 选中产品后自动设为 stock unit
|
||||
const row = chemRows.value[i];
|
||||
if (ch?.unit) row.unit = ch.unit;
|
||||
}
|
||||
|
||||
// 表单草稿:401 跳转登录前自动 flush,登录后回原页恢复
|
||||
const draft = useFormDraft('washes/new');
|
||||
const restored = draft.load();
|
||||
if (restored) {
|
||||
if (restored.form) Object.assign(form, restored.form);
|
||||
if (Array.isArray(restored.chemRows) && restored.chemRows.length) chemRows.value = restored.chemRows;
|
||||
}
|
||||
watch([form, chemRows], () => {
|
||||
draft.save({ form: { ...form }, chemRows: chemRows.value });
|
||||
}, { deep: true });
|
||||
const unregisterFlush = registerDraftForFlush(() => draft.flush());
|
||||
onBeforeUnmount(() => unregisterFlush());
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await vehiclesApi.list({ active: 1 });
|
||||
vehicles.value = asArray(r.data, 'vehicles');
|
||||
} catch {}
|
||||
try {
|
||||
const r = await chemicalsApi.all();
|
||||
chemicals.value = asArray(r.data, 'chemicals');
|
||||
} catch {}
|
||||
// 拉 quantity_units 给单位下拉用
|
||||
try {
|
||||
const r = await fetch('/api/objects/quantity_units', { credentials: 'include' });
|
||||
const j = await r.json();
|
||||
if (Array.isArray(j)) availableUnits.value = j;
|
||||
} catch {}
|
||||
// PWA 快捷方式:?capture=1 → 自动唤起相机
|
||||
if (route.query.capture === '1' || route.query.capture === 1) {
|
||||
setTimeout(() => triggerCamera(), 400);
|
||||
}
|
||||
});
|
||||
|
||||
function triggerCamera() {
|
||||
// 优先用 hidden 的 file input(无 camera 时回退为普通文件选择)
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.setAttribute('capture', 'environment');
|
||||
input.style.display = 'none';
|
||||
input.onchange = async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
input.remove();
|
||||
if (!f) return;
|
||||
// 直接走 AI 识别流程(识别后会自动回填本表单)
|
||||
try {
|
||||
await ai.recognizeFromFile(f, 'wash');
|
||||
} catch (err) {
|
||||
// 失败时 fallback modal 会自动打开
|
||||
}
|
||||
};
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
error.value = '';
|
||||
busy.value = true;
|
||||
try {
|
||||
const payload = { ...form };
|
||||
if (!payload.vehicle_id) delete payload.vehicle_id;
|
||||
const chemicals_ = chemRows.value
|
||||
.filter(c => c.chemical_id && c.amount > 0)
|
||||
.map(c => ({ chemical_id: c.chemical_id, amount: c.amount, unit: c.unit }));
|
||||
if (chemicals_.length) payload.chemicals = chemicals_;
|
||||
const r = await washesApi.create(payload);
|
||||
draft.clear();
|
||||
router.push({ name: 'wash-show', params: { id: r.data?.id || r.data?.row?.id } });
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.response?.data?.code || '保存失败:' + e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.form { max-width: 760px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
|
||||
.chem-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.chem-row {
|
||||
display: flex; gap: 8px; align-items: flex-start;
|
||||
}
|
||||
.chem-picker-col { flex: 1; min-width: 0; }
|
||||
.chem-amount-col { display: flex; gap: 6px; align-items: center; flex-shrink: 0; }
|
||||
.chem-unit { width: 80px; font-size: 13px; }
|
||||
.chem-amt { width: 90px; font-variant-numeric: tabular-nums; }
|
||||
.chem-equiv { font-size: 12px; color: var(--text-soft); white-space: nowrap; line-height: 36px; flex-shrink: 0; }
|
||||
.del-btn { flex-shrink: 0; line-height: 36px; }
|
||||
.mt-2 { margin-top: 8px; }
|
||||
.error {
|
||||
color: var(--danger); background: #FBE3DF; padding: 8px 12px;
|
||||
border-radius: var(--radius-sm); font-size: 13px;
|
||||
}
|
||||
.actions { display: flex; justify-content: flex-end; gap: 12px; }
|
||||
@media (max-width: 800px) { .grid { grid-template-columns: 1fr 1fr; } }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head .actions { width: 100%; }
|
||||
.head .actions > * { flex: 1; justify-content: center; }
|
||||
.grid { grid-template-columns: 1fr; }
|
||||
.actions { position: sticky; bottom: 0; padding-top: 12px; background: var(--card); }
|
||||
.actions .btn { flex: 1; min-height: 44px; justify-content: center; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<h1 class="title">洗车详情</h1>
|
||||
<div class="head-actions">
|
||||
<router-link to="/washes" class="btn btn-ghost btn-sm">← 返回</router-link>
|
||||
<button class="btn btn-ghost" @click="onAiRecognize" :disabled="aiBusy">
|
||||
<span v-if="aiBusy">识别中…</span>
|
||||
<span v-else>📷 AI 识别</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="card card-pad text-soft">加载中…</div>
|
||||
<div v-else-if="error" class="card card-pad text-danger">{{ error }}</div>
|
||||
<div v-else>
|
||||
<div class="row">
|
||||
<div class="card card-pad main-card">
|
||||
<div class="row-top">
|
||||
<div>
|
||||
<div class="text-soft" style="font-size:13px">洗车日期</div>
|
||||
<div class="big">{{ data.wash_date }}</div>
|
||||
</div>
|
||||
<span class="pill" :class="typePill(data.wash_type)" style="font-size:14px; padding:6px 14px">
|
||||
{{ typeLabel(data.wash_type) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid mt-4">
|
||||
<div><div class="text-soft sm">车辆</div><div class="val">{{ data.vehicle_name || '—' }}<span v-if="data.vehicle_plate" class="text-soft" style="font-weight:400"> · {{ data.vehicle_plate }}</span></div></div>
|
||||
<div><div class="text-soft sm">位置</div><div class="val">{{ data.location || '—' }}</div></div>
|
||||
<div><div class="text-soft sm">花费</div><div class="val">¥ {{ Number(data.cost).toFixed(2) }}</div></div>
|
||||
<div><div class="text-soft sm">耗时</div><div class="val">{{ data.duration_min ? data.duration_min + ' 分钟' : '—' }}</div></div>
|
||||
<div><div class="text-soft sm">天气</div><div class="val">{{ data.weather_desc || '—' }}<span v-if="data.temp_c" class="text-soft" style="font-weight:400"> · {{ data.temp_c }}℃</span></div></div>
|
||||
<div><div class="text-soft sm">湿度</div><div class="val">{{ data.humidity ? data.humidity + '%' : '—' }}</div></div>
|
||||
</div>
|
||||
<div v-if="data.notes" class="mt-4">
|
||||
<div class="text-soft sm">备注</div>
|
||||
<div class="notes">{{ data.notes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad side-card">
|
||||
<h3 class="chart-title">化学品使用</h3>
|
||||
<div v-if="!data.chemicals?.length" class="text-mute" style="font-size:13px">未记录</div>
|
||||
<div v-else class="chem-list">
|
||||
<div v-for="c in data.chemicals" :key="c.id" class="chem-item">
|
||||
<div>
|
||||
<div class="val-sm">{{ c.chemical_name || c.chemical_id }}</div>
|
||||
<div class="text-mute" style="font-size:12px">{{ c.chemical_id }}</div>
|
||||
</div>
|
||||
<div class="text-soft">{{ c.amount }} {{ c.unit || '' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对比照:上传 + 时间轴 -->
|
||||
<div class="card card-pad mt-4">
|
||||
<div class="photo-head">
|
||||
<h3 class="chart-title" style="margin:0">对比照</h3>
|
||||
<div class="photo-tabs">
|
||||
<button :class="['tab', { active: tab === 'gallery' }]" @click="tab = 'gallery'">图册 ({{ photos.length }})</button>
|
||||
<button :class="['tab', { active: tab === 'compare' }]" @click="tab = 'compare'">前后对比</button>
|
||||
<button :class="['tab', { active: tab === 'upload' }]" @click="tab = 'upload'">+ 上传</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 图册视图 -->
|
||||
<div v-if="tab === 'gallery'" class="photo-gallery">
|
||||
<div v-if="!photos.length" class="text-mute" style="padding:24px; text-align:center">
|
||||
暂无照片。<a @click.prevent="tab='upload'" href="#">立即上传</a>
|
||||
</div>
|
||||
<div v-else class="gallery-grid">
|
||||
<div v-for="p in photos" :key="p.id" class="gallery-item">
|
||||
<a :href="p.url" target="_blank">
|
||||
<img :src="p.url" :alt="p.caption || p.photo_type" loading="lazy" />
|
||||
</a>
|
||||
<div class="gallery-meta">
|
||||
<span :class="['pill', typePill2(p.photo_type)]">{{ photoTypeLabel(p.photo_type) }}</span>
|
||||
<span v-if="p.caption" class="text-soft sm">{{ p.caption }}</span>
|
||||
<button class="btn btn-ghost btn-xs del" @click="onDelete(p)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 前后对比视图 -->
|
||||
<div v-if="tab === 'compare'" class="compare-wrap">
|
||||
<div v-if="!compareData?.before && !compareData?.after" class="text-mute" style="padding:24px; text-align:center">
|
||||
还没上传 before/after 照片。<a @click.prevent="tab='upload'" href="#">去上传</a>
|
||||
</div>
|
||||
<div v-else class="compare-row">
|
||||
<div class="compare-side">
|
||||
<div class="compare-label">洗前 <span class="text-mute sm">(before)</span></div>
|
||||
<div v-if="compareData?.before" class="compare-img-wrap">
|
||||
<img :src="compareData.before.url" />
|
||||
<div v-if="compareData.before.caption" class="text-soft sm mt-1">{{ compareData.before.caption }}</div>
|
||||
</div>
|
||||
<div v-else class="compare-empty">未上传</div>
|
||||
</div>
|
||||
<div class="compare-divider">→</div>
|
||||
<div class="compare-side">
|
||||
<div class="compare-label">洗后 <span class="text-mute sm">(after)</span></div>
|
||||
<div v-if="compareData?.after" class="compare-img-wrap">
|
||||
<img :src="compareData.after.url" />
|
||||
<div v-if="compareData.after.caption" class="text-soft sm mt-1">{{ compareData.after.caption }}</div>
|
||||
</div>
|
||||
<div v-else class="compare-empty">未上传</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 上传视图 -->
|
||||
<div v-if="tab === 'upload'" class="upload-wrap">
|
||||
<div class="upload-row">
|
||||
<div>
|
||||
<label class="label">类型</label>
|
||||
<select v-model="uploadType" class="select">
|
||||
<option value="before">洗前 (before)</option>
|
||||
<option value="after">洗后 (after)</option>
|
||||
<option value="detail">细节特写 (detail)</option>
|
||||
<option value="scene">场景照 (scene)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex: 1">
|
||||
<label class="label">说明(可选)</label>
|
||||
<input v-model="uploadCaption" class="input" placeholder="例:右后轮毂清洁前" />
|
||||
</div>
|
||||
</div>
|
||||
<label class="dropzone" :class="{ active: dragOver }" @dragover.prevent="dragOver=true" @dragleave.prevent="dragOver=false" @drop.prevent="onDrop">
|
||||
<input ref="fileInput" type="file" accept="image/*" multiple style="display:none" @change="onFileChange" />
|
||||
<div v-if="!uploadFiles.length" class="dropzone-empty" @click="$refs.fileInput.click()">
|
||||
<div class="dz-icon">📷</div>
|
||||
<div>点击或拖拽图片到这里上传</div>
|
||||
<div class="text-mute sm mt-1">支持 jpg/png/webp/heic,单张最大 15MB</div>
|
||||
</div>
|
||||
<div v-else class="dropzone-list">
|
||||
<div v-for="(f, i) in uploadFiles" :key="i" class="dz-file">
|
||||
<span>{{ f.name }} ({{ Math.round(f.size / 1024) }} KB)</span>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="uploadFiles.splice(i,1)">×</button>
|
||||
</div>
|
||||
<div class="dz-actions">
|
||||
<button class="btn btn-ghost btn-sm" @click="$refs.fileInput.click()">+ 添加</button>
|
||||
<button class="btn btn-primary btn-sm" @click="onUpload" :disabled="uploading">
|
||||
{{ uploading ? `上传中 ${uploadProgress}/${uploadFiles.length}` : `上传 ${uploadFiles.length} 张` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-pad mt-4">
|
||||
<h3 class="chart-title">元信息</h3>
|
||||
<div class="meta">
|
||||
<div><span class="text-soft">ID:</span> {{ data.id }}</div>
|
||||
<div><span class="text-soft">创建时间:</span> {{ data.created_at }}</div>
|
||||
<div><span class="text-soft">更新时间:</span> {{ data.updated_at }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import * as washesApi from '../api/washes';
|
||||
import { useAiRecognize } from '../composables/useAiRecognize';
|
||||
|
||||
const route = useRoute();
|
||||
const data = ref({});
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
|
||||
const typeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||
const typePill = (t) => ({ quick: 'pill-blue', full: 'pill-green', detail: 'pill-warn' }[t] || 'pill-gray');
|
||||
const photoTypeLabel = (t) => ({ before: '洗前', after: '洗后', detail: '细节', scene: '场景' }[t] || t || '其他');
|
||||
const typePill2 = (t) => ({ before: 'pill-warn', after: 'pill-green', detail: 'pill-blue', scene: 'pill-gray' }[t] || 'pill-gray');
|
||||
|
||||
const tab = ref('gallery');
|
||||
const photos = ref([]);
|
||||
const compareData = ref({ before: null, after: null });
|
||||
|
||||
// 上传
|
||||
const fileInput = ref(null);
|
||||
const uploadFiles = ref([]);
|
||||
const uploadType = ref('after');
|
||||
const uploadCaption = ref('');
|
||||
const uploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const dragOver = ref(false);
|
||||
|
||||
// AI 识别
|
||||
const ai = useAiRecognize();
|
||||
const aiBusy = ai.busy;
|
||||
async function onAiRecognize() {
|
||||
await ai.open('wash', (d) => {
|
||||
if (d.wash_date) data.value.wash_date = d.wash_date;
|
||||
if (d.wash_type) data.value.wash_type = d.wash_type;
|
||||
if (d.cost != null) data.value.cost = d.cost;
|
||||
if (d.location) data.value.location = d.location;
|
||||
if (d.notes) data.value.notes = (data.value.notes ? data.value.notes + '\n' : '') + d.notes;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadPhotos() {
|
||||
try {
|
||||
const r = await washesApi.listPhotos(route.params.id);
|
||||
photos.value = r.data || [];
|
||||
const c = await washesApi.comparePhotos(route.params.id).catch(() => ({ data: { before: null, after: null } }));
|
||||
compareData.value = c.data;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function onUpload() {
|
||||
if (!uploadFiles.value.length) return;
|
||||
uploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
for (const f of uploadFiles.value) {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fd.append('photo_type', uploadType.value);
|
||||
if (uploadCaption.value) fd.append('caption', uploadCaption.value);
|
||||
await washesApi.uploadPhoto(route.params.id, fd);
|
||||
uploadProgress.value++;
|
||||
} catch (e) {
|
||||
alert('上传失败:' + (e.response?.data?.error?.message || e.message));
|
||||
}
|
||||
}
|
||||
uploading.value = false;
|
||||
uploadFiles.value = [];
|
||||
uploadCaption.value = '';
|
||||
await loadPhotos();
|
||||
tab.value = 'gallery';
|
||||
}
|
||||
|
||||
async function onDelete(p) {
|
||||
if (!confirm('删除这张照片?')) return;
|
||||
await washesApi.deletePhoto(route.params.id, p.id);
|
||||
await loadPhotos();
|
||||
}
|
||||
|
||||
function onFileChange(e) {
|
||||
const files = Array.from(e.target.files || []);
|
||||
uploadFiles.value.push(...files);
|
||||
e.target.value = '';
|
||||
}
|
||||
function onDrop(e) {
|
||||
dragOver.value = false;
|
||||
const files = Array.from(e.dataTransfer.files || []).filter(f => f.type.startsWith('image/'));
|
||||
uploadFiles.value.push(...files);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
data.value = (await washesApi.get(route.params.id)).data;
|
||||
await loadPhotos();
|
||||
} catch (e) {
|
||||
error.value = e.response?.data?.message || e.response?.data?.code || e.message || '加载失败';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.head-actions { display: flex; gap: 8px; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.row { display: grid; grid-template-columns: 1.6fr 1fr; gap: 18px; }
|
||||
.row-top { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.big { font-size: 22px; font-weight: 600; margin-top: 2px; }
|
||||
.grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px 24px; }
|
||||
.sm { font-size: 12px; }
|
||||
.val { font-size: 15px; font-weight: 500; margin-top: 2px; }
|
||||
.val-sm { font-size: 14px; font-weight: 500; }
|
||||
.notes { padding: 10px 14px; background: var(--bg-soft); border-radius: var(--radius-sm); margin-top: 4px; font-size: 14px; }
|
||||
.chart-title { font-size: 14px; font-weight: 600; color: var(--text-soft); margin: 0 0 14px; }
|
||||
.chem-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.chem-item { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--line); }
|
||||
.chem-item:last-child { border-bottom: 0; }
|
||||
.meta { display: flex; flex-direction: column; gap: 6px; font-size: 13px; }
|
||||
|
||||
/* 对比照 */
|
||||
.photo-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
||||
.photo-tabs { display: flex; gap: 4px; }
|
||||
.tab { background: transparent; border: 0; padding: 6px 14px; border-radius: var(--pill); font-size: 13px; color: var(--text-soft); cursor: pointer; transition: all .15s; }
|
||||
.tab:hover { background: var(--bg-soft); color: var(--text); }
|
||||
.tab.active { background: var(--accent); color: #fff; }
|
||||
|
||||
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 14px; }
|
||||
.gallery-item { background: var(--bg-soft); border-radius: var(--radius-sm); overflow: hidden; }
|
||||
.gallery-item img { width: 100%; height: 180px; object-fit: cover; display: block; }
|
||||
.gallery-meta { padding: 8px 10px; display: flex; align-items: center; gap: 6px; }
|
||||
.gallery-meta .del { margin-left: auto; padding: 0 8px; }
|
||||
|
||||
.compare-wrap { padding: 12px 0; }
|
||||
.compare-row { display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: center; }
|
||||
.compare-side { text-align: center; }
|
||||
.compare-label { font-size: 13px; font-weight: 600; margin-bottom: 8px; }
|
||||
.compare-img-wrap img { width: 100%; max-height: 420px; object-fit: contain; border-radius: 8px; background: var(--bg-soft); }
|
||||
.compare-empty { padding: 80px 16px; background: var(--bg-soft); border-radius: 8px; color: var(--text-mute); }
|
||||
.compare-divider { font-size: 32px; color: var(--text-soft); padding: 0 8px; }
|
||||
|
||||
.upload-row { display: flex; gap: 12px; margin-bottom: 14px; }
|
||||
.label { font-size: 13px; color: var(--text-soft); display: block; margin-bottom: 4px; }
|
||||
.input, .select { padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; font-size: 14px; background: var(--bg); width: 100%; }
|
||||
.dropzone { display: block; border: 2px dashed var(--line); border-radius: var(--radius); padding: 24px; cursor: pointer; transition: all .15s; text-align: center; }
|
||||
.dropzone.active, .dropzone:hover { border-color: var(--accent); background: rgba(77,186,154,0.05); }
|
||||
.dropzone-empty { color: var(--text-soft); }
|
||||
.dz-icon { font-size: 36px; margin-bottom: 6px; }
|
||||
.dz-file { display: flex; justify-content: space-between; align-items: center; padding: 6px 12px; background: var(--bg-soft); border-radius: 6px; margin: 6px 0; }
|
||||
.dz-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
|
||||
.btn-xs { padding: 2px 8px; font-size: 11px; }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
.mt-4 { margin-top: 18px; }
|
||||
.mt-1 { margin-top: 4px; }
|
||||
.r { text-align: right; }
|
||||
.text-soft { color: var(--text-soft); }
|
||||
.text-mute { color: var(--text-mute); }
|
||||
.text-danger { color: var(--danger); }
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.row { grid-template-columns: 1fr; }
|
||||
.grid { grid-template-columns: 1fr 1fr; }
|
||||
.compare-row { grid-template-columns: 1fr; }
|
||||
.compare-divider { transform: rotate(90deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head-actions { display: flex; gap: 8px; }
|
||||
.head-actions > * { flex: 1; justify-content: center; }
|
||||
.row-top { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.row-top .big { font-size: 24px; }
|
||||
.grid { grid-template-columns: 1fr; gap: 12px; }
|
||||
.photo-head { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.photo-tabs { width: 100%; overflow-x: auto; flex-wrap: nowrap; }
|
||||
.photo-tabs .tab { flex: 1; white-space: nowrap; }
|
||||
.gallery-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
|
||||
.upload-row { flex-direction: column; }
|
||||
.meta { grid-template-columns: 1fr; }
|
||||
.dropzone { padding: 16px; }
|
||||
.dropzone-empty .dz-icon { font-size: 36px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="head">
|
||||
<div>
|
||||
<h1 class="title">洗车记录</h1>
|
||||
<p class="subtitle text-soft">共 {{ total }} 条记录{{ selectedCount ? ` · 已选 ${selectedCount} 条` : '' }}</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<button
|
||||
v-if="selectedCount > 0"
|
||||
class="btn btn-danger"
|
||||
@click="openBatchDelete"
|
||||
>批量删除 ({{ selectedCount }})</button>
|
||||
<router-link to="/washes/new" class="btn btn-primary">+ 新建</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card filter">
|
||||
<button
|
||||
class="filter-toggle mobile-only"
|
||||
type="button"
|
||||
:aria-expanded="filterOpen"
|
||||
@click="filterOpen = !filterOpen"
|
||||
>
|
||||
<span>筛选</span>
|
||||
<span class="filter-count" v-if="activeFilterCount">{{ activeFilterCount }}</span>
|
||||
<span class="chevron" :class="{ on: filterOpen }">▾</span>
|
||||
</button>
|
||||
<div class="filter-body" :class="{ open: filterOpen }">
|
||||
<div class="filter-row">
|
||||
<div>
|
||||
<label class="label">类型</label>
|
||||
<select v-model="filters.type" class="select" @change="reload">
|
||||
<option value="">全部</option>
|
||||
<option value="quick">快速</option>
|
||||
<option value="full">标准</option>
|
||||
<option value="detail">精洗</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">车辆</label>
|
||||
<select v-model="filters.vehicle_id" class="select" @change="reload">
|
||||
<option value="">全部</option>
|
||||
<option v-for="v in vehicles" :key="v.id" :value="v.id">{{ v.name }} ({{ v.plate }})</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">开始</label>
|
||||
<input v-model="filters.from" type="date" class="input" @change="reload" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">结束</label>
|
||||
<input v-model="filters.to" type="date" class="input" @change="reload" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<MobileCardList
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
:is-selected="isSelected"
|
||||
:empty-text="'暂无记录'"
|
||||
row-key="id"
|
||||
@row-click="(row) => goTo(row.id)"
|
||||
>
|
||||
<template #checkbox="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="isSelected(row.id)"
|
||||
@change="toggleOne(row.id)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-date="{ row }">
|
||||
{{ row.wash_date }}
|
||||
</template>
|
||||
<template #cell-type="{ row }">
|
||||
<span class="pill" :class="typePill(row.wash_type)">{{ typeLabel(row.wash_type) }}</span>
|
||||
</template>
|
||||
<template #cell-vehicle="{ row }">
|
||||
{{ row.vehicle_name || '—' }}
|
||||
</template>
|
||||
<template #cell-location="{ row }">
|
||||
{{ row.location || '—' }}
|
||||
</template>
|
||||
<template #cell-cost="{ row }">
|
||||
¥ {{ Number(row.cost).toFixed(2) }}
|
||||
</template>
|
||||
<template #cell-duration="{ row }">
|
||||
{{ row.duration_min ? row.duration_min + ' 分钟' : '—' }}
|
||||
</template>
|
||||
<template #cell-weather="{ row }">
|
||||
{{ row.weather_desc || '—' }}
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<button class="btn-link" @click.stop="openSingleDelete(row)">删除</button>
|
||||
<span class="text-brand" style="font-size:12px">查看 →</span>
|
||||
</template>
|
||||
<template #empty>
|
||||
暂无记录
|
||||
<div class="mt-2"><router-link to="/washes/new" class="text-brand">+ 新建第一条</router-link></div>
|
||||
</template>
|
||||
</MobileCardList>
|
||||
</div>
|
||||
|
||||
<!-- 批量删除确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="batchDialog.open"
|
||||
v-model="batchDialog.open"
|
||||
title="批量删除洗车记录"
|
||||
:message="`确认要删除 ${batchDialog.ids.length} 条洗车记录?`"
|
||||
mode="math"
|
||||
confirm-label="确认删除"
|
||||
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||
:busy="batchDialog.busy"
|
||||
:error="batchDialog.error"
|
||||
@confirm="confirmBatchDelete"
|
||||
@cancel="batchDialog.open = false"
|
||||
/>
|
||||
<!-- 单条删除确认 -->
|
||||
<ConfirmDangerDialog
|
||||
v-if="singleDialog.open"
|
||||
v-model="singleDialog.open"
|
||||
title="删除洗车记录"
|
||||
:message="`确认要删除这条记录${singleDialog.row ? '(' + singleDialog.row.wash_date + ' ¥' + Number(singleDialog.row.cost).toFixed(2) + ')' : ''}?`"
|
||||
mode="type"
|
||||
confirm-label="确认删除"
|
||||
:tips="['已删除记录可在「操作日志」中恢复']"
|
||||
:busy="singleDialog.busy"
|
||||
:error="singleDialog.error"
|
||||
@confirm="confirmSingleDelete"
|
||||
@cancel="singleDialog.open = false"
|
||||
/>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AppLayout from '../components/AppLayout.vue';
|
||||
import MobileCardList from '../components/MobileCardList.vue';
|
||||
import ConfirmDangerDialog from '../components/ConfirmDangerDialog.vue';
|
||||
import * as washesApi from '../api/washes';
|
||||
import * as vehiclesApi from '../api/vehicles';
|
||||
import { asArray } from '../api/client';
|
||||
|
||||
const router = useRouter();
|
||||
const rows = ref([]);
|
||||
const vehicles = ref([]);
|
||||
const total = ref(0);
|
||||
const filters = reactive({ type: '', vehicle_id: '', from: '', to: '' });
|
||||
const selected = ref(new Set());
|
||||
const filterOpen = ref(false);
|
||||
|
||||
const selectedCount = computed(() => selected.value.size);
|
||||
const allSelected = computed(() => rows.value.length > 0 && selected.value.size === rows.value.length);
|
||||
const someSelected = computed(() => selected.value.size > 0 && selected.value.size < rows.value.length);
|
||||
const activeFilterCount = computed(() => {
|
||||
let n = 0;
|
||||
if (filters.type) n++;
|
||||
if (filters.vehicle_id) n++;
|
||||
if (filters.from) n++;
|
||||
if (filters.to) n++;
|
||||
return n;
|
||||
});
|
||||
|
||||
// MobileCardList 列定义
|
||||
const columns = [
|
||||
{ key: 'date', label: '日期', primary: true, alwaysShow: true },
|
||||
{ key: 'type', label: '类型', alwaysShow: true },
|
||||
{ key: 'vehicle', label: '车辆', alwaysShow: true },
|
||||
{ key: 'location', label: '位置' },
|
||||
{ key: 'cost', label: '花费', alwaysShow: true },
|
||||
{ key: 'duration', label: '耗时' },
|
||||
{ key: 'weather', label: '天气' },
|
||||
];
|
||||
|
||||
function isSelected(id) { return selected.value.has(id); }
|
||||
function toggleOne(id) {
|
||||
const s = new Set(selected.value);
|
||||
s.has(id) ? s.delete(id) : s.add(id);
|
||||
selected.value = s;
|
||||
}
|
||||
function toggleAll() {
|
||||
if (allSelected.value) {
|
||||
selected.value = new Set();
|
||||
} else {
|
||||
selected.value = new Set(rows.value.map(r => r.id));
|
||||
}
|
||||
}
|
||||
|
||||
const typeLabel = (t) => ({ quick: '快速', full: '标准', detail: '精洗', other: '其他' }[t] || t || '—');
|
||||
const typePill = (t) => ({ quick: 'pill-blue', full: 'pill-green', detail: 'pill-warn' }[t] || 'pill-gray');
|
||||
function goTo(id) { router.push({ name: 'wash-show', params: { id } }); }
|
||||
|
||||
// 单条删除
|
||||
const singleDialog = reactive({ open: false, row: null, busy: false, error: '' });
|
||||
function openSingleDelete(row) {
|
||||
singleDialog.row = row;
|
||||
singleDialog.busy = false;
|
||||
singleDialog.error = '';
|
||||
singleDialog.open = true;
|
||||
}
|
||||
async function confirmSingleDelete(challenge) {
|
||||
singleDialog.busy = true;
|
||||
singleDialog.error = '';
|
||||
try {
|
||||
await washesApi.remove(singleDialog.row.id);
|
||||
singleDialog.open = false;
|
||||
selected.value.delete(singleDialog.row.id);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
singleDialog.error = e.response?.data?.error?.message || e.message;
|
||||
} finally {
|
||||
singleDialog.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除
|
||||
const batchDialog = reactive({ open: false, ids: [], busy: false, error: '' });
|
||||
function openBatchDelete() {
|
||||
batchDialog.ids = [...selected.value];
|
||||
batchDialog.busy = false;
|
||||
batchDialog.error = '';
|
||||
batchDialog.open = true;
|
||||
}
|
||||
async function confirmBatchDelete(challenge) {
|
||||
batchDialog.busy = true;
|
||||
batchDialog.error = '';
|
||||
try {
|
||||
await washesApi.batchDelete(batchDialog.ids, challenge);
|
||||
batchDialog.open = false;
|
||||
selected.value = new Set();
|
||||
await reload();
|
||||
} catch (e) {
|
||||
singleDialog.error = '';
|
||||
batchDialog.error = e.response?.data?.error?.message || e.message;
|
||||
// 题目答错时换一道新题
|
||||
if (e.response?.data?.error?.code === 'CONFIRM_FAIL') {
|
||||
batchDialog.open = false;
|
||||
setTimeout(() => { batchDialog.open = true; }, 50);
|
||||
}
|
||||
} finally {
|
||||
batchDialog.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const r = await vehiclesApi.list();
|
||||
vehicles.value = asArray(r.data, 'vehicles');
|
||||
} catch {}
|
||||
reload();
|
||||
});
|
||||
async function reload() {
|
||||
const params = { ...filters };
|
||||
if (!params.type) delete params.type;
|
||||
if (!params.vehicle_id) delete params.vehicle_id;
|
||||
if (!params.from) delete params.from;
|
||||
if (!params.to) delete params.to;
|
||||
const r = await washesApi.list(params);
|
||||
rows.value = r.data.rows || [];
|
||||
total.value = r.data.total || 0;
|
||||
// 清理已不存在的选中项
|
||||
const ids = new Set(rows.value.map(r => r.id));
|
||||
selected.value = new Set([...selected.value].filter(id => ids.has(id)));
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 20px; gap: 12px; flex-wrap: wrap; }
|
||||
.head-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.title { font-size: 24px; font-weight: 600; margin: 0; letter-spacing: -0.02em; }
|
||||
.subtitle { margin: 4px 0 0; font-size: 14px; }
|
||||
.filter { padding: 16px 20px; }
|
||||
.filter-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.check-col { width: 36px; padding-left: 16px; padding-right: 0; }
|
||||
.row-link { cursor: pointer; }
|
||||
.row-link.selected { background: rgba(232, 244, 249, 0.5); }
|
||||
.row-actions { display: flex; align-items: center; gap: 12px; white-space: nowrap; }
|
||||
.btn-link {
|
||||
background: none; border: 0; padding: 2px 4px; cursor: pointer;
|
||||
color: var(--danger); font-size: 13px;
|
||||
}
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-danger { background: var(--danger); color: #fff; padding: 8px 16px; border-radius: var(--pill); border: 0; font-size: 14px; cursor: pointer; }
|
||||
.btn-danger:hover { background: #d63c2f; }
|
||||
|
||||
/* === 移动端筛选折叠 === */
|
||||
.filter-toggle {
|
||||
display: none;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.filter-count {
|
||||
display: inline-flex;
|
||||
align-items: center; justify-content: center;
|
||||
min-width: 20px; height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: var(--pill);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.chevron {
|
||||
transition: transform .2s;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.chevron.on { transform: rotate(180deg); }
|
||||
|
||||
/* === 响应式 === */
|
||||
@media (max-width: 900px) {
|
||||
.filter-row { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.title { font-size: 20px; }
|
||||
.head { flex-direction: column; align-items: stretch; gap: 12px; }
|
||||
.head-actions { width: 100%; }
|
||||
.head-actions .btn { flex: 1; justify-content: center; }
|
||||
|
||||
.filter { padding: 0; background: transparent; box-shadow: none; }
|
||||
.filter-toggle { display: flex; }
|
||||
.filter-body {
|
||||
display: none;
|
||||
background: var(--card);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--card-shadow);
|
||||
}
|
||||
.filter-body.open { display: block; }
|
||||
.filter-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.head-actions .btn { padding: 8px 12px; font-size: 13px; }
|
||||
.head-actions .btn-danger { padding: 6px 10px; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
injectRegister: 'auto', // 自动注入 register 脚本
|
||||
includeAssets: ['favicon-16x16.png', 'favicon-32x32.png'],
|
||||
manifest: {
|
||||
id: '/',
|
||||
name: 'CarLog 洗车管理系统',
|
||||
short_name: 'CarLog',
|
||||
description: '记录洗车/加油/充电/保养/保险/车品的全能车辆账本',
|
||||
lang: 'zh-CN',
|
||||
dir: 'ltr',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
background_color: '#F5F8FC',
|
||||
theme_color: '#1B6EF3',
|
||||
categories: ['productivity', 'lifestyle', 'utilities'],
|
||||
icons: [
|
||||
{ src: '/pwa/pwa-192x192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/pwa/pwa-512x512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{
|
||||
src: '/pwa/pwa-maskable-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
{
|
||||
src: '/pwa/apple-touch-icon.png',
|
||||
sizes: '180x180',
|
||||
type: 'image/png',
|
||||
purpose: 'any',
|
||||
},
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: '拍照录入',
|
||||
short_name: '拍照',
|
||||
url: '/washes/new?capture=1',
|
||||
description: '打开相机快速拍照记录',
|
||||
},
|
||||
{
|
||||
name: '新建洗车',
|
||||
short_name: '洗车',
|
||||
url: '/washes/new',
|
||||
description: '快速记录一次洗车',
|
||||
},
|
||||
{
|
||||
name: '新建加油',
|
||||
short_name: '加油',
|
||||
url: '/refuel/new',
|
||||
description: '快速记录一次加油',
|
||||
},
|
||||
{
|
||||
name: '新建保养',
|
||||
short_name: '保养',
|
||||
url: '/maintenance/new',
|
||||
description: '快速记录一次保养',
|
||||
},
|
||||
],
|
||||
// 拍照快捷方式行为
|
||||
capture_links: ['/washes/new?capture=1'],
|
||||
launch_handler: { client_mode: 'auto' },
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2,webmanifest}'],
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api/, /^\/uploads/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/api/') && url.pathname.includes('/static'),
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'api-static',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ url }) => url.pathname.startsWith('/uploads/'),
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'uploads',
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'image',
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: { cacheName: 'images' },
|
||||
},
|
||||
{
|
||||
urlPattern: ({ request }) => request.destination === 'font',
|
||||
handler: 'CacheFirst',
|
||||
options: { cacheName: 'fonts' },
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false, // dev 不开 SW(避免 HMR 干扰)
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8787',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
include: ['vue', 'pinia', 'axios', 'chart.js/auto'],
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
cssCodeSplit: true,
|
||||
target: 'es2018',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('chart.js') || id.includes('vue-chartjs')) return 'chart';
|
||||
if (id.includes('echarts') || id.includes('zrender')) return 'chart';
|
||||
if (id.includes('workbox') || id.includes('vite-plugin-pwa')) return 'pwa';
|
||||
if (id.includes('vue') || id.includes('pinia') || id.includes('@vue')) return 'vue';
|
||||
if (
|
||||
id.includes('axios') ||
|
||||
id.includes('dayjs') ||
|
||||
id.includes('dompurify')
|
||||
)
|
||||
return 'utils';
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
// 文件名带 hash,长期缓存友好
|
||||
entryFileNames: 'assets/[name]-[hash].js',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||
},
|
||||
},
|
||||
chunkSizeWarningLimit: 600,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
# 宝塔面板安装(Node.js 版)
|
||||
|
||||
> 适用:CentOS 7+ / Ubuntu 20+ / Debian 11+,宝塔 7.7+,Node.js ≥ 20
|
||||
|
||||
## 0. 系统要求
|
||||
|
||||
- Node.js **20+**(宝塔「软件商店」→ Node.js 版本管理器 → 安装 20.x LTS)
|
||||
- 内存 ≥ 512 MB,磁盘 ≥ 2 GB
|
||||
- 一台已解析好的域名(可选,纯 IP 也能跑)
|
||||
|
||||
## 1. 宝塔安装 Node.js
|
||||
|
||||
「软件商店」搜索 **Node.js 版本管理器** → 安装 → 切换到 **20.x** → 设为默认。
|
||||
|
||||
SSH 验证:
|
||||
|
||||
```bash
|
||||
node -v # 应显示 v20.x.x
|
||||
npm -v # 应显示 10.x
|
||||
```
|
||||
|
||||
## 2. 创建站点 + 上传代码
|
||||
|
||||
### 方式 A:直接上传 zip
|
||||
|
||||
1. 宝塔 → 网站 → **添加站点**(PHP 静态都无所谓,因为走 Node)→ 记下站点根目录,如 `/www/wwwroot/carwash.example.com`
|
||||
2. 上传 `carwash-system-v2.zip` 到站点根目录
|
||||
3. SSH 到服务器:
|
||||
```bash
|
||||
cd /www/wwwroot/carwash.example.com
|
||||
unzip -o carwash-system-v2.zip
|
||||
```
|
||||
|
||||
### 方式 B:git 拉取
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/carwash.example.com
|
||||
git clone <your-repo-url> .
|
||||
```
|
||||
|
||||
## 3. 安装依赖 + 构建前端
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/carwash.example.com
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
这会执行:
|
||||
- `npm install --prefix server`(约 100 个包,含 better-sqlite3,需编译)
|
||||
- `npm install --prefix client`(约 200 个包,纯 JS)
|
||||
- `npm run build --prefix client`(Vite 打包到 `client/dist/`)
|
||||
|
||||
> ⚠ **better-sqlite3 需要 Node-gyp 编译**。如遇 `node-gyp` 报错:
|
||||
> ```bash
|
||||
> # CentOS
|
||||
> yum install -y python3 make gcc gcc-c++ nodejs
|
||||
> # Ubuntu/Debian
|
||||
> apt install -y python3 make g++ build-essential
|
||||
> ```
|
||||
|
||||
## 4. 初始化数据库
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/carwash.example.com
|
||||
npm run migrate
|
||||
```
|
||||
|
||||
期望输出:
|
||||
```
|
||||
✓ Migration 0001_init.sql
|
||||
✓ Migration 0002_auth.sql
|
||||
✓ Migration 0003_vehicles.sql
|
||||
|
||||
✓ 已创建默认管理员账号
|
||||
用户名: admin
|
||||
密码: carwash2026
|
||||
⚠ 首次登录后请到「设置 → 账户」修改密码!
|
||||
```
|
||||
|
||||
可选灌入演示数据:`npm run seed-demo`
|
||||
|
||||
## 5. 配置 .env
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
填天气 API key、Grocy 凭证等(全部可留空,留空不影响启动;只是相关功能不可用)。
|
||||
|
||||
## 6. 启动服务(PM2)
|
||||
|
||||
宝塔「软件商店」搜索 **PM2 管理器** → 安装。
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/carwash.example.com
|
||||
pm2 start npm --name carwash -- run serve
|
||||
pm2 save
|
||||
pm2 startup # 设置开机启动(按提示复制粘贴输出的命令)
|
||||
```
|
||||
|
||||
验证:
|
||||
```bash
|
||||
pm2 list # 看到 carwash status = online
|
||||
curl http://127.0.0.1:8787/api/health
|
||||
# → {"ok":true,...}
|
||||
```
|
||||
|
||||
## 7. Nginx 反向代理
|
||||
|
||||
宝塔 → 网站 → 你的站点 → **设置** → **反向代理**:
|
||||
|
||||
- 代理名称:carwash
|
||||
- 目标 URL:`http://127.0.0.1:8787`
|
||||
- 发送域名:留空
|
||||
|
||||
或在「配置文件」里把 `location /` 替换为:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8787;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# 上传大小限制(备份恢复可能上传大文件)
|
||||
client_max_body_size 50M;
|
||||
```
|
||||
|
||||
> 不需要 PHP,不需要伪静态规则。Node 直接吐 `index.html`。
|
||||
|
||||
## 8. 申请 HTTPS(强烈建议)
|
||||
|
||||
宝塔 → 站点 → **SSL** → Let's Encrypt → 申请 → 强制 HTTPS。
|
||||
|
||||
## 9. 备份与 cron
|
||||
|
||||
```bash
|
||||
# 每天凌晨 3 点备份
|
||||
crontab -e
|
||||
0 3 * * * cd /www/wwwroot/carwash.example.com && npm run backup
|
||||
```
|
||||
|
||||
备份保留 10 份,自动清理老的(`storage/backups/carwash-YYYYMMDDHHMM.tar.gz`)。
|
||||
|
||||
## 10. 升级
|
||||
|
||||
```bash
|
||||
cd /www/wwwroot/carwash.example.com
|
||||
git pull # 或重新上传新版 zip 后 unzip -o
|
||||
npm run install:all
|
||||
npm run migrate
|
||||
pm2 restart carwash
|
||||
```
|
||||
|
||||
数据库结构变更在 `server/migrations/0004_*.sql` 这种格式里加,`migrate` 会自动跳过已应用的。
|
||||
|
||||
## 11. 常见问题
|
||||
|
||||
**Q: 端口 8787 被占?**
|
||||
A: 编辑 `.env` 改 `PORT=8788`,然后 `pm2 restart carwash` + 改 nginx 代理。
|
||||
|
||||
**Q: better-sqlite3 安装失败?**
|
||||
A: 系统缺少编译工具链,安装 python3 + make + g++(见步骤 3)。
|
||||
|
||||
**Q: 忘记 admin 密码?**
|
||||
A: SSH 跑 `npm run users -- passwd admin 新密码`。
|
||||
|
||||
**Q: 浏览器打开页面是空白?**
|
||||
A: 检查 `pm2 logs carwash`;最常见是没跑 `npm run build:client` 或 nginx 配置没指向 Node。
|
||||
|
||||
**Q: SQLite 锁了?**
|
||||
A: 检查 `server/data/` 下有没有遗留的 `-wal` / `-shm` 文件没被清理;正常 `pm2 stop` 会触发 checkpoint。
|
||||
|
||||
**Q: 多设备共享数据?**
|
||||
A: 这是个人单机系统。如果要支持远程访问,**必须**走 HTTPS + 反向代理(本教程标准配置),不要直接暴露 8787 端口到公网。
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"url": [
|
||||
"http://localhost:4173/login",
|
||||
"http://localhost:4173/"
|
||||
],
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"preset": "desktop",
|
||||
"chromeFlags": "--user-data-dir=/tmp/lh-cache --no-sandbox --disable-dev-shm-usage --disable-gpu",
|
||||
"skipAudits": ["uses-http2", "is-on-https", "redirects-http"]
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["warn", { "minScore": 0.6 }],
|
||||
"categories:accessibility": ["error", { "minScore": 0.7 }],
|
||||
"categories:best-practices": ["warn", { "minScore": 0.7 }],
|
||||
"categories:seo": ["warn", { "minScore": 0.6 }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "carwash-system",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "个人自用洗车记录系统 - Vue 3 + Node.js + MySQL/SQLite",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"install:all": "npm install && npm install --prefix server && npm install --prefix client && npm run build:client",
|
||||
"build:client": "npm run build --prefix client",
|
||||
"dev": "concurrently -k -n SERVER,CLIENT -c green,cyan \"npm:serve\" \"npm:dev:client\"",
|
||||
"dev:client": "npm run dev --prefix client",
|
||||
"migrate": "node server/src/bin/migrate.js",
|
||||
"serve": "node server/src/bin/serve.js",
|
||||
"users": "node server/src/bin/users.js",
|
||||
"weather": "node server/src/bin/weather.js",
|
||||
"grocy-sync": "node server/src/bin/grocy-sync.js",
|
||||
"grocy-refresh-products": "node server/src/bin/grocy-refresh-products.js",
|
||||
"export": "node server/src/bin/export.js",
|
||||
"backup": "node server/src/bin/backup.js",
|
||||
"seed-demo": "node server/src/bin/seed-demo.js",
|
||||
"verify": "node server/src/bin/verify.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lighthouse": "lhci autorun",
|
||||
"lighthouse:pwa": "node client/scripts/check-pwa.mjs",
|
||||
"a11y": "pa11y-ci --config .pa11yci.json http://localhost:4173/login"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lhci/cli": "^0.15.1",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"concurrently": "^9.0.1",
|
||||
"eslint": "^8.57.0",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^15.2.0",
|
||||
"pa11y-ci": "^4.1.1",
|
||||
"prettier": "^3.3.3",
|
||||
"supertest": "^7.0.0",
|
||||
"vitest": "^2.1.0",
|
||||
"vue-eslint-parser": "^9.4.3"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,vue}": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"*.{json,md,yml,yaml,css}": [
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0001: 基础表(Node.js / better-sqlite3 版)
|
||||
-- =============================================================================
|
||||
|
||||
-- 基础 PRAGMA 由 server/src/db.js 统一设置(journal_mode=WAL / foreign_keys=ON / synchronous=NORMAL / busy_timeout=5000)
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. chemicals - 药剂字典(Grocy 缓存层)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chemicals (
|
||||
grocy_product_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT,
|
||||
unit TEXT NOT NULL DEFAULT 'ml',
|
||||
standard_dose REAL,
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
fetched_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (grocy_product_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chemicals_category ON chemicals(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_chemicals_active ON chemicals(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_chemicals_fetched ON chemicals(fetched_at);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. weather_snapshots - 天气快照
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS weather_snapshots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('qweather','openweathermap')),
|
||||
temp_c REAL,
|
||||
humidity INTEGER,
|
||||
weather_desc TEXT,
|
||||
weather_code TEXT,
|
||||
wind_kph REAL,
|
||||
precip_mm REAL,
|
||||
raw_json TEXT,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_weather_city_date ON weather_snapshots(city, snapshot_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_weather_date ON weather_snapshots(snapshot_date);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. wash_records - 洗车记录
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wash_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wash_date TEXT NOT NULL,
|
||||
wash_type TEXT NOT NULL CHECK (wash_type IN ('quick','full','detail','other')),
|
||||
weather_snapshot_id INTEGER,
|
||||
location TEXT,
|
||||
cost REAL NOT NULL DEFAULT 0,
|
||||
duration_min INTEGER,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wash_records_date ON wash_records(wash_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_wash_records_type ON wash_records(wash_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_wash_records_weather ON wash_records(weather_snapshot_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. chemical_usage - 药剂消耗
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chemical_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
usage_date TEXT NOT NULL,
|
||||
chemical_id TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
wash_record_id INTEGER,
|
||||
notes TEXT,
|
||||
sync_status TEXT NOT NULL DEFAULT 'pending' CHECK (sync_status IN ('pending','synced','failed')),
|
||||
sync_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_date ON chemical_usage(usage_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_chemical ON chemical_usage(chemical_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_wash ON chemical_usage(wash_record_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_sync ON chemical_usage(sync_status);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. settings - 运行时配置 KV
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value TEXT,
|
||||
is_secret INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 预置 11 个 key
|
||||
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||
('db_path', NULL, 0, '数据库路径(SQLite 模式)'),
|
||||
('app_city', NULL, 0, '所在城市(用于天气查询)'),
|
||||
('app_timezone', 'Asia/Shanghai', 0, 'PHP 时区(保留兼容)'),
|
||||
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
|
||||
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
|
||||
('weather_provider', 'qweather', 0, '天气提供方 qweather/openweathermap'),
|
||||
('qweather_api_key', NULL, 1, '和风天气 API key'),
|
||||
('qweather_api_host', 'api.qweather.com', 0, '和风 API host'),
|
||||
('openweathermap_api_key', NULL, 1, 'OpenWeatherMap API key'),
|
||||
('backup_keep_count', '10', 0, '本地备份保留份数'),
|
||||
('backup_dir', 'storage/backups', 0, '备份输出目录');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. 视图
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
DROP VIEW IF EXISTS v_wash_monthly_count;
|
||||
CREATE VIEW v_wash_monthly_count AS
|
||||
SELECT
|
||||
substr(wash_date, 1, 7) AS month,
|
||||
COUNT(*) AS wash_count,
|
||||
SUM(COALESCE(cost, 0)) AS total_cost
|
||||
FROM wash_records
|
||||
GROUP BY substr(wash_date, 1, 7)
|
||||
ORDER BY month DESC;
|
||||
|
||||
DROP VIEW IF EXISTS v_chemical_monthly_usage;
|
||||
CREATE VIEW v_chemical_monthly_usage AS
|
||||
SELECT
|
||||
substr(cu.usage_date, 1, 7) AS month,
|
||||
c.grocy_product_id AS grocy_product_id,
|
||||
c.name AS chemical_name,
|
||||
c.unit AS unit,
|
||||
SUM(cu.amount) AS total_amount,
|
||||
COUNT(*) AS usage_count
|
||||
FROM chemical_usage cu
|
||||
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
|
||||
GROUP BY substr(cu.usage_date, 1, 7), c.grocy_product_id
|
||||
ORDER BY month DESC, total_amount DESC;
|
||||
|
||||
DROP VIEW IF EXISTS v_last_wash;
|
||||
CREATE VIEW v_last_wash AS
|
||||
SELECT
|
||||
id AS wash_id,
|
||||
wash_date,
|
||||
wash_type,
|
||||
CAST(julianday('now') - julianday(wash_date) AS INTEGER) AS days_since
|
||||
FROM wash_records
|
||||
ORDER BY wash_date DESC, id DESC
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,68 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0002: 用户认证 + 防撞库
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. users - 登录账号
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user','admin')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
last_login_at TEXT,
|
||||
last_login_ip TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. login_attempts - 登录尝试记录
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
attempted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
ip_address TEXT NOT NULL,
|
||||
username TEXT NOT NULL,
|
||||
success INTEGER NOT NULL CHECK (success IN (0, 1)),
|
||||
user_agent TEXT,
|
||||
failure_reason TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_ip_time ON login_attempts(ip_address, attempted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_user_time ON login_attempts(username, attempted_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_attempts_time ON login_attempts(attempted_at);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. auth_locks - 锁状态
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS auth_locks (
|
||||
lock_key TEXT PRIMARY KEY,
|
||||
lock_type TEXT NOT NULL CHECK (lock_type IN ('ip','user')),
|
||||
target TEXT NOT NULL,
|
||||
locked_until TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_locks_until ON auth_locks(locked_until);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. auth 设置 seed
|
||||
-- -----------------------------------------------------------------------------
|
||||
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
|
||||
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
|
||||
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
|
||||
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
|
||||
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
|
||||
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
|
||||
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
|
||||
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
|
||||
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
|
||||
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
|
||||
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
|
||||
@@ -0,0 +1,45 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0003: 车辆管理
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. vehicles - 车辆字典
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
plate TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'car' CHECK (type IN ('car','suv','mpv','truck','other')),
|
||||
color TEXT,
|
||||
notes TEXT,
|
||||
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1)),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_active ON vehicles(is_active);
|
||||
CREATE INDEX IF NOT EXISTS idx_vehicles_sort ON vehicles(sort_order);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. wash_records 加 vehicle_id
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE wash_records ADD COLUMN vehicle_id INTEGER REFERENCES vehicles(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_wash_records_vehicle ON wash_records(vehicle_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. 视图:v_last_wash 加 vehicle_name
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP VIEW IF EXISTS v_last_wash;
|
||||
CREATE VIEW v_last_wash AS
|
||||
SELECT
|
||||
w.id AS wash_id,
|
||||
w.wash_date,
|
||||
w.wash_type,
|
||||
w.vehicle_id,
|
||||
v.name AS vehicle_name,
|
||||
CAST(julianday('now') - julianday(w.wash_date) AS INTEGER) AS days_since
|
||||
FROM wash_records w
|
||||
LEFT JOIN vehicles v ON v.id = w.vehicle_id
|
||||
ORDER BY w.wash_date DESC, w.id DESC
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0004: Grocy 主数据同步字段
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. chemicals 表加 Grocy 完整字段
|
||||
ALTER TABLE chemicals ADD COLUMN description TEXT;
|
||||
ALTER TABLE chemicals ADD COLUMN current_amount REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE chemicals ADD COLUMN current_value REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE chemicals ADD COLUMN min_stock_amount REAL NOT NULL DEFAULT 0;
|
||||
ALTER TABLE chemicals ADD COLUMN best_before_date TEXT;
|
||||
ALTER TABLE chemicals ADD COLUMN location TEXT;
|
||||
ALTER TABLE chemicals ADD COLUMN product_group_id INTEGER;
|
||||
ALTER TABLE chemicals ADD COLUMN qu_id INTEGER;
|
||||
ALTER TABLE chemicals ADD COLUMN location_id INTEGER;
|
||||
ALTER TABLE chemicals ADD COLUMN picture_file_name TEXT;
|
||||
ALTER TABLE chemicals ADD COLUMN last_synced_at TEXT;
|
||||
|
||||
-- 2. 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_chem_amount ON chemicals(current_amount);
|
||||
CREATE INDEX IF NOT EXISTS idx_chem_pg ON chemicals(product_group_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_chem_synced ON chemicals(last_synced_at);
|
||||
|
||||
-- 3. Grocy 设置 seed
|
||||
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
|
||||
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比,库存/min_stock_amount * 100 <= 该值时标红)'),
|
||||
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
|
||||
@@ -0,0 +1,39 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0005: 资料来源 + 分类映射 + 用品详情
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. chemicals 表加资料来源 + 同步元数据
|
||||
ALTER TABLE chemicals ADD COLUMN source TEXT NOT NULL DEFAULT 'manual';
|
||||
-- source: 'grocy' | 'manual' | 'seed'
|
||||
ALTER TABLE chemicals ADD COLUMN grocy_last_pulled_at TEXT;
|
||||
|
||||
-- 2. 分类映射表(grocy_product_group_id → 真实名字)
|
||||
CREATE TABLE IF NOT EXISTS category_mappings (
|
||||
grocy_group_id INTEGER PRIMARY KEY,
|
||||
display_name TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 3. chemical_inventory_log 进销存记录(本地系统用)
|
||||
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
chemical_id TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust')),
|
||||
amount_delta REAL NOT NULL,
|
||||
amount_after REAL,
|
||||
source TEXT NOT NULL DEFAULT 'local',
|
||||
-- 'local' = 本系统产生,'grocy' = 来自 Grocy
|
||||
source_ref TEXT,
|
||||
-- 外部引用(如 Grocy stock log id、wash_record_id 等)
|
||||
note TEXT,
|
||||
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_invlog_chem ON chemical_inventory_log(chemical_id, occurred_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invlog_type ON chemical_inventory_log(change_type);
|
||||
|
||||
-- 4. settings seed
|
||||
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||
('grocy_categories_json', '[]', 0, 'Grocy 分类 ID → 显示名映射(JSON: [{id, name}])');
|
||||
@@ -0,0 +1,13 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0006: 单位换算
|
||||
-- =============================================================================
|
||||
-- qu_factor: 1 个 user_input_unit 包含多少 stock_unit(1=无换算)
|
||||
-- 例如:阿达姆斯加仑装 stock_unit=毫升,qu_factor=3785(1 加仑 = 3785 毫升)
|
||||
ALTER TABLE chemicals ADD COLUMN qu_factor REAL NOT NULL DEFAULT 1.0;
|
||||
ALTER TABLE chemicals ADD COLUMN consume_unit_id INTEGER; -- Grocy qu_id_consume
|
||||
ALTER TABLE chemicals ADD COLUMN consume_unit_name TEXT; -- 显示用
|
||||
|
||||
-- chemical_usage 加 stock_amount(最小单位,扣减 Grocy 用)
|
||||
ALTER TABLE chemical_usage ADD COLUMN unit TEXT; -- 用户输入的单位
|
||||
ALTER TABLE chemical_usage ADD COLUMN stock_amount REAL; -- 换算后 Grocy stock unit 量
|
||||
ALTER TABLE chemical_usage ADD COLUMN consume_unit_id INTEGER;
|
||||
@@ -0,0 +1,75 @@
|
||||
-- 0007_vehicle_logs.sql — 保养 / 加油 / 充电三类用车记录
|
||||
-- 三张表结构对称:(vehicle_id, log_date, odometer_km, location, total_cost, notes, created_at)
|
||||
-- 业务差异字段单独存
|
||||
|
||||
CREATE TABLE IF NOT EXISTS maintenance_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vehicle_id INTEGER NOT NULL,
|
||||
maint_date TEXT NOT NULL,
|
||||
odometer_km INTEGER,
|
||||
total_cost REAL NOT NULL DEFAULT 0,
|
||||
shop TEXT,
|
||||
items_json TEXT NOT NULL DEFAULT '[]', -- [{name, cost, interval_km}, ...]
|
||||
next_due_date TEXT,
|
||||
next_due_km INTEGER,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_maint_vehicle_date ON maintenance_records(vehicle_id, maint_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_maint_date ON maintenance_records(maint_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refuel_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vehicle_id INTEGER NOT NULL,
|
||||
refuel_date TEXT NOT NULL,
|
||||
odometer_km INTEGER,
|
||||
liters REAL NOT NULL,
|
||||
price_per_liter REAL,
|
||||
total_cost REAL NOT NULL,
|
||||
fuel_type TEXT, -- 92 / 95 / 98 / 0#柴油 / 自定义
|
||||
is_full INTEGER NOT NULL DEFAULT 0, -- 是否加满(计算油耗需要)
|
||||
station TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_refuel_vehicle_date ON refuel_records(vehicle_id, refuel_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_refuel_date ON refuel_records(refuel_date DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS charging_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vehicle_id INTEGER NOT NULL,
|
||||
charge_date TEXT NOT NULL,
|
||||
odometer_km INTEGER,
|
||||
kwh REAL NOT NULL,
|
||||
price_per_kwh REAL,
|
||||
total_cost REAL NOT NULL,
|
||||
charge_type TEXT, -- slow (慢充/交流) / fast (快充/直流) / home (家充) / public (公共桩)
|
||||
start_soc INTEGER, -- 起始电量 %
|
||||
end_soc INTEGER, -- 结束电量 %
|
||||
station TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_charging_vehicle_date ON charging_records(vehicle_id, charge_date DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_charging_date ON charging_records(charge_date DESC);
|
||||
|
||||
-- 一张视图方便首页拿"最近 30 天每类最新 5 条"
|
||||
DROP VIEW IF EXISTS v_recent_logs;
|
||||
CREATE VIEW v_recent_logs AS
|
||||
SELECT 'maintenance' AS log_type, id, vehicle_id, maint_date AS log_date,
|
||||
total_cost, odometer_km, shop AS location
|
||||
FROM maintenance_records
|
||||
UNION ALL
|
||||
SELECT 'refuel' AS log_type, id, vehicle_id, refuel_date AS log_date,
|
||||
total_cost, odometer_km, station AS location
|
||||
FROM refuel_records
|
||||
UNION ALL
|
||||
SELECT 'charging' AS log_type, id, vehicle_id, charge_date AS log_date,
|
||||
total_cost, odometer_km, station AS location
|
||||
FROM charging_records;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- 0008_mileage_and_insurance.sql
|
||||
-- 1. 混动车:保养记录加 EV 里程和 HEV 里程
|
||||
ALTER TABLE maintenance_records ADD COLUMN ev_km INTEGER;
|
||||
ALTER TABLE maintenance_records ADD COLUMN hev_km INTEGER;
|
||||
|
||||
-- 2. 保险记录(含附件)
|
||||
CREATE TABLE IF NOT EXISTS insurance_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
vehicle_id INTEGER NOT NULL,
|
||||
insurance_type TEXT NOT NULL, -- 交强险 / 商业险 / 车损险 / 三责险 / 座位险 / 不计免赔 / 玻璃险 / 划痕险 / 自燃险 / 涉水险
|
||||
company TEXT, -- 人保 / 平安 / 太保 / 中华 / ...
|
||||
policy_no TEXT, -- 保单号
|
||||
start_date TEXT NOT NULL, -- 生效日
|
||||
end_date TEXT NOT NULL, -- 到期日
|
||||
premium REAL, -- 保费
|
||||
coverage_amount REAL, -- 保额(可选)
|
||||
notes TEXT,
|
||||
attachment_path TEXT, -- 保单图片/PDF 相对路径(uploads/insurance/xxx.pdf)
|
||||
attachment_name TEXT, -- 原文件名
|
||||
attachment_mime TEXT, -- mime type
|
||||
attachment_size INTEGER, -- 字节
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_insurance_vehicle ON insurance_records(vehicle_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_insurance_end_date ON insurance_records(end_date);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 0009_vehicle_powertrain.sql
|
||||
-- 车辆动力类型:纯油 (ice) / 混动 (hev) / 纯电 (ev) / 增程 (erev)
|
||||
-- 油耗只在 ice 上算;电耗只在 ev 上算;hev/erev 算不了(分不清油/电)
|
||||
ALTER TABLE vehicles ADD COLUMN powertrain TEXT NOT NULL DEFAULT 'ice'
|
||||
CHECK (powertrain IN ('ice', 'hev', 'ev', 'erev'));
|
||||
@@ -0,0 +1,18 @@
|
||||
-- 0010_operation_logs.sql — 操作日志(审计用)
|
||||
-- 记录"会改变数据"的操作,重点是删除类(不可逆),也兼容未来扩展 create/update
|
||||
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
username TEXT, -- 冗余存一份:用户被删/改名后日志还能看懂
|
||||
action TEXT NOT NULL, -- 'delete' | 'batch_delete' | 'create' | 'update' | ...
|
||||
target_type TEXT NOT NULL, -- 'wash_record' | 'chemical' | ...
|
||||
target_ids TEXT NOT NULL, -- JSON 数组(批量时是多个 id)
|
||||
target_summary TEXT, -- 人类可读的摘要,例如 "洗车 2026-01-15 快速 ¥30"
|
||||
detail_json TEXT, -- 任意 JSON,存删除前的快照等
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_created ON operation_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_user_time ON operation_logs(username, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_oplog_action ON operation_logs(action, target_type, created_at DESC);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 0011_soft_delete.sql — 统一软删(is_deleted)+ 操作日志完善
|
||||
-- 所有数据表加 is_deleted 标志,DELETE 改为 UPDATE SET is_deleted=1
|
||||
-- 恢复:UPDATE SET is_deleted=0(操作日志已存完整快照)
|
||||
|
||||
ALTER TABLE vehicles ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
ALTER TABLE wash_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
ALTER TABLE chemical_usage ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
ALTER TABLE maintenance_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
ALTER TABLE refuel_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
ALTER TABLE charging_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
ALTER TABLE insurance_records ADD COLUMN is_deleted INTEGER DEFAULT 0;
|
||||
|
||||
-- 索引加速
|
||||
CREATE INDEX IF NOT EXISTS ix_vehicles_is_deleted ON vehicles(is_deleted);
|
||||
CREATE INDEX IF NOT EXISTS ix_wash_records_is_deleted ON wash_records(is_deleted);
|
||||
CREATE INDEX IF NOT EXISTS ix_maintenance_is_deleted ON maintenance_records(is_deleted);
|
||||
CREATE INDEX IF NOT EXISTS ix_refuel_is_deleted ON refuel_records(is_deleted);
|
||||
CREATE INDEX IF NOT EXISTS ix_charging_is_deleted ON charging_records(is_deleted);
|
||||
CREATE INDEX IF NOT EXISTS ix_insurance_is_deleted ON insurance_records(is_deleted);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 0012_operation_logs_recovery.sql — 操作日志恢复支持
|
||||
-- 1. operation_logs 表加 recovered_at 字段(恢复时间戳)
|
||||
-- 2. 各数据表 is_deleted 默认值设为 0(已有列则跳过 ALTER TABLE 报错)
|
||||
|
||||
ALTER TABLE operation_logs ADD COLUMN recovered_at TEXT;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 0013_weather_wttr.sql — 天气表支持 wttr provider
|
||||
-- 原 CHECK 只允许 qweather/openweathermap,扩展到包含 wttr
|
||||
-- SQLite 无法直接 ALTER CHECK,需重建表
|
||||
|
||||
CREATE TABLE IF NOT EXISTS _weather_snapshots_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot_date TEXT NOT NULL,
|
||||
city TEXT NOT NULL,
|
||||
provider TEXT NOT NULL CHECK (provider IN ('wttr','qweather','openweathermap')),
|
||||
temp_c REAL,
|
||||
humidity INTEGER,
|
||||
weather_desc TEXT,
|
||||
weather_code TEXT,
|
||||
wind_kph REAL,
|
||||
precip_mm REAL,
|
||||
raw_json TEXT,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO _weather_snapshots_new
|
||||
(id, snapshot_date, city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, fetched_at)
|
||||
SELECT id, snapshot_date, city, provider, temp_c, humidity, weather_desc, weather_code, wind_kph, precip_mm, raw_json, fetched_at
|
||||
FROM weather_snapshots;
|
||||
DROP TABLE weather_snapshots;
|
||||
ALTER TABLE _weather_snapshots_new RENAME TO weather_snapshots;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uk_weather_city_date ON weather_snapshots(city, snapshot_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_weather_date ON weather_snapshots(snapshot_date);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- =============================================================================
|
||||
-- Migration 0014: Grocy 鉴权改造 + 同步日志表 + 天气默认城市
|
||||
-- =============================================================================
|
||||
|
||||
-- 1. settings 表:新增 grocy_username / grocy_password
|
||||
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||
('grocy_username', '', 1, 'Grocy 用户名(session cookie 鉴权)'),
|
||||
('grocy_password', '', 1, 'Grocy 密码(session cookie 鉴权)');
|
||||
|
||||
-- 2. 新增默认城市设置
|
||||
INSERT OR IGNORE INTO settings (key, value, is_secret, description) VALUES
|
||||
('app_city_default', '', 0, '天气默认城市(永久生效)');
|
||||
|
||||
-- 3. Grocy 同步日志表
|
||||
CREATE TABLE IF NOT EXISTS grocy_sync_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL, -- 'pull_products' | 'sync_usage'
|
||||
status TEXT NOT NULL, -- 'success' | 'failed' | 'partial'
|
||||
ok_count INTEGER NOT NULL DEFAULT 0,
|
||||
fail_count INTEGER NOT NULL DEFAULT 0,
|
||||
detail TEXT, -- JSON 详情
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_grocy_sync_logs_action ON grocy_sync_logs(action);
|
||||
CREATE INDEX IF NOT EXISTS idx_grocy_sync_logs_started ON grocy_sync_logs(started_at DESC);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 0015_wash_photos.sql (MySQL) — 洗车对比照(before / after / detail)
|
||||
CREATE TABLE IF NOT EXISTS wash_photos (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
wash_id INT NOT NULL,
|
||||
photo_type VARCHAR(20) NOT NULL DEFAULT 'detail', -- before / after / detail / scene
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
mime_type VARCHAR(50) DEFAULT NULL,
|
||||
file_size INT DEFAULT NULL,
|
||||
width INT DEFAULT NULL,
|
||||
height INT DEFAULT NULL,
|
||||
caption VARCHAR(255) DEFAULT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
|
||||
INDEX idx_wash_photos_wash (wash_id, is_deleted),
|
||||
INDEX idx_wash_photos_type (photo_type),
|
||||
INDEX idx_wash_photos_created (created_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- 0016_vehicle_current_km.sql — 车辆当前里程(手动校准)
|
||||
-- 真实里程 = MAX(current_km, MAX(odometer_km) FROM 各日志表)
|
||||
-- 用户可以手动覆盖 current_km(比如仪表盘数与日志对不上时)
|
||||
ALTER TABLE vehicles
|
||||
ADD COLUMN current_km INT DEFAULT NULL COMMENT '手动校准的当前里程,NULL 时按各日志表 MAX 算';
|
||||
|
||||
-- 保险提示阈值(可被 settings 覆盖)
|
||||
CREATE TABLE IF NOT EXISTS notification_prefs (
|
||||
key_name VARCHAR(50) NOT NULL PRIMARY KEY,
|
||||
days INT NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO notification_prefs (key_name, days, enabled) VALUES
|
||||
('refuel_remind_days', 30, 1),
|
||||
('maintenance_remind_days', 180, 1),
|
||||
('wash_remind_days', 14, 1)
|
||||
ON DUPLICATE KEY UPDATE updated_at = NOW();
|
||||
|
||||
-- 站内通知表(OCR / 同步 / 备份结果持久化)
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT DEFAULT NULL,
|
||||
type VARCHAR(30) NOT NULL, -- ocr_done / sync_done / backup_done / system
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT DEFAULT NULL,
|
||||
link VARCHAR(500) DEFAULT NULL,
|
||||
severity VARCHAR(20) NOT NULL DEFAULT 'info', -- info / warn / error / success
|
||||
is_read TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_notif_user_unread (user_id, is_read, created_at DESC),
|
||||
INDEX idx_notif_created (created_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 0017_tags.sql — 标签系统
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
color VARCHAR(20) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_tag_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS record_tags (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
record_type VARCHAR(20) NOT NULL, -- wash / refuel / charge / maintenance / insurance
|
||||
record_id INT NOT NULL,
|
||||
tag_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_record_tag (record_type, record_id, tag_id),
|
||||
INDEX idx_record (record_type, record_id),
|
||||
INDEX idx_tag (tag_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- 0018_achievements.sql — 成就系统
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL, -- 成就 code,如 'wash_streak_30'
|
||||
name VARCHAR(100) NOT NULL,
|
||||
description VARCHAR(255) DEFAULT NULL,
|
||||
icon VARCHAR(20) DEFAULT NULL, -- emoji
|
||||
threshold INT NOT NULL DEFAULT 1, -- 触发条件
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_ach_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_achievements (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
achievement_id INT NOT NULL,
|
||||
progress INT NOT NULL DEFAULT 0,
|
||||
unlocked_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_user_ach (user_id, achievement_id),
|
||||
INDEX idx_user (user_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT INTO achievements (code, name, description, icon, threshold) VALUES
|
||||
('wash_first', '洗车新手', '完成第一笔洗车记录', '🧽', 1),
|
||||
('wash_10', '勤洗车友', '累计洗车 10 次', '🚿', 10),
|
||||
('wash_50', '洗车达人', '累计洗车 50 次', '🛁', 50),
|
||||
('wash_100', '洗车狂魔', '累计洗车 100 次', '🏆', 100),
|
||||
('wash_streak_7', '一周一洗', '连续 7 天至少洗 1 次', '📅', 7),
|
||||
('wash_streak_30', '月度好习惯', '连续 30 天至少洗 1 次', '🌟', 30),
|
||||
('refuel_10', '小有车生活', '累计加油 10 次', '⛽', 10),
|
||||
('refuel_50', '老司机', '累计加油 50 次', '🚗', 50),
|
||||
('mileage_10000', '万里征程', '累计行驶突破 10000 公里', '🛣️', 10000),
|
||||
('mileage_100000', '十万俱乐部', '累计行驶突破 100000 公里', '🏅', 100000),
|
||||
('maintain_first', '爱车初保养', '完成第一笔保养记录', '🔧', 1),
|
||||
('maintain_5', '按时保养', '累计保养 5 次', '⚙️', 5),
|
||||
('cost_track_30d', '记账坚持者', '连续 30 天有记录', '📊', 30),
|
||||
('insure_first', '保险达人', '记录第一张保单', '🛡️', 1)
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), icon = VALUES(icon), threshold = VALUES(threshold);
|
||||
@@ -0,0 +1,148 @@
|
||||
-- =============================================================================
|
||||
-- 洗车记录系统 - Migration 0001: 基础表 (MySQL 8.x)
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. chemicals - 药剂字典(Grocy 缓存层)
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chemicals (
|
||||
grocy_product_id VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
category VARCHAR(255) DEFAULT NULL,
|
||||
unit VARCHAR(50) NOT NULL DEFAULT 'ml',
|
||||
standard_dose DOUBLE DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
fetched_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (grocy_product_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_chemicals_category ON chemicals(category);
|
||||
CREATE INDEX idx_chemicals_active ON chemicals(is_active);
|
||||
CREATE INDEX idx_chemicals_fetched ON chemicals(fetched_at);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. weather_snapshots - 天气快照
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS weather_snapshots (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
snapshot_date VARCHAR(10) NOT NULL,
|
||||
city VARCHAR(100) NOT NULL,
|
||||
provider VARCHAR(50) NOT NULL,
|
||||
temp_c DOUBLE DEFAULT NULL,
|
||||
humidity INT DEFAULT NULL,
|
||||
weather_desc VARCHAR(255) DEFAULT NULL,
|
||||
weather_code VARCHAR(20) DEFAULT NULL,
|
||||
wind_kph DOUBLE DEFAULT NULL,
|
||||
precip_mm DOUBLE DEFAULT NULL,
|
||||
raw_json TEXT DEFAULT NULL,
|
||||
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE UNIQUE INDEX uk_weather_city_date ON weather_snapshots(city, snapshot_date);
|
||||
CREATE INDEX idx_weather_date ON weather_snapshots(snapshot_date);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. wash_records - 洗车记录
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS wash_records (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
wash_date VARCHAR(10) NOT NULL,
|
||||
wash_type VARCHAR(20) NOT NULL,
|
||||
weather_snapshot_id INT DEFAULT NULL,
|
||||
location VARCHAR(255) DEFAULT NULL,
|
||||
cost DOUBLE NOT NULL DEFAULT 0,
|
||||
duration_min INT DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_wash_type CHECK (wash_type IN ('quick','full','detail','other')),
|
||||
CONSTRAINT fk_wash_weather FOREIGN KEY (weather_snapshot_id) REFERENCES weather_snapshots(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_wash_records_date ON wash_records(wash_date);
|
||||
CREATE INDEX idx_wash_records_type ON wash_records(wash_type);
|
||||
CREATE INDEX idx_wash_records_weather ON wash_records(weather_snapshot_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. chemical_usage - 药剂消耗
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS chemical_usage (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
usage_date VARCHAR(10) NOT NULL,
|
||||
chemical_id VARCHAR(255) NOT NULL,
|
||||
amount DOUBLE NOT NULL,
|
||||
wash_record_id INT DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
sync_status VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
sync_at DATETIME DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_sync_status CHECK (sync_status IN ('pending','synced','failed')),
|
||||
CONSTRAINT fk_usage_chemical FOREIGN KEY (chemical_id) REFERENCES chemicals(grocy_product_id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_usage_wash FOREIGN KEY (wash_record_id) REFERENCES wash_records(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_usage_date ON chemical_usage(usage_date);
|
||||
CREATE INDEX idx_usage_chemical ON chemical_usage(chemical_id);
|
||||
CREATE INDEX idx_usage_wash ON chemical_usage(wash_record_id);
|
||||
CREATE INDEX idx_usage_sync ON chemical_usage(sync_status);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. settings - 运行时配置 KV
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
`key` VARCHAR(100) NOT NULL PRIMARY KEY,
|
||||
value TEXT DEFAULT NULL,
|
||||
is_secret TINYINT(1) NOT NULL DEFAULT 0,
|
||||
description TEXT DEFAULT NULL,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('app_city', NULL, 0, '所在城市(用于天气查询)'),
|
||||
('app_timezone', 'Asia/Shanghai', 0, '时区'),
|
||||
('grocy_url', NULL, 0, 'Grocy 实例 URL'),
|
||||
('grocy_api_token', NULL, 1, 'Grocy REST API token'),
|
||||
('backup_keep_count', '10', 0, '本地备份保留份数'),
|
||||
('backup_dir', 'storage/backups', 0, '备份输出目录');
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 6. views
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP VIEW IF EXISTS v_wash_monthly_count;
|
||||
CREATE VIEW v_wash_monthly_count AS
|
||||
SELECT
|
||||
SUBSTRING(wash_date, 1, 7) AS month,
|
||||
COUNT(*) AS wash_count,
|
||||
SUM(COALESCE(cost, 0)) AS total_cost
|
||||
FROM wash_records
|
||||
GROUP BY SUBSTRING(wash_date, 1, 7)
|
||||
ORDER BY month DESC;
|
||||
|
||||
DROP VIEW IF EXISTS v_chemical_monthly_usage;
|
||||
CREATE VIEW v_chemical_monthly_usage AS
|
||||
SELECT
|
||||
SUBSTRING(cu.usage_date, 1, 7) AS month,
|
||||
c.grocy_product_id AS grocy_product_id,
|
||||
c.name AS chemical_name,
|
||||
c.unit AS unit,
|
||||
SUM(cu.amount) AS total_amount,
|
||||
COUNT(*) AS usage_count
|
||||
FROM chemical_usage cu
|
||||
JOIN chemicals c ON c.grocy_product_id = cu.chemical_id
|
||||
GROUP BY SUBSTRING(cu.usage_date, 1, 7), c.grocy_product_id
|
||||
ORDER BY month DESC, total_amount DESC;
|
||||
|
||||
DROP VIEW IF EXISTS v_last_wash;
|
||||
CREATE VIEW v_last_wash AS
|
||||
SELECT
|
||||
id AS wash_id,
|
||||
wash_date,
|
||||
wash_type,
|
||||
DATEDIFF(NOW(), STR_TO_DATE(wash_date, '%Y-%m-%d')) AS days_since
|
||||
FROM wash_records
|
||||
ORDER BY wash_date DESC, id DESC
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- 0002_auth.sql - 用户认证 + 防撞库 (MySQL)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME DEFAULT NULL,
|
||||
last_login_ip VARCHAR(45) DEFAULT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_role CHECK (role IN ('user','admin'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_users_active ON users(is_active);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
attempted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address VARCHAR(45) NOT NULL,
|
||||
username VARCHAR(50) NOT NULL,
|
||||
success TINYINT(1) NOT NULL,
|
||||
user_agent VARCHAR(500) DEFAULT NULL,
|
||||
failure_reason VARCHAR(100) DEFAULT NULL,
|
||||
CONSTRAINT chk_success CHECK (success IN (0, 1))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_attempts_ip_time ON login_attempts(ip_address, attempted_at);
|
||||
CREATE INDEX idx_attempts_user_time ON login_attempts(username, attempted_at);
|
||||
CREATE INDEX idx_attempts_time ON login_attempts(attempted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_locks (
|
||||
lock_key VARCHAR(100) PRIMARY KEY,
|
||||
lock_type VARCHAR(10) NOT NULL,
|
||||
target VARCHAR(50) NOT NULL,
|
||||
locked_until DATETIME NOT NULL,
|
||||
reason VARCHAR(255) DEFAULT NULL,
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_lock_type CHECK (lock_type IN ('ip','user'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_locks_until ON auth_locks(locked_until);
|
||||
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('session_lifetime_days', '30', 0, '登录 session 有效期(天)'),
|
||||
('session_cookie_secure', 'auto', 0, 'Cookie secure 标志:true/false/auto'),
|
||||
('login_max_failures_ip', '5', 0, '每 IP 允许的最大连续失败次数'),
|
||||
('login_max_failures_user', '5', 0, '每用户名允许的最大连续失败次数'),
|
||||
('login_lock_minutes_ip', '15', 0, 'IP 级别锁定时长(分钟)'),
|
||||
('login_lock_minutes_user', '30', 0, '用户名级别锁定时长(分钟)'),
|
||||
('login_global_max_failures', '10', 0, '触发全局 IP 封锁的失败次数'),
|
||||
('login_global_lock_hours', '1', 0, '全局 IP 封锁时长(小时)'),
|
||||
('login_attempts_retention_days', '30', 0, 'login_attempts 保留天数'),
|
||||
('csrf_token_lifetime_hours', '12', 0, 'CSRF token 有效期(小时)'),
|
||||
('bcrypt_cost', '12', 0, 'bcrypt cost factor');
|
||||
@@ -0,0 +1,35 @@
|
||||
-- 0003_vehicles.sql - 车辆管理 (MySQL)
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vehicles (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
plate VARCHAR(20) DEFAULT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'car',
|
||||
color VARCHAR(30) DEFAULT NULL,
|
||||
notes TEXT DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_vehicle_type CHECK (type IN ('car','suv','mpv','truck','other'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_vehicles_active ON vehicles(is_active);
|
||||
CREATE INDEX idx_vehicles_sort ON vehicles(sort_order);
|
||||
|
||||
ALTER TABLE wash_records ADD COLUMN vehicle_id INT DEFAULT NULL;
|
||||
CREATE INDEX idx_wash_records_vehicle ON wash_records(vehicle_id);
|
||||
|
||||
DROP VIEW IF EXISTS v_last_wash;
|
||||
CREATE VIEW v_last_wash AS
|
||||
SELECT
|
||||
w.id AS wash_id,
|
||||
w.wash_date,
|
||||
w.wash_type,
|
||||
w.vehicle_id,
|
||||
v.name AS vehicle_name,
|
||||
DATEDIFF(NOW(), STR_TO_DATE(w.wash_date, '%Y-%m-%d')) AS days_since
|
||||
FROM wash_records w
|
||||
LEFT JOIN vehicles v ON v.id = w.vehicle_id
|
||||
ORDER BY w.wash_date DESC, w.id DESC
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 0004_grocy_full.sql - Grocy 主数据同步字段 (MySQL)
|
||||
ALTER TABLE chemicals
|
||||
ADD COLUMN description TEXT DEFAULT NULL,
|
||||
ADD COLUMN current_amount DOUBLE NOT NULL DEFAULT 0,
|
||||
ADD COLUMN current_value DOUBLE NOT NULL DEFAULT 0,
|
||||
ADD COLUMN min_stock_amount DOUBLE NOT NULL DEFAULT 0,
|
||||
ADD COLUMN best_before_date VARCHAR(20) DEFAULT NULL,
|
||||
ADD COLUMN location VARCHAR(255) DEFAULT NULL,
|
||||
ADD COLUMN product_group_id INT DEFAULT NULL,
|
||||
ADD COLUMN qu_id INT DEFAULT NULL,
|
||||
ADD COLUMN location_id INT DEFAULT NULL,
|
||||
ADD COLUMN picture_file_name VARCHAR(255) DEFAULT NULL,
|
||||
ADD COLUMN last_synced_at DATETIME DEFAULT NULL;
|
||||
|
||||
CREATE INDEX idx_chem_amount ON chemicals(current_amount);
|
||||
CREATE INDEX idx_chem_pg ON chemicals(product_group_id);
|
||||
CREATE INDEX idx_chem_synced ON chemicals(last_synced_at);
|
||||
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('grocy_sync_batch', '50', 0, 'Grocy 扣减同步每批条数'),
|
||||
('grocy_low_stock_pct', '20', 0, '低库存阈值(百分比)'),
|
||||
('grocy_pull_auto', '0', 0, 'Grocy 拉取模式:0=手动,1=启动时自动拉');
|
||||
@@ -0,0 +1,31 @@
|
||||
-- 0005_inventory_detail.sql (MySQL)
|
||||
ALTER TABLE chemicals
|
||||
ADD COLUMN source VARCHAR(20) NOT NULL DEFAULT 'manual',
|
||||
ADD COLUMN grocy_last_pulled_at DATETIME DEFAULT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS category_mappings (
|
||||
grocy_group_id INT PRIMARY KEY,
|
||||
display_name VARCHAR(100) NOT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chemical_inventory_log (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
chemical_id VARCHAR(255) NOT NULL,
|
||||
change_type VARCHAR(20) NOT NULL,
|
||||
amount_delta DOUBLE NOT NULL,
|
||||
amount_after DOUBLE DEFAULT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'local',
|
||||
source_ref VARCHAR(255) DEFAULT NULL,
|
||||
note TEXT DEFAULT NULL,
|
||||
occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_change_type CHECK (change_type IN ('purchase','consume','inventory','transfer','adjust'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE INDEX idx_invlog_chem ON chemical_inventory_log(chemical_id, occurred_at DESC);
|
||||
CREATE INDEX idx_invlog_type ON chemical_inventory_log(change_type);
|
||||
|
||||
INSERT IGNORE INTO settings (`key`, value, is_secret, description) VALUES
|
||||
('grocy_categories_json', '[]', 0, 'Grocy 分类映射 JSON');
|
||||
@@ -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;
|
||||