Files
bid-setup.tootaio.com/app/pages/index.vue
xiaomai b00a130114 feat(table): enhance item table with sticky header and improved search
This commit introduces two key improvements to the items table. A sticky header and max-height are added for better
usability on long lists. The global search is enhanced to allow filtering by brand and tags. This is achieved by adding
a hidden column for the brand and using an accessorFn to convert the tags array into a searchable string.
2025-10-14 23:07:11 +08:00

280 lines
7.6 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>
<div>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- 统计 -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div
v-for="stat in statistics"
:key="stat.label"
class="bg-white rounded-xl p-6 shadow-sm border border-gray-100"
>
<div class="flex items-center">
<div
:class="`p-3 rounded-lg flex bg-${stat.color}-100 items-center`"
>
<Icon
class="w-6 h-6"
:name="stat.icon"
:style="`color: ${stat.color}`"
/>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">{{ stat.label }}</p>
<p class="text-2xl font-bold text-gray-900">{{ stat.value }}</p>
</div>
</div>
</div>
</div>
<!-- 列表 -->
<div class="flex flex-col flex-1 w-full">
<div
class="flex justify-between items-center gap-2 overflow-x-auto px-4 py-3.5 border-b border-accented"
>
<div>
<UInput
v-model="globalFilter"
class="max-w-sm"
placeholder="Search..."
icon="lucide:search"
/>
</div>
<div>
<ItemEditModal ref="itemEditModal" />
</div>
</div>
<!-- UTable 一个 keytableKey用于在需要时强制重挂载 -->
<UTable
:key="tableKey"
sticky
v-model:global-filter="globalFilter"
:data="itemsList"
:columns="itemColumns"
class="max-h-[640px]"
>
<template #select-cell="{ row }">
<UCheckbox
:model-value="row.getIsSelected()"
@update:model-value="(value: boolean | 'indeterminate') => row.toggleSelected(!!value)"
aria-label="Select row"
/>
</template>
<template #name-cell="{ row }">
<div class="flex items-center gap-3">
<UAvatar
:src="row.original.imageUrl"
size="lg"
:alt="row.original.name"
/>
<div>
<p class="font-medium text-highlighted">
<strong>{{ row.original.brand }}</strong>
{{ row.original.name }}
</p>
</div>
</div>
</template>
<!-- tags 空值兜底,并加 key 避免重复 key 警告 -->
<template #tags-cell="{ row }">
<div class="flex gap-2">
<UBadge
v-for="(tag, idx) in row.original.tags ?? []"
:key="`${row.original.id}-tag-${idx}`"
variant="subtle"
>
{{ tag }}
</UBadge>
</div>
</template>
<template #actions-cell="{ row }">
<div class="text-right">
<UDropdownMenu
:items="getRowItems(row)"
:content="{ align: 'end' }"
aria-label="Actions dropdown"
>
<UButton
icon="i-lucide-ellipsis-vertical"
color="neutral"
variant="ghost"
class="ml-auto"
aria-label="Actions dropdown"
/>
</UDropdownMenu>
</div>
</template>
</UTable>
</div>
<ItemDeleteModal
v-model:open="deleteModal.open"
:item="deleteModal.selectedItem"
/>
</main>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, reactive, watch } from "vue";
import type { TableColumn, DropdownMenuItem } from "@nuxt/ui";
import type { Row } from "@tanstack/vue-table";
const UCheckbox = resolveComponent("UCheckbox");
const UBadge = resolveComponent("UBadge");
const UAvatar = resolveComponent("UAvatar");
const toast = useToast();
const { items, stats } = useItemsStore();
const itemEditModal = ref<any>(null);
const deleteModal = reactive<{ open: boolean; selectedItem: Item | null }>({
open: false,
selectedItem: null,
});
const globalFilter = ref<string>("");
// --- computed 包装,保证传给 UTable 的始终是响应式且值随 items 变化
const itemsList = computed(() => items.value ?? []);
// --- 强制表格重挂载的 key当 items 改变时递增)
const tableKey = ref(0);
// 深度监听 items数组变化每次 items 内容或引用变化都让 tableKey++,从而触发 UTable 重新挂载
watch(
items,
() => {
tableKey.value += 1;
// Console debug运行时可在浏览器控制台查看
console.debug(
"[items watch] tableKey ->",
tableKey.value,
"items.length ->",
(items.value ?? []).length
);
},
{ deep: true }
);
// 统计
const statistics = [
{
icon: "lucide:box",
label: "总物品数",
value: stats.totalItems,
color: "blue",
},
{
icon: "lucide:package-plus",
label: "本月新增",
value: stats.addedThisMonth,
color: "green",
},
{
icon: "lucide:history",
label: "历史记录",
value: computed(() => 0),
color: "orange",
},
{
icon: "lucide:image",
label: "图片总数",
value: computed(() => 0),
color: "purple",
},
];
const itemColumns: TableColumn<Item>[] = [
{
id: "select",
header: ({ table }) =>
h(UCheckbox, {
modelValue: table.getIsSomePageRowsSelected()
? "indeterminate"
: table.getIsAllPageRowsSelected(),
"onUpdate:modelValue": (value: boolean | "indeterminate") =>
table.toggleAllPageRowsSelected(!!value),
ariaLabel: "Select all",
}),
},
{
accessorKey: "id",
header: "#",
cell: ({ row }) => `#${row.getValue("id")}`,
},
// 新增一个 brand 列 —— 让它参与全局搜索但不实际渲染内容brand 已在 name-cell 中显示)
{
accessorKey: "brand",
header: "", // 留空,避免影响布局
// 明确允许全局过滤(通常是默认 true但写上更明确
enableGlobalFilter: true,
// 不在表格中重复显示 brand因为你在 name-cell 已经显示了)
cell: () => null,
},
{
accessorKey: "name",
header: "标品名称",
},
{
accessorKey: "tags",
header: "标签",
accessorFn: (row: Item) => (row.tags ?? []).join(" "), // <-- 关键:把数组转成字符串
enableGlobalFilter: true,
// 注意:你仍然使用 template #tags-cell 来渲染标签外观badgeslot 会优先显示
},
{
id: "actions",
header: "", // 不需要标题
},
];
function getRowItems(row: Row<Item>): DropdownMenuItem[] {
return [
{ type: "label", label: "操作" },
{
label: "编辑",
color: "primary",
icon: "lucide:pencil-line",
onSelect() {
itemEditModal.value?.openModal(row.original);
toast.add({
title: `编辑 ${row.original.name}`,
description: "已打开编辑模态框",
color: "warning",
icon: "lucide:pencil-line",
});
},
},
{ type: "separator" },
{
label: "删除",
color: "error",
icon: "lucide:trash-2",
onSelect() {
deleteModal.selectedItem = row.original;
deleteModal.open = true;
toast.add({
title: `Deleting ${row.original.name}...`,
color: "error",
icon: "lucide:trash-2",
});
},
},
] as const;
}
// Define Shortcut
defineShortcuts({
meta_i: () => {
console.log("Shortcut works!");
},
});
</script>
<style></style>