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

@@ -0,0 +1,79 @@
<template>
<UModal v-model:open="open" title="编辑标品">
<template #body>
<UForm
@submit="onSubmit"
:state="biddingItemEditState"
:schema="biddingItemEditSchema"
>
<UFormField label="起拍价" name="startPrice">
<UInput
v-model.number="biddingItemEditState.startPrice"
type="number"
/>
</UFormField>
<UFormField label="备注" name="remarks">
<UTextarea v-model="biddingItemEditState.remarks" />
</UFormField>
<UButton type="submit" label="保存" color="primary" class="mt-4" />
</UForm>
</template>
</UModal>
</template>
<script lang="ts" setup>
import type { FormSubmitEvent } from "@nuxt/ui";
import * as z from "zod";
const biddingItemEditForm = ref();
const currentId = ref<number>(0);
const open = ref<boolean>(false);
const toast = useToast();
const { biddingItems, editBiddingItem } = useBiddingItems();
const biddingItemEditSchema = z.object({
startPrice: z.number().min(0, "起拍价不能少于 RM 0").default(0),
remarks: z.string().optional(),
});
type BiddingItemEditSchema = z.output<typeof biddingItemEditSchema>;
const biddingItemEditState = reactive<BiddingItem & BiddingItemEditSchema>({
id: 0,
name: "",
imageUrl: "",
startPrice: 0,
remarks: "",
});
const openModal = (biddingItem: BiddingItem) => {
Object.assign(biddingItemEditState, biddingItem); // 👈 一次性复制所有字段
currentId.value = biddingItem.id;
open.value = true;
};
const onSubmit = async (event: FormSubmitEvent<BiddingItemEditSchema>) => {
const updatedItem: BiddingItem = {
...biddingItemEditState, // 👈 这里直接复用已有字段
startPrice: event.data.startPrice,
remarks: event.data.remarks ?? "",
};
editBiddingItem(updatedItem);
toast.add({
title: "已保存修改",
description: `${updatedItem.name} 的信息已更新`,
color: "success",
});
open.value = false;
};
defineExpose({ openModal });
</script>
<style></style>

View File

