add sku filter

This commit is contained in:
Timi
2026-09-25 22:07:58 +08:00
parent 1df745f646
commit b62e7a5766
5 changed files with 493 additions and 13 deletions
+121 -12
View File
@@ -31,13 +31,18 @@
</div> </div>
</template> </template>
</t-input> </t-input>
<t-dropdown-menu class="menus"> <t-dropdown-menu ref="filterMenuRef" class="menus">
<t-dropdown-item <t-dropdown-item
v-model="dateFilterType" v-model="dateFilterType"
:label="dateFilterLabel" :label="dateFilterLabel"
:options="dateOptions" :options="dateOptions"
@change="selectDateFilter" @change="selectDateFilter"
/> />
<t-dropdown-item
:label="skuFilterLabel"
:options="[]"
@open="openSkuFilter"
/>
<t-dropdown-item <t-dropdown-item
v-model="status" v-model="status"
label="履约状态" label="履约状态"
@@ -53,16 +58,32 @@
@scrolltolower="loadMore" @scrolltolower="loadMore"
> >
<div class="list-wrap"> <div class="list-wrap">
<t-cell-group v-if="salesOrder" class="order-info" theme="card"> <t-cell-group v-if="salesOrder || selectedSkuOptionList.length" class="filter-info" theme="card">
<t-cell title="订单号" :note="salesOrder.orderNo || salesOrder.id || '未知订单'" /> <template v-if="salesOrder">
<t-cell title="订单类型" :note="getSalesOrderTypeText(salesOrder.orderType)" /> <t-cell title="订单号" :note="salesOrder.orderNo || salesOrder.id || '未知订单'" />
<t-cell title="订单状态"> <t-cell title="订单类型" :note="getSalesOrderTypeText(salesOrder.orderType)" />
<t-cell title="订单状态">
<template #note>
<t-tag
:content="getSalesOrderStatusText(salesOrder.status)"
:theme="getStatusTheme(salesOrder.status)"
variant="light-outline"
/>
</template>
</t-cell>
</template>
<t-cell v-if="selectedSkuOptionList.length" class="sku-filter-cell" title="已筛选 SKU">
<template #note> <template #note>
<t-tag <div class="selected-sku-list">
:content="getSalesOrderStatusText(salesOrder.status)" <t-tag
:theme="getStatusTheme(salesOrder.status)" v-for="sku in selectedSkuOptionList"
variant="light-outline" :key="sku.skuId"
/> size="small"
theme="primary"
variant="outline"
:content="formatSelectedSkuName(sku.productName, sku.skuName)"
/>
</div>
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
@@ -96,6 +117,12 @@
</t-pull-down-refresh> </t-pull-down-refresh>
</div> </div>
<nav-bar-action-menu v-if="canCreate" :actions="actions" /> <nav-bar-action-menu v-if="canCreate" :actions="actions" />
<sku-filter-popup
v-model="skuFilterVisible"
:sku-id-list="selectedSkuIdList"
@options-loaded="handleSkuFilterOptionsLoaded"
@confirm="confirmSkuFilter"
/>
<customer-selector <customer-selector
v-model="customerId" v-model="customerId"
v-model:visible="customerSelectorVisible" v-model:visible="customerSelectorVisible"
@@ -137,6 +164,7 @@ import SalesOrderAPI from "@/api/SalesOrderAPI";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue"; import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue"; import OrderFulfillmentListCell from "@/pages/fulfillment/components/OrderFulfillmentListCell.vue";
import SkuFilterPopup from "@/pages/fulfillment/components/SkuFilterPopup.vue";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment"; import type { GaoFulfillmentStatus, OrderFulfillment } from "@/types/Fulfillment";
import type { NavBarAction } from "@/types/NavBarAction"; import type { NavBarAction } from "@/types/NavBarAction";
@@ -152,6 +180,7 @@ defineOptions({ name: "OrderFulfillmentList" });
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const filterMenuRef = ref<{ collapseMenu: () => void }>();
const navBarStore = useNavBarStore(); const navBarStore = useNavBarStore();
const userStore = useUserStore(); const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create)); const canCreate = computed(() => userStore.hasPermissions(permissionCodes.orderFulfillment.create));
@@ -172,6 +201,7 @@ const actions = computed<NavBarAction[]>(() => [
} }
]); ]);
type DateFilterType = "today" | "yesterday" | "thisWeek" | "thisMonth" | "lastMonth" | "all" | "custom"; type DateFilterType = "today" | "yesterday" | "thisWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
type SkuFilterOption = { skuId: string; productName: string; skuName: string };
const dateOptions: Array<{ label: string; value: DateFilterType }> = [ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "今天", value: "today" }, { label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" }, { label: "昨天", value: "yesterday" },
@@ -183,11 +213,14 @@ const dateOptions: Array<{ label: string; value: DateFilterType }> = [
]; ];
const customerId = ref(getQueryText(route.query.customerId)); const customerId = ref(getQueryText(route.query.customerId));
const selectedSkuIdList = ref(getSkuIdList(route.query.skuIds));
const selectedCustomer = ref<Customer>(); const selectedCustomer = ref<Customer>();
const salesOrder = ref<SalesOrder>(); const salesOrder = ref<SalesOrder>();
const skuFilterOptionList = ref<SkuFilterOption[]>([]);
const skuFilterVisible = ref(false);
const customerSelectorVisible = ref(false); const customerSelectorVisible = ref(false);
const customerSearchKeyword = ref(""); const customerSearchKeyword = ref("");
const dateFilterType = ref<DateFilterType>(parseDateFilterType(getQueryText(route.query.dateType), dateOptions, "today")); const dateFilterType = ref<DateFilterType>(parseDateFilterType(getQueryText(route.query.dateType), dateOptions, "all"));
const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value)); const dateRange = ref<[string, string]>(parseDateRange(dateFilterType.value));
const startDraft = ref(""); const startDraft = ref("");
const endDraft = ref(""); const endDraft = ref("");
@@ -211,12 +244,27 @@ const statusOptions = [
{ label: "已冲正", value: "REVOKED" } { label: "已冲正", value: "REVOKED" }
]; ];
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围"); const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "时间范围");
const selectedSkuOptionList = computed(() => {
const optionMap = new Map(skuFilterOptionList.value.map(option => [option.skuId, option]));
return selectedSkuIdList.value
.map(skuId => optionMap.get(skuId))
.filter((option): option is SkuFilterOption => !!option);
});
const skuFilterLabel = computed(() => selectedSkuIdList.value.length ? `SKU (${selectedSkuIdList.value.length})` : "产品 SKU");
const isCustomDate = computed(() => dateFilterType.value === "custom"); const isCustomDate = computed(() => dateFilterType.value === "custom");
function getQueryText(value: unknown): string { function getQueryText(value: unknown): string {
return Array.isArray(value) ? "" : typeof value === "string" ? value : ""; return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
} }
function getSkuIdList(value: unknown): string[] {
const queryValueList = Array.isArray(value) ? value : [value];
return [...new Set(queryValueList
.flatMap(item => typeof item === "string" ? item.split(",") : [])
.map(item => item.trim())
.filter(Boolean))];
}
function getInitialStatus(value: unknown): "ALL" | GaoFulfillmentStatus { function getInitialStatus(value: unknown): "ALL" | GaoFulfillmentStatus {
const statusValue = getQueryText(value); const statusValue = getQueryText(value);
if (["WAITING_STOCK", "PENDING", "VERIFIED", "REJECTED", "CANCELLED", "REVOKED"].includes(statusValue)) { if (["WAITING_STOCK", "PENDING", "VERIFIED", "REJECTED", "CANCELLED", "REVOKED"].includes(statusValue)) {
@@ -250,6 +298,7 @@ async function fetchList(reset: boolean): Promise<void> {
size: pageSize, size: pageSize,
salesOrderId: getQueryText(route.query.salesOrderId) || undefined, salesOrderId: getQueryText(route.query.salesOrderId) || undefined,
customerId: customerId.value || undefined, customerId: customerId.value || undefined,
skuIdList: selectedSkuIdList.value.length ? selectedSkuIdList.value : undefined,
beginAt, beginAt,
endAt, endAt,
status: status.value === "ALL" ? undefined : status.value status: status.value === "ALL" ? undefined : status.value
@@ -297,6 +346,29 @@ async function selectStatus(value: DropdownValue): Promise<void> {
} }
} }
async function openSkuFilter(): Promise<void> {
skuFilterVisible.value = true;
await nextTick();
filterMenuRef.value?.collapseMenu();
}
function handleSkuFilterOptionsLoaded(optionList: SkuFilterOption[]): void {
skuFilterOptionList.value = optionList;
}
async function confirmSkuFilter(skuIdList: string[]): Promise<void> {
selectedSkuIdList.value = skuIdList;
await replaceFilterQuery();
await reloadList();
}
function formatSelectedSkuName(productName: string, skuName: string) {
if (productName === skuName) {
return productName;
}
return `${productName}(${skuName})`;
}
async function handleCustomerChange(value: string | string[]): Promise<void> { async function handleCustomerChange(value: string | string[]): Promise<void> {
customerId.value = Array.isArray(value) ? String(value[0] || "") : value; customerId.value = Array.isArray(value) ? String(value[0] || "") : value;
try { try {
@@ -417,6 +489,7 @@ function buildFilterQuery(transitionDepth = getQueryText(route.query.transitionD
const query = { const query = {
salesOrderId: getQueryText(route.query.salesOrderId), salesOrderId: getQueryText(route.query.salesOrderId),
customerId: customerId.value, customerId: customerId.value,
skuIds: selectedSkuIdList.value.join(","),
dateType: dateFilterType.value, dateType: dateFilterType.value,
beginAt: dateRange.value[0] || "", beginAt: dateRange.value[0] || "",
endAt: dateRange.value[1] || "", endAt: dateRange.value[1] || "",
@@ -541,7 +614,7 @@ onUnmounted(() => navBarStore.clearRight());
--td-spacer-1: 6px; --td-spacer-1: 6px;
width: 100%; width: 100%;
border: 1px solid rgba(148, 163, 184, .18); border: 1px solid var(--td-component-border);
overflow: hidden; overflow: hidden;
margin-top: -1rem; margin-top: -1rem;
border-top: none; border-top: none;
@@ -556,6 +629,42 @@ onUnmounted(() => navBarStore.clearRight());
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.filter-info {
.selected-sku-list {
gap: .4rem;
width: 100%;
display: flex;
min-width: 0;
max-width: 100%;
overflow-x: auto;
justify-content: flex-start;
white-space: nowrap;
touch-action: pan-x;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar {
display: none;
}
:deep(.t-tag) {
flex: none;
}
}
.sku-filter-cell {
:deep(.t-cell__title) {
flex: 0 0 5rem;
}
:deep(.t-cell__note) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
}
}
}
.date-summary { .date-summary {
:deep(.t-cell__title) { :deep(.t-cell__title) {
@@ -0,0 +1,367 @@
<template>
<bottom-popup
v-model="popupVisible"
title="筛选产品 SKU"
height="min(80vh, 48rem)"
left-button-text="重置"
right-button-text="确定"
@left-button-click="resetSelection"
@right-button-click="confirmSelection"
>
<div class="sku-filter-popup">
<div class="filters glass">
<t-input
class="search"
v-model="keyword"
borderless
clearable
placeholder="搜索产品名称、拼音、编码或 SKU"
/>
</div>
<div v-if="singleSkuProductList.length" class="single-list">
<t-cell-group theme="card">
<t-cell
class="sku-cell"
v-for="product in singleSkuProductList"
:key="product.id"
@click="toggleSkuSelected(getSkuId(getProductSkuList(product)[0]))"
>
<template #title>
<div @click.stop>
<t-checkbox
:model-value="draftSkuIdList.includes(getSkuId(getProductSkuList(product)[0]))"
icon="line"
:block="false"
borderless
@update:model-value="checked => setSkuSelected(getSkuId(getProductSkuList(product)[0]), checked)"
>
<span v-text="product.name || '未命名产品'" />
</t-checkbox>
</div>
</template>
</t-cell>
</t-cell-group>
</div>
<t-collapse
v-if="multiSkuProductList.length"
v-model="expandedProductIdList"
class="multi-list"
:expand-mutex="false"
>
<t-collapse-panel
v-for="product in multiSkuProductList"
:key="product.id"
:value="product.id || ''"
class="collapse-panel"
>
<template #header>
<div class="header">
<div class="group-checkbox" @click.stop>
<t-checkbox
:model-value="0 < getVisibleSelectedSkuCount(product)"
:indeterminate="isProductPartiallySelected(product)"
icon="line"
:block="false"
borderless
@update:model-value="toggleProductSkuSelection(product)"
/>
</div>
<span class="clip-text" v-text="product.name || '未命名产品'" />
<span
v-if="getSelectedSkuCount(product)"
class="tips"
v-text="`已选 ${getSelectedSkuCount(product)} 个`"
/>
</div>
</template>
<t-cell-group theme="card" class="sku-choice-list">
<t-cell
v-for="sku in getVisibleProductSkuList(product)"
:key="sku.id"
class="sku-cell"
@click="toggleSkuSelected(getSkuId(sku))"
>
<template #title>
<div class="title">
<div @click.stop>
<t-checkbox
:model-value="draftSkuIdList.includes(getSkuId(sku))"
icon="line"
:block="false"
borderless
@update:model-value="checked => setSkuSelected(getSkuId(sku), checked)"
/>
</div>
<span class="clip-text" v-text="getSkuText(sku)" />
</div>
</template>
</t-cell>
</t-cell-group>
</t-collapse-panel>
</t-collapse>
<div
v-if="!singleSkuProductList.length && !multiSkuProductList.length"
class="center-tips"
v-text="loadingProducts ? '正在加载产品 SKU' : keyword ? '没有匹配的产品或 SKU' : '暂无可筛选的产品 SKU'"
/>
</div>
</bottom-popup>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import ProductAPI from "@/api/ProductAPI";
import type { Product, ProductSku } from "@/types/Product";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
defineOptions({ name: "SkuFilterPopup" });
type SkuFilterOption = { skuId: string; productName: string; skuName: string };
const props = defineProps<{
modelValue: boolean;
skuIdList: string[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
"options-loaded": [optionList: SkuFilterOption[]];
confirm: [skuIdList: string[]];
}>();
const popupVisible = computed({
get: () => props.modelValue,
set: value => emit("update:modelValue", value)
});
const draftSkuIdList = ref<string[]>([...props.skuIdList]);
const keyword = ref("");
const productList = ref<Product[]>([]);
const expandedProductIdList = ref<Array<string | number>>([]);
const loadingProducts = ref(false);
const normalizedKeyword = computed(() => keyword.value.trim().toLocaleLowerCase());
const filterableProductList = computed(() => productList.value.filter(product => !!product.id && 0 < getProductSkuList(product).length));
const singleSkuProductList = computed(() => filterableProductList.value.filter(product => getProductSkuList(product).length === 1 && matchesProductSearch(product)));
const multiSkuProductList = computed(() => filterableProductList.value.filter(product => 1 < getProductSkuList(product).length && matchesProductSearch(product)));
const skuFilterOptionList = computed<SkuFilterOption[]>(() => filterableProductList.value.flatMap(product => getProductSkuList(product).map(sku => ({
skuId: getSkuId(sku),
productName: product.name || "未命名产品",
skuName: getSkuText(sku)
}))));
watch(() => props.modelValue, visible => {
if (!visible) {
return;
}
draftSkuIdList.value = [...props.skuIdList];
expandSelectedProducts();
});
watch(() => props.skuIdList, skuIdList => {
if (!props.modelValue) {
draftSkuIdList.value = [...skuIdList];
}
}, { deep: true });
watch(normalizedKeyword, value => {
if (value) {
expandedProductIdList.value = multiSkuProductList.value.map(product => product.id || "");
}
});
function getProductSkuList(product: Product): ProductSku[] {
return (product.skuList || []).filter((sku): sku is ProductSku & { id: string } => !!sku.id);
}
function getSkuId(sku?: ProductSku): string {
return sku?.id || "";
}
function getSkuText(sku?: ProductSku): string {
return sku?.specSummary || sku?.name || "默认 SKU";
}
function matchesSearchText(value?: string | null): boolean {
return !!normalizedKeyword.value && !!value?.toLocaleLowerCase().includes(normalizedKeyword.value);
}
function matchesProductName(product: Product): boolean {
return [
product.name,
product.namePinyinFull,
product.namePinyinInitial,
product.namePinyinMixed,
product.code
].some(matchesSearchText);
}
function matchesSku(sku: ProductSku): boolean {
return [sku.name, sku.specSummary, sku.code, sku.barcode].some(matchesSearchText);
}
function matchesProductSearch(product: Product): boolean {
return !normalizedKeyword.value
|| matchesProductName(product)
|| getProductSkuList(product).some(matchesSku);
}
function getVisibleProductSkuList(product: Product): ProductSku[] {
const skuList = getProductSkuList(product);
if (!normalizedKeyword.value || matchesProductName(product)) {
return skuList;
}
return skuList.filter(matchesSku);
}
function getSelectedSkuCount(product: Product): number {
const skuIdSet = new Set(getProductSkuList(product).map(getSkuId));
return draftSkuIdList.value.filter(skuId => skuIdSet.has(skuId)).length;
}
function getVisibleSelectedSkuCount(product: Product): number {
const skuIdSet = new Set(getVisibleProductSkuList(product).map(getSkuId));
return draftSkuIdList.value.filter(skuId => skuIdSet.has(skuId)).length;
}
function isProductPartiallySelected(product: Product): boolean {
const selectedCount = getVisibleSelectedSkuCount(product);
return 0 < selectedCount && selectedCount < getVisibleProductSkuList(product).length;
}
function toggleProductSkuSelection(product: Product): void {
const skuIdList = getVisibleProductSkuList(product).map(getSkuId);
const skuIdSet = new Set(draftSkuIdList.value);
const shouldSelectAll = !skuIdList.every(skuId => skuIdSet.has(skuId));
for (const skuId of skuIdList) {
if (shouldSelectAll) {
skuIdSet.add(skuId);
} else {
skuIdSet.delete(skuId);
}
}
draftSkuIdList.value = [...skuIdSet];
}
function expandSelectedProducts(): void {
if (normalizedKeyword.value) {
expandedProductIdList.value = multiSkuProductList.value.map(product => product.id || "");
return;
}
expandedProductIdList.value = multiSkuProductList.value
.filter(product => getProductSkuList(product).some(sku => props.skuIdList.includes(getSkuId(sku))))
.map(product => product.id || "");
}
function setSkuSelected(skuId: string, checked: boolean): void {
if (!skuId) {
return;
}
const skuIdSet = new Set(draftSkuIdList.value);
if (checked) {
skuIdSet.add(skuId);
} else {
skuIdSet.delete(skuId);
}
draftSkuIdList.value = [...skuIdSet];
}
function toggleSkuSelected(skuId: string): void {
setSkuSelected(skuId, !draftSkuIdList.value.includes(skuId));
}
function resetSelection(): void {
draftSkuIdList.value = [];
}
function confirmSelection(): void {
emit("confirm", [...new Set(draftSkuIdList.value)]);
popupVisible.value = false;
}
async function loadFilterProducts(): Promise<void> {
loadingProducts.value = true;
try {
const result = await ProductAPI.list({ index: 0, size: 1000 });
productList.value = result.list || [];
emit("options-loaded", skuFilterOptionList.value);
expandSelectedProducts();
} catch (error) {
Message.error(getErrorMessage(error, "产品 SKU 筛选项加载失败"));
} finally {
loadingProducts.value = false;
}
}
onMounted(loadFilterProducts);
</script>
<style scoped lang="less">
.sku-filter-popup {
display: flex;
flex-direction: column;
:deep(.t-cell-group) {
margin: 0;
}
.filters {
top: 0;
z-index: 1;
padding: .5rem 1rem;
position: sticky;
.search {
border: 1px solid var(--td-component-border);
padding: .5rem 1rem;
border-radius: 1rem;
}
}
.single-list,
.multi-list {
margin: 0;
}
.multi-list {
border-top: 1px solid var(--td-component-stroke);
.collapse-panel {
:deep(.t-collapse-panel__content) {
padding: 0 0 0 1rem;
}
.header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
.group-checkbox {
flex: 0 0 auto;
}
}
}
}
.sku-choice-list {
margin: 0;
}
.sku-cell,
:deep(.t-collapse-panel__title) {
padding: .5rem;
.title {
gap: .125rem;
display: flex;
min-width: 0;
}
}
.tips {
color: var(--td-text-color-secondary);
font-size: .75rem;
margin-left: .5rem;
}
}
</style>
+1 -1
View File
@@ -5,7 +5,7 @@ function meta(title: string, depth = 3): RouteRecordRaw["meta"] {
} }
export default [ export default [
{ path: "/management/order-fulfillments", name: "OrderFulfillmentList", component: () => import("@/pages/fulfillment/OrderFulfillmentList.vue"), meta: meta("统一履约") }, { path: "/management/order-fulfillments", name: "OrderFulfillmentList", component: () => import("@/pages/fulfillment/OrderFulfillmentList.vue"), meta: meta("履约记录") },
{ path: "/management/order-fulfillments/create", name: "OrderFulfillmentCreate", component: () => import("@/pages/fulfillment/OrderFulfillmentCreate.vue"), meta: meta("创建履约单", 4) }, { path: "/management/order-fulfillments/create", name: "OrderFulfillmentCreate", component: () => import("@/pages/fulfillment/OrderFulfillmentCreate.vue"), meta: meta("创建履约单", 4) },
{ path: "/management/order-fulfillments/:id", name: "OrderFulfillmentDetail", component: () => import("@/pages/fulfillment/OrderFulfillmentDetail.vue"), meta: meta("履约单详情", 4) } { path: "/management/order-fulfillments/:id", name: "OrderFulfillmentDetail", component: () => import("@/pages/fulfillment/OrderFulfillmentDetail.vue"), meta: meta("履约单详情", 4) }
] as RouteRecordRaw[]; ] as RouteRecordRaw[];
+1
View File
@@ -52,6 +52,7 @@ export type FulfillmentPage = {
storeId?: string; storeId?: string;
salesOrderId?: string; salesOrderId?: string;
customerId?: string; customerId?: string;
skuIdList?: string[];
status?: GaoFulfillmentStatus; status?: GaoFulfillmentStatus;
beginAt?: number; beginAt?: number;
endAt?: number; endAt?: number;
+3
View File
@@ -82,6 +82,9 @@ export type Product = {
categoryId?: string; categoryId?: string;
code: string; code: string;
name: string; name: string;
namePinyinFull?: string;
namePinyinInitial?: string;
namePinyinMixed?: string;
productType: ProductType; productType: ProductType;
stockEnabled: boolean; stockEnabled: boolean;
status?: ProductStatus; status?: ProductStatus;