65b0bb04f8
把 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 + 跑测试。
370 lines
17 KiB
Vue
370 lines
17 KiB
Vue
<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>
|