Files
CarLog/client/src/components/MobileCardList.vue
T
wsh5485 fe17886ac4 feat: 洗车管理系统 v2.8 — 个人 detailer 单用户全栈应用
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 CRUD + 软删
- AI 截图识别(5 类型 OCR schema):OpenAI 兼容 + MiniMax M3
- 化学品 / Grocy 实例对接 + 库存镜像同步
- 仪表盘:30 天频次 + 健康度 + 同比环比 + 油价趋势 + 年均养护
- 月度报表:Excel 6 sheet + PDF
- PWA:manifest / SW / 离线缓存 / iOS 引导
- 安全:bcrypt + CSRF + 登录锁定(IP/用户/全局三级)+ 401 自动跳登录 + 表单草稿
- 高 ROI 8 功能:里程/提醒/成本/搜索/标签/通知/同比/成就
- 3 个新 migration(0016/0017/0018)+ 18 个迁移全幂等
- 101/101 测试通过(含 ipRateLimit / CSRF / retry / stats / tags / notifications)
- 部署:宝塔面板文档 + PM2 + Nginx
2026-06-20 21:11:54 +08:00

242 lines
7.7 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>
<!-- 桌面端表格视图 -->
<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>