Updates the iddingItems state when the sortable list is reordered to ensure the exported data reflects the visual order. Additionally, renames the xportCsv computed property to csvPreview for better clarity.
363 lines
11 KiB
Vue
363 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>{{ csvPreview }}</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,
|
||
onUpdate: () => {
|
||
biddingItems.value = [...biddingItemList.value];
|
||
},
|
||
});
|
||
|
||
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 csvPreview = computed<string>(() => {
|
||
return [
|
||
...biddingItems.value.map(
|
||
(biddingItem) =>
|
||
`${biddingItem.name},${biddingItem.startPrice},${
|
||
biddingItem.remarks
|
||
},${toUnderscore(biddingItem.name)}`
|
||
),
|
||
].join("\n");
|
||
});
|
||
|
||
const downloadCsv = () => {
|
||
const blob = new Blob([csvPreview.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>
|