Files
i/client/src/views/WashesList.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

352 lines
13 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"> {{ 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>