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

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

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

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

370 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>