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 + 跑测试。
352 lines
13 KiB
Vue
352 lines
13 KiB
Vue
<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>
|