feat(export): implement export page for bidding items

Introduces a new export page for creating and managing bidding lists. Key features include: selecting items from a
master list, adding them to a bidding list, editing start price/remarks, batch price updates, and drag-and-drop
reordering. The final list can be previewed and exported as a CSV. This change adds the `useBiddingItems` composable and
the `sortablejs` dependency. Also refactors `imageUrl` to be a non-nullable string for type consistency.
This commit is contained in:
xiaomai
2025-10-20 17:33:48 +08:00
parent b00a130114
commit 802c4460a7
7 changed files with 487 additions and 12 deletions

View File

@@ -1,13 +1,349 @@
<template>
<div>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<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>
</main>
</div>
</template>
<script lang="ts" setup>
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>
</style>
/* 阻止表头换行 */
.no-wrap-header thead th {
white-space: nowrap !important;
text-overflow: ellipsis;
overflow: hidden;
}
</style>