fe17886ac4
- 车辆 / 洗车 / 加油 / 充电 / 保养 / 保险 完整 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
242 lines
7.7 KiB
Vue
242 lines
7.7 KiB
Vue
<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>
|