@@ -100,7 +100,7 @@ const { utils, isNameAvailable, addItem, editItem, items } = useItemsStore();
const baseSchema = z.object({
brand: z.string().optional(),
name: z.string().min(1, "名称不能为空"),
imageUrl: z.string().nullable().optional(),
imageUrl: z.string().optional(),
description: z.string().nullable().optional(),
tags: z.string().nullable().optional(),
});
@@ -121,7 +121,7 @@ type ItemSchema = z.output<typeof baseSchema>;
const itemState = reactive<ItemSchema>({
brand: undefined,
name: "",
imageUrl: null,
imageUrl: "",
description: null,
tags: null,
});
@@ -134,7 +134,7 @@ const openModal = (item?: Item) => {
currentId.value = item.id;
itemState.brand = item.brand;
itemState.name = item.name;
itemState.imageUrl = item.imageUrl ?? null;
itemState.imageUrl = item.imageUrl;
itemState.description = item.description ?? null;
itemState.tags = item.tags?.join(", ") ?? null;
} else {
@@ -143,7 +143,7 @@ const openModal = (item?: Item) => {
itemState.brand = undefined;
currentId.value = null;
itemState.name = "";
itemState.imageUrl = null;
itemState.imageUrl = "";
itemState.description = null;
itemState.tags = null;
}

View File

@@ -0,0 +1,50 @@
import { createSharedComposable } from "@vueuse/core";
export type BiddingItem = {
id: number;
name: string;
startPrice: number;
remarks: string;
imageUrl?: string;
};
const _useBiddingItems = () => {
const biddingItems = ref<BiddingItem[]>([]);
const biddingItemsLatestId = computed(() => {
const arr = biddingItems.value ?? [];
if (arr.length === 0) return 1;
return Math.max(...arr.map((i) => i.id)) + 1;
});
const addBiddingItem = (item: Item, startPrice: number) => {
biddingItems.value.push({
id: biddingItemsLatestId.value,
name: `${item.brand} ${item.name}`,
startPrice: startPrice,
remarks: "",
imageUrl: item.imageUrl,
});
};
const removeBiddingItem = (id: number) => {
biddingItems.value = (biddingItems.value ?? []).filter((i) => i.id !== id);
};
const editBiddingItem = (biddingItem: BiddingItem) => {
const idx = (biddingItems.value ?? []).findIndex(
(i) => i.id === biddingItem.id
);
if (idx === -1) return;
const updatedBiddingItems = [...(biddingItems.value ?? [])];
updatedBiddingItems[idx] = {
...updatedBiddingItems[idx],
...biddingItem,
};
biddingItems.value = updatedBiddingItems;
};
return { biddingItems, addBiddingItem, removeBiddingItem, editBiddingItem };
};
export const useBiddingItems = createSharedComposable(_useBiddingItems);

View File

@@ -4,7 +4,7 @@ export type Item = {
id: number;
brand?: string;
name: string;
imageUrl?: string | null;
imageUrl?: string;
description?: string | null;
tags?: string[] | null;
createdAt?: Date;

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>

View File

@@ -12,6 +12,7 @@
"dependencies": {
"@nuxt/ui": "4.0.1",
"nuxt": "^4.1.3",
"sortablejs": "^1.15.6",
"typescript": "^5.9.3",
"vue": "^3.5.22",
"vue-router": "^4.5.1"

17
pnpm-lock.yaml generated
View File

@@ -10,10 +10,13 @@ importers:
dependencies:
'@nuxt/ui':
specifier: 4.0.1
version: 4.0.1(@babel/parser@7.28.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.8.1)(magicast@0.3.5)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))(zod@4.1.12)
version: 4.0.1(@babel/parser@7.28.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.8.1)(magicast@0.3.5)(sortablejs@1.15.6)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))(zod@4.1.12)
nuxt:
specifier: ^4.1.3
version: 4.1.3(@parcel/watcher@2.5.1)(@vue/compiler-sfc@3.5.22)(db0@0.3.4)(ioredis@5.8.1)(lightningcss@1.30.1)(magicast@0.3.5)(rollup@4.52.4)(terser@5.44.0)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(yaml@2.8.1)
sortablejs:
specifier: ^1.15.6
version: 1.15.6
typescript:
specifier: ^5.9.3
version: 5.9.3
@@ -3152,6 +3155,9 @@ packages:
smob@1.5.0:
resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==}
sortablejs@1.15.6:
resolution: {integrity: sha512-aNfiuwMEpfBM/CN6LY0ibyhxPfPbyFeBTYJKCvzkJ2GkUpazIt3H+QIPAMHwqQ7tMKaHz1Qj+rJJCqljnf4p3A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -4439,7 +4445,7 @@ snapshots:
transitivePeerDependencies:
- magicast
'@nuxt/ui@4.0.1(@babel/parser@7.28.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.8.1)(magicast@0.3.5)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))(zod@4.1.12)':
'@nuxt/ui@4.0.1(@babel/parser@7.28.4)(db0@0.3.4)(embla-carousel@8.6.0)(ioredis@5.8.1)(magicast@0.3.5)(sortablejs@1.15.6)(typescript@5.9.3)(vite@7.1.9(jiti@2.6.1)(lightningcss@1.30.1)(terser@5.44.0)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))(zod@4.1.12)':
dependencies:
'@ai-sdk/vue': 2.0.68(vue@3.5.22(typescript@5.9.3))(zod@4.1.12)
'@iconify/vue': 5.0.0(vue@3.5.22(typescript@5.9.3))
@@ -4456,7 +4462,7 @@ snapshots:
'@tanstack/vue-table': 8.21.3(vue@3.5.22(typescript@5.9.3))
'@unhead/vue': 2.0.19(vue@3.5.22(typescript@5.9.3))
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.3))
'@vueuse/integrations': 13.9.0(fuse.js@7.1.0)(vue@3.5.22(typescript@5.9.3))
'@vueuse/integrations': 13.9.0(fuse.js@7.1.0)(sortablejs@1.15.6)(vue@3.5.22(typescript@5.9.3))
colortranslator: 5.0.0
consola: 3.4.2
defu: 6.1.4
@@ -5292,13 +5298,14 @@ snapshots:
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
'@vueuse/integrations@13.9.0(fuse.js@7.1.0)(vue@3.5.22(typescript@5.9.3))':
'@vueuse/integrations@13.9.0(fuse.js@7.1.0)(sortablejs@1.15.6)(vue@3.5.22(typescript@5.9.3))':
dependencies:
'@vueuse/core': 13.9.0(vue@3.5.22(typescript@5.9.3))
'@vueuse/shared': 13.9.0(vue@3.5.22(typescript@5.9.3))
vue: 3.5.22(typescript@5.9.3)
optionalDependencies:
fuse.js: 7.1.0
sortablejs: 1.15.6
'@vueuse/metadata@10.11.1': {}
@@ -7237,6 +7244,8 @@ snapshots:
smob@1.5.0: {}
sortablejs@1.15.6: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21: