Files
bid-setup.tootaio.com/app/pages/export/index.vue
xiaomai 802c4460a7 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.
2025-10-20 17:33:48 +08:00

350 lines
11 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="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>
/* 阻止表头换行 */
.no-wrap-header thead th {
white-space: nowrap !important;
text-overflow: ellipsis;
overflow: hidden;
}
</style>