This commit replaces the previous simple layout with a comprehensive dashboard interface using Nuxt UI Pro components. - Introduced a new default layout with `UDashboardSidebar` and `UDashboardPanel`. - Refactored the main page (`index.vue`) by splitting it into `ItemManageStatistics` and `ItemManageTable` components. - Added new computed stats for monthly growth and total images, and displayed them in new statistic cards. - Reorganized components from `item/` to a new `itemManage/` directory. - Added custom primary and secondary theme colors.
360 lines
11 KiB
Vue
360 lines
11 KiB
Vue
<template>
|
||
<UDashboardPanel>
|
||
<template #header>
|
||
<UDashboardNavbar :title="pageTitle" toggle>
|
||
<template #leading>
|
||
<UDashboardSidebarCollapse />
|
||
</template>
|
||
</UDashboardNavbar>
|
||
</template>
|
||
|
||
<template #body>
|
||
<div class="space-y-4">
|
||
<div class="flex gap-4">
|
||
<!-- 物品列表 -->
|
||
<div class="w-1/2 min-w-[600px] overflow-x-auto">
|
||
<h2 class="text-2xl font-medium">物品列表</h2>
|
||
<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 class="space-x-2">
|
||
<UInput
|
||
v-model="globalFilter"
|
||
class="max-w-sm"
|
||
placeholder="Search..."
|
||
icon="lucide:search"
|
||
/>
|
||
<UInputNumber
|
||
v-model="startPriceInput"
|
||
:min="0"
|
||
increment-icon="lucide:banknote-arrow-up"
|
||
decrement-icon="lucide:banknote-arrow-down"
|
||
class="max-w-sm"
|
||
placeholder="Starting price"
|
||
icon="lucide:banknote"
|
||
:format-options="{
|
||
style: 'currency',
|
||
currency: 'MYR',
|
||
currencyDisplay: 'narrowSymbol',
|
||
currencySign: 'accounting',
|
||
}"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<UTable
|
||
sticky
|
||
v-model:global-filter="globalFilter"
|
||
:data="itemsList"
|
||
:columns="itemColumns"
|
||
class="max-h-[640px]"
|
||
v-model:row-selection="itemListRowSelection"
|
||
@select="onitemListSelect"
|
||
>
|
||
<template #name-cell="{ row }">
|
||
<div class="flex items-center gap-3 select-none">
|
||
<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>
|
||
</UTable>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 竞标清单 -->
|
||
<div class="w-1/2 min-w-[600px] overflow-x-auto">
|
||
<h2 class="text-2xl font-medium">竞标清单</h2>
|
||
<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 class="flex items-center gap-2">
|
||
<UInputNumber
|
||
v-model="batchStartPriceInput"
|
||
:min="0"
|
||
increment-icon="lucide:banknote-arrow-up"
|
||
decrement-icon="lucide:banknote-arrow-down"
|
||
class="max-w-sm"
|
||
placeholder="Starting price"
|
||
icon="lucide:banknote"
|
||
:format-options="{
|
||
style: 'currency',
|
||
currency: 'MYR',
|
||
currencyDisplay: 'narrowSymbol',
|
||
currencySign: 'accounting',
|
||
}"
|
||
/>
|
||
<UButton
|
||
icon="lucide:check"
|
||
:disabled="!isSomeBiddingItemSelected"
|
||
@click="applyStartPriceButtonClicked"
|
||
>应用到选中</UButton
|
||
>
|
||
</div>
|
||
</div>
|
||
<UTable
|
||
ref="biddingItemsTableRef"
|
||
sticky
|
||
:data="biddingItemList"
|
||
:columns="biddingItemColumns"
|
||
class="max-h-[640px] no-wrap-header"
|
||
:ui="{
|
||
tbody: 'bidding-item-table-tbody',
|
||
}"
|
||
v-model:column-pinning="biddingItemColumnPinning"
|
||
>
|
||
<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">
|
||
{{ row.original.name }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<template #startPrice-cell="{ row }">
|
||
<div class="">
|
||
{{ formatCurrency(row.original.startPrice) }}
|
||
</div>
|
||
</template>
|
||
|
||
<template #button-cell="{ row }">
|
||
<div class="space-x-2">
|
||
<UButton
|
||
icon="lucide:pencil-line"
|
||
@click="editItemButtonClicked(row.original)"
|
||
/>
|
||
<UButton
|
||
icon="lucide:trash-2"
|
||
color="error"
|
||
variant="outline"
|
||
@click="deleteItemButtonClicked(row.original.id)"
|
||
/>
|
||
</div>
|
||
</template>
|
||
</UTable>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- 导出预览 -->
|
||
<div class="space-y-2">
|
||
<h2 class="text-2xl font-medium">导出预览</h2>
|
||
<div class="bg-gray-200 p-6 rounded-md">
|
||
<pre>{{ exportCsv }}</pre>
|
||
</div>
|
||
<div class="flex gap-2">
|
||
<UButton
|
||
class="flex-1 justify-center"
|
||
icon="lucide:file-output"
|
||
@click="downloadCsv"
|
||
>导出 CSV</UButton
|
||
>
|
||
<UButton class="flex-1 justify-center" icon="lucide:download"
|
||
>下载图片集</UButton
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
<ExportEditModal ref="biddingItemEditModal" />
|
||
</div>
|
||
</template>
|
||
</UDashboardPanel>
|
||
</template>
|
||
|
||
<script lang="ts" setup>
|
||
const pageTitle = "导出配置";
|
||
|
||
import type { TableColumn, TableRow } from "@nuxt/ui";
|
||
import type { Table, Row } from "@tanstack/table-core";
|
||
import { useSortable } from "@vueuse/integrations/useSortable";
|
||
|
||
const UCheckbox = resolveComponent("UCheckbox");
|
||
|
||
const { items } = useItemsStore();
|
||
const itemsList = computed(() => items.value ?? []);
|
||
|
||
const { biddingItems, addBiddingItem, removeBiddingItem } = useBiddingItems();
|
||
const biddingItemList = biddingItems;
|
||
|
||
const biddingItemEditModal = ref<any>(null);
|
||
|
||
const formatCurrency = (value: number) => {
|
||
return new Intl.NumberFormat("en-MY", {
|
||
style: "currency",
|
||
currency: "MYR",
|
||
minimumFractionDigits: 2,
|
||
currencyDisplay: "narrowSymbol",
|
||
}).format(value);
|
||
};
|
||
|
||
// Left Table (Item List)
|
||
const globalFilter = ref<string>("");
|
||
const startPriceInput = ref<number>(0);
|
||
|
||
const itemListRowSelection = ref<Record<string, boolean>>({});
|
||
|
||
function onitemListSelect(row: TableRow<Item>, e?: Event) {
|
||
/* If you decide to also select the column you can do this */
|
||
// row.toggleSelected(!row.getIsSelected());
|
||
addBiddingItem(row.original, startPriceInput.value);
|
||
}
|
||
|
||
const itemColumns: TableColumn<Item>[] = [
|
||
{
|
||
accessorKey: "id",
|
||
header: "#",
|
||
cell: ({ row }) => `#${row.getValue("id")}`,
|
||
},
|
||
{
|
||
accessorKey: "name",
|
||
header: "标品名称",
|
||
},
|
||
// 新增一个 brand 列 —— 让它参与全局搜索,但不实际渲染内容(brand 已在 name-cell 中显示)
|
||
{
|
||
accessorKey: "brand",
|
||
header: "", // 留空,避免影响布局
|
||
// 明确允许全局过滤(通常是默认 true,但写上更明确)
|
||
enableGlobalFilter: true,
|
||
// 不在表格中重复显示 brand(因为你在 name-cell 已经显示了)
|
||
cell: () => null,
|
||
},
|
||
];
|
||
// Right Table (Bidding Item List)
|
||
const biddingItemsTable = useTemplateRef<{
|
||
tableRef: HTMLTableElement;
|
||
tableApi: Table<BiddingItem>;
|
||
}>("biddingItemsTableRef");
|
||
const batchStartPriceInput = ref<number>(0);
|
||
|
||
const isSomeBiddingItemSelected = computed<boolean>(
|
||
() =>
|
||
(biddingItemsTable.value?.tableApi.getIsSomeRowsSelected() ||
|
||
biddingItemsTable.value?.tableApi.getIsAllRowsSelected()) ??
|
||
false
|
||
);
|
||
|
||
const biddingItemColumns: TableColumn<BiddingItem>[] = [
|
||
{
|
||
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.index + 1}`, // 动态行号,从 1 开始
|
||
},
|
||
{
|
||
accessorKey: "name",
|
||
header: "标品名称",
|
||
},
|
||
{
|
||
accessorKey: "startPrice",
|
||
header: "起拍价",
|
||
},
|
||
{
|
||
accessorKey: "remarks",
|
||
header: "备注",
|
||
},
|
||
{
|
||
id: "button",
|
||
header: "",
|
||
},
|
||
];
|
||
const biddingItemColumnPinning = ref({
|
||
left: [],
|
||
right: ["button"],
|
||
});
|
||
|
||
useSortable(".bidding-item-table-tbody", biddingItemList, {
|
||
animation: 150,
|
||
});
|
||
|
||
const applyStartPriceButtonClicked = () => {
|
||
const selectedBiddingItems: Row<BiddingItem>[] =
|
||
biddingItemsTable.value?.tableApi.getFilteredSelectedRowModel().rows ?? [];
|
||
selectedBiddingItems.forEach((r) => {
|
||
r.original.startPrice = batchStartPriceInput.value;
|
||
});
|
||
|
||
biddingItemsTable.value?.tableApi.resetRowSelection();
|
||
};
|
||
|
||
const editItemButtonClicked = (biddingItem: BiddingItem) => {
|
||
biddingItemEditModal.value?.openModal(biddingItem);
|
||
};
|
||
|
||
const deleteItemButtonClicked = (id: number) => {
|
||
removeBiddingItem(id);
|
||
};
|
||
|
||
function toUnderscore(name: string) {
|
||
return name.trim().toLowerCase().replace(/\s+/g, "_");
|
||
}
|
||
|
||
// Export Preview
|
||
const exportCsv = computed<string>(() => {
|
||
return [
|
||
...biddingItems.value.map(
|
||
(biddingItem) =>
|
||
`${biddingItem.name},${biddingItem.startPrice},${
|
||
biddingItem.remarks
|
||
},${toUnderscore(biddingItem.name)}`
|
||
),
|
||
].join("\n");
|
||
});
|
||
|
||
const downloadCsv = () => {
|
||
const blob = new Blob([exportCsv.value], { type: "text/csv;charset=utf-8;" });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = `物品清单-${new Date().toISOString().split("T")[0]}.csv`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
};
|
||
</script>
|
||
|
||
<style>
|
||
/* 阻止表头换行 */
|
||
.no-wrap-header thead th {
|
||
white-space: nowrap !important;
|
||
text-overflow: ellipsis;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|