Files
bid-setup.tootaio.com/app/pages/index.vue
xiaomai 070aa8ff5f feat(item): add brand field to items
This commit introduces an optional 'brand' field to the item model, allowing for better organization and
differentiation.

- The Add/Edit modal now includes a form field to select or create a brand.
- The item list on the main page now displays the brand next to the item name.
- Name uniqueness validation is now brand-aware. An item name must be unique within the context of its brand.
- The `addItem` logic is centralized in the `useItemsStore` to handle ID and timestamp generation.
2025-10-14 21:11:59 +08:00

265 lines
6.9 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"
v-model:global-filter="globalFilter"
:data="itemsList"
:columns="itemColumns"
>
<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")}`,
},
{
accessorKey: "name",
header: "标品名称",
},
{
accessorKey: "tags",
header: "标签",
},
{
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>