more filter for sales order

This commit is contained in:
Timi
2026-09-20 15:43:19 +08:00
parent 2aa29da3ff
commit 2fbd4c9fd0
9 changed files with 1337 additions and 180 deletions
@@ -0,0 +1,49 @@
/** 过滤项支持的值类型。 */
export type FilterValue = string | number | boolean;
/** 一个过滤组的选中值。 */
export type FilterValueList = FilterValue[];
/** 过滤组的选项控件类型。 */
export type FilterControl = "checkbox" | "radio";
/** 过滤弹窗的值模型。 */
export type FilterModel = Record<string, FilterValueList>;
/** 过滤项配置。 */
export interface FilterOption {
label: string;
value: FilterValue;
disabled?: boolean;
}
/** 过滤值变化上下文。 */
export interface FilterChangeContext {
group: FilterGroup;
groupKey: string;
value: FilterValueList;
previousValue: FilterValueList;
changedValue?: FilterValue;
checked?: boolean;
modelValue: FilterModel;
}
/** 过滤值变化回调返回值。数组用于替换当前组,对象用于修改一个或多个组。 */
export type FilterChangeResult = FilterValueList | Partial<FilterModel> | void;
/** 过滤值变化回调。 */
export type FilterChangeHandler = (context: FilterChangeContext) => FilterChangeResult;
/** 过滤组配置。 */
export interface FilterGroup {
key: string;
title: string;
description?: string;
options: FilterOption[];
control?: FilterControl;
multiple?: boolean;
columns: number;
readonly?: boolean;
readonlyText?: string;
onChange?: FilterChangeHandler;
}
+319
View File
@@ -0,0 +1,319 @@
<template>
<bottom-popup
v-model="popupVisible"
:title="props.title"
:height="props.height"
:left-button-text="props.leftButtonText"
:right-button-text="props.rightButtonText"
:z-index="props.zIndex"
:overlay-props="props.overlayProps"
@left-button-click="reset"
@right-button-click="confirm"
>
<div class="filter-popup bg-white">
<slot name="before" />
<section v-for="group in props.groups" :key="group.key" class="section">
<div class="title">
<strong v-text="group.title" />
<span v-if="group.description" v-text="group.description" />
</div>
<slot :name="`group-${group.key}-before`" :group="group" :value="getGroupValue(group)" />
<div v-if="group.readonly" class="fixed-filter">
<t-icon name="layers" />
<span v-text="group.readonlyText || '当前条件已固定'" />
</div>
<t-radio-group
v-else-if="group.control === 'radio' && group.options.length"
:model-value="getSingleGroupValue(group)"
class="radio-options"
:style="{ gridTemplateColumns: `repeat(${group.columns}, minmax(0, 1fr))` }"
@change="value => changeRadioGroup(group, value)"
>
<t-radio
v-for="option in group.options"
:key="String(option.value)"
class="radio-option"
:value="option.value"
:disabled="option.disabled"
:block="false"
borderless
>
<span v-text="option.label" />
</t-radio>
</t-radio-group>
<t-checkbox-group
v-else-if="group.options.length"
:model-value="getGroupValue(group)"
class="options"
:style="{ gridTemplateColumns: `repeat(${group.columns}, minmax(0, 1fr))` }"
@change="(value, context) => changeGroup(group, value, context)"
>
<t-checkbox
v-for="option in group.options"
:key="String(option.value)"
class="option"
:value="option.value"
:label="option.label"
:disabled="option.disabled"
icon="line"
borderless
/>
</t-checkbox-group>
<slot :name="`group-${group.key}`" :group="group" :value="getGroupValue(group)" />
<slot :name="`group-${group.key}-after`" :group="group" :value="getGroupValue(group)" />
</section>
<slot name="after" />
<slot />
</div>
</bottom-popup>
</template>
<script setup lang="ts">
import { BottomPopup } from "timi-tdesign-mobile";
import type {
FilterChangeContext,
FilterChangeHandler,
FilterChangeResult,
FilterGroup,
FilterModel,
FilterValue,
FilterValueList
} from "./FilterPopup";
defineOptions({ name: "FilterPopup" });
interface Props {
modelValue: FilterModel;
visible: boolean;
groups: FilterGroup[];
title?: string;
height?: string | number;
leftButtonText?: string;
rightButtonText?: string;
resetValue?: FilterModel;
zIndex?: number;
overlayProps?: Record<string, unknown>;
onFilterChange?: FilterChangeHandler;
}
const props = withDefaults(defineProps<Props>(), {
title: "筛选",
height: "auto",
leftButtonText: "重置",
rightButtonText: "确定"
});
const emit = defineEmits<{
"update:modelValue": [value: FilterModel];
"update:visible": [value: boolean];
change: [value: FilterModel, context: FilterChangeContext];
reset: [value: FilterModel];
confirm: [value: FilterModel];
}>();
const popupVisible = computed({
get: () => props.visible,
set: value => emit("update:visible", value)
});
function getGroupValue(group: FilterGroup): FilterValueList {
const value = props.modelValue[group.key] || [];
return group.multiple === false ? value.slice(-1) : value;
}
function getSingleGroupValue(group: FilterGroup): FilterValue | undefined {
return getGroupValue(group)[0];
}
function changeRadioGroup(group: FilterGroup, value: unknown): void {
changeGroup(group, isFilterValue(value) ? [value] : [], { current: value, type: "check" });
}
function changeGroup(group: FilterGroup, value: unknown, changeContext: unknown): void {
const previousValue = [...getGroupValue(group)];
const checkboxValue = toFilterValueList(value);
const contextValue = getChangedValue(changeContext);
const checked = getCheckedState(changeContext);
const nextValue = normalizeGroupValue(group, checkboxValue, contextValue, checked);
const nextModel = cloneFilterModel(props.modelValue);
nextModel[group.key] = nextValue;
const context: FilterChangeContext = {
group,
groupKey: group.key,
value: [...nextValue],
previousValue,
changedValue: contextValue,
checked,
modelValue: cloneFilterModel(nextModel)
};
const groupResult = group.onChange?.(context);
const groupModel = applyChangeResult(nextModel, group.key, groupResult);
const finalContext: FilterChangeContext = {
...context,
value: [...(groupModel[group.key] || [])],
modelValue: cloneFilterModel(groupModel)
};
const finalModel = applyChangeResult(groupModel, group.key, props.onFilterChange?.(finalContext));
emit("update:modelValue", finalModel);
emit("change", finalModel, finalContext);
}
function normalizeGroupValue(
group: FilterGroup,
value: FilterValueList,
changedValue?: FilterValue,
checked?: boolean
): FilterValueList {
if (group.multiple !== false || value.length < 2) {
return value;
}
if (checked !== false && changedValue !== undefined) {
return [changedValue];
}
return value.slice(-1);
}
function applyChangeResult(model: FilterModel, groupKey: string, result: FilterChangeResult): FilterModel {
if (!result) {
return model;
}
if (Array.isArray(result)) {
return { ...model, [groupKey]: toFilterValueList(result) };
}
const nextModel = cloneFilterModel(model);
for (const [key, value] of Object.entries(result)) {
if (Array.isArray(value)) {
nextModel[key] = toFilterValueList(value);
}
}
return nextModel;
}
function toFilterValueList(value: unknown): FilterValueList {
if (!Array.isArray(value)) {
return [];
}
return value.filter(isFilterValue);
}
function isFilterValue(value: unknown): value is FilterValue {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function getChangedValue(value: unknown): FilterValue | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const current = (value as { current?: unknown }).current;
return isFilterValue(current) ? current : undefined;
}
function getCheckedState(value: unknown): boolean | undefined {
if (!value || typeof value !== "object") {
return undefined;
}
const type = (value as { type?: unknown }).type;
return type === "check" ? true : type === "uncheck" ? false : undefined;
}
function cloneFilterModel(model: FilterModel): FilterModel {
return Object.fromEntries(Object.entries(model).map(([key, value]) => [key, toFilterValueList(value)]));
}
function createEmptyFilterModel(): FilterModel {
return Object.fromEntries(props.groups.map(group => [group.key, []]));
}
function reset(): void {
const value = cloneFilterModel(props.resetValue || createEmptyFilterModel());
emit("update:modelValue", value);
emit("reset", value);
}
function confirm(): void {
emit("confirm", cloneFilterModel(props.modelValue));
}
</script>
<style scoped lang="less">
:global(.bottom-popup:has(.filter-popup)) {
max-height: 86vh;
overflow-y: auto;
}
.filter-popup {
padding: 0 1rem 1rem;
.section {
padding: 1rem 0 0;
.title {
display: flex;
align-items: baseline;
margin-bottom: .75rem;
justify-content: space-between;
strong {
font-size: 1rem;
}
span {
color: var(--td-text-color-secondary);
font-size: .75rem;
}
}
}
.options,
.radio-options {
gap: .5rem;
display: grid;
padding-bottom: .25rem;
.option,
.radio-option {
margin: 0;
border: 1px solid var(--td-component-border);
padding: .5rem;
display: flex;
min-width: 0;
background: var(--td-bg-color-container);
border-radius: .75rem;
justify-content: start;
:deep(input) {
display: none;
}
:deep(.t-checkbox__label),
:deep(.t-radio__label) {
font-size: .75rem;
white-space: nowrap;
}
:deep(.t-radio__icon),
:deep(.t-checkbox__icon) {
margin-right: .25rem;
}
&:has(:deep(.t-checkbox__icon--checked)),
&:has(:deep(.t-radio__icon--checked)) {
color: var(--td-brand-color);
background: var(--td-brand-color-light);
border-color: var(--td-brand-color);
}
}
}
.fixed-filter {
gap: .5rem;
color: var(--td-brand-color);
display: flex;
padding: .75rem;
font-size: .8125rem;
background: var(--td-brand-color-light);
align-items: center;
border-radius: .5rem;
}
}
</style>
+1 -1
View File
@@ -16,7 +16,7 @@
v-for="(item, index) in result.list" v-for="(item, index) in result.list"
:key="item.id" :key="item.id"
class="item" class="item"
:title="item.product?.name || item.sku?.name || '未命名产品'" :title="item.product?.name + '长名称长名称长名称长名称长名称长名称长名称长名称' || item.sku?.name || '未命名产品'"
arrow arrow
v-entrance-transition="index * 30" v-entrance-transition="index * 30"
@click="toEdit(item)" @click="toEdit(item)"
+253 -158
View File
@@ -1,7 +1,7 @@
<template> <template>
<div v-can="permissionCodes.salesOrder.read" class="sales-order-list"> <div v-can="permissionCodes.salesOrder.read" class="sales-order-list">
<div class="filters"> <div class="filters">
<div class="search-status"> <div class="search-row">
<t-input <t-input
v-model.trim="keyword" v-model.trim="keyword"
class="keyword" class="keyword"
@@ -21,36 +21,19 @@
</div> </div>
</template> </template>
</t-input> </t-input>
<t-dropdown-menu class="status"> <t-button
<t-dropdown-item class="filter-button"
v-model="status" size="small"
label="订单状态" theme="light"
:options="statusOptions" variant="outline"
@change="selectStatus" @click="filterVisible = true"
/> >
</t-dropdown-menu> <template #icon>
<t-icon name="filter-1" />
</template>
<span>筛选</span>
</t-button>
</div> </div>
<t-dropdown-menu class="menus">
<t-dropdown-item
v-if="!packageConfigId"
v-model="orderType"
label="订单类型"
:options="orderTypeOptions"
@change="selectOrderType"
/>
<t-dropdown-item
v-model="paymentStatus"
label="收款状态"
:options="paymentOptions"
@change="selectPaymentStatus"
/>
<t-dropdown-item
v-model="refundStatus"
label="退款状态"
:options="refundOptions"
@change="selectRefundStatus"
/>
</t-dropdown-menu>
</div> </div>
<t-pull-down-refresh <t-pull-down-refresh
class="list-wrap" class="list-wrap"
@@ -60,6 +43,11 @@
@scrolltolower="loadMore" @scrolltolower="loadMore"
> >
<div class="items"> <div class="items">
<sales-order-summary
v-if="customerId"
ref="customerOrderSummaryRef"
:customer="selectedCustomer"
/>
<t-cell-group v-if="packageConfigId" class="selected-package" theme="card"> <t-cell-group v-if="packageConfigId" class="selected-package" theme="card">
<t-cell title="套餐" :note="selectedPackageName"> <t-cell title="套餐" :note="selectedPackageName">
<template #left-icon> <template #left-icon>
@@ -67,15 +55,16 @@
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
<sales-order-summary <sales-order-filter-summary
v-if="customerId" :filter="appliedFilter"
ref="customerOrderSummaryRef" :total="total"
:customer="selectedCustomer" :package-config-id="packageConfigId"
@confirm="confirmFilter"
/> />
<sales-order-list-cell <sales-order-list-cell
class="item"
v-for="(item, index) in list" v-for="(item, index) in list"
:key="item.id" :key="item.id"
class="item"
:order="item" :order="item"
:can-receive="canReceive" :can-receive="canReceive"
:can-fulfill="canCreateFulfillment" :can-fulfill="canCreateFulfillment"
@@ -101,10 +90,15 @@
v-model:visible="customerSelectorVisible" v-model:visible="customerSelectorVisible"
@change="handleCustomerChange" @change="handleCustomerChange"
/> />
<sales-order-filter-popup
v-model:visible="filterVisible"
:model-value="appliedFilter"
:package-config-id="packageConfigId"
@confirm="confirmFilter"
/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Toolkit } from "timi-web"; import { Toolkit } from "timi-web";
import { useUserStore } from "timi-web-api"; import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
@@ -112,19 +106,40 @@ import PackageAPI from "@/api/PackageAPI";
import SalesOrderAPI from "@/api/SalesOrderAPI"; 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 SalesOrderFilterPopup from "@/pages/salesOrder/components/SalesOrderFilterPopup.vue";
import SalesOrderFilterSummary from "@/pages/salesOrder/components/SalesOrderFilterSummary.vue";
import SalesOrderListCell from "@/pages/salesOrder/components/SalesOrderListCell.vue"; import SalesOrderListCell from "@/pages/salesOrder/components/SalesOrderListCell.vue";
import SalesOrderSummary from "@/pages/salesOrder/components/SalesOrderSummary.vue"; import SalesOrderSummary from "@/pages/salesOrder/components/SalesOrderSummary.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { GaoPackageConfig } from "@/types/Package"; import type { GaoPackageConfig } from "@/types/Package";
import type { GaoSalesOrderStatus, GaoSalesOrderType, GaoSalesPaymentStatus, GaoSalesRefundStatus, SalesOrder, SalesOrderPage } from "@/types/SalesOrder"; import type {
GaoSalesOrderStatus,
GaoSalesOrderType,
GaoSalesPaymentStatus,
GaoSalesRefundStatus,
SalesOrder,
SalesOrderPage
} from "@/types/SalesOrder";
import {
cloneSalesOrderFilter,
createDefaultSalesOrderFilter,
normalizeSalesOrderFilter,
type SalesOrderListFilter,
type SalesOrderPaymentFilter
} from "@/types/SalesOrderFilter";
import { buildPresetDateRange, parseDateTime } from "@/utils/date";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ name: "SalesOrderList" }); defineOptions({ name: "SalesOrderList" });
const SALES_ORDER_FILTER_STORAGE_KEY = "gao-customer-h5:sales-order-filter";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const currentStoreStore = useCurrentStoreStore();
const navBarStore = useNavBarStore(); const navBarStore = useNavBarStore();
const userStore = useUserStore(); const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.create)); const canCreate = computed(() => userStore.hasPermissions(permissionCodes.salesOrder.create));
@@ -150,19 +165,46 @@ const actions = computed(() => [
action: () => router.push("/management/customer-packages/create") action: () => router.push("/management/customer-packages/create")
}] : []) }] : [])
]); ]);
// ---------- 顶部搜索和客户 ----------
const keyword = ref(""); const keyword = ref("");
const customerId = ref(""); const customerId = ref("");
const selectedCustomer = ref<Customer>(); const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false); const customerSelectorVisible = ref(false);
let customerKeyword = ""; let customerKeyword = "";
const selectedPackage = ref<GaoPackageConfig>(); const selectedPackage = ref<GaoPackageConfig>();
const orderType = ref("ALL"); const packageConfigId = computed(() => queryValue(route.query.packageConfigId));
type PaymentFilter = "ALL" | GaoSalesPaymentStatus | "PENDING_RECEIVE"; const selectedPackageName = computed(() => selectedPackage.value?.name || queryValue(route.query.packageConfigName) || "未命名套餐");
type RefundFilter = "ALL" | GaoSalesRefundStatus;
// ---------- 筛选 ----------
const filterVisible = ref(false);
const appliedFilter = ref<SalesOrderListFilter>(createDefaultSalesOrderFilter());
const filterStorageKey = computed(() => `${SALES_ORDER_FILTER_STORAGE_KEY}:${currentStoreStore.currentStoreId || "default"}:${packageConfigId.value || "all"}`);
const orderTypeOptions: Array<{ label: string; value: GaoSalesOrderType }> = [
{ label: "套餐订单", value: "PACKAGE" },
{ label: "产品订单", value: "DIRECT_PRODUCT" }
];
const statusOptions: Array<{ label: string; value: GaoSalesOrderStatus }> = [
{ label: "进行中", value: "ACTIVE" },
{ label: "已履约", value: "FULFILLED" },
{ label: "已取消", value: "CANCELLED" }
];
const paymentOptions: Array<{ label: string; value: SalesOrderPaymentFilter }> = [
{ label: "未收清", value: "PENDING_RECEIVE" },
{ label: "未收款", value: "UNPAID" },
{ label: "部分收款", value: "PARTIALLY_PAID" },
{ label: "已收款", value: "PAID" }
];
const paymentStatusValues: GaoSalesPaymentStatus[] = ["UNPAID", "PARTIALLY_PAID", "PAID"];
const refundOptions: Array<{ label: string; value: GaoSalesRefundStatus }> = [
{ label: "无需退款", value: "NOT_REQUIRED" },
{ label: "待退款", value: "PENDING" },
{ label: "已退款", value: "REFUNDED" }
];
// ---------- 列表 ----------
const status = ref("ALL");
const paymentStatus = ref<PaymentFilter>("ALL");
const refundStatus = ref<RefundFilter>("ALL");
type SalesOrderSummaryExpose = { refresh: () => Promise<void> }; type SalesOrderSummaryExpose = { refresh: () => Promise<void> };
const customerOrderSummaryRef = ref<SalesOrderSummaryExpose>(); const customerOrderSummaryRef = ref<SalesOrderSummaryExpose>();
const list = ref<SalesOrder[]>([]); const list = ref<SalesOrder[]>([]);
@@ -172,30 +214,8 @@ const loading = ref(false);
const refreshing = ref(false); const refreshing = ref(false);
const noMore = ref(false); const noMore = ref(false);
const pageSize = 15; const pageSize = 15;
const orderTypeOptions = [
{ label: "全部类型", value: "ALL" }, // ---------- 路由和持久化 ----------
{ label: "套餐订单", value: "PACKAGE" },
{ label: "产品订单", value: "DIRECT_PRODUCT" }
];
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "进行中", value: "ACTIVE" },
{ label: "已履约", value: "FULFILLED" },
{ label: "已取消", value: "CANCELLED" }
];
const paymentOptions = [
{ label: "全部收款", value: "ALL" },
{ label: "未收清", value: "PENDING_RECEIVE" },
{ label: "未收款", value: "UNPAID" },
{ label: "部分收款", value: "PARTIALLY_PAID" },
{ label: "已收款", value: "PAID" }
];
const refundOptions = [
{ label: "全部退款", value: "ALL" },
{ label: "无需退款", value: "NOT_REQUIRED" },
{ label: "待退款", value: "PENDING" },
{ label: "已退款", value: "REFUNDED" }
];
const routeFilterKey = computed(() => [ const routeFilterKey = computed(() => [
route.query.customerId, route.query.customerId,
@@ -207,21 +227,75 @@ const routeFilterKey = computed(() => [
route.query.refundStatus, route.query.refundStatus,
route.query.pendingReceive route.query.pendingReceive
].map(queryValue).join("|")); ].map(queryValue).join("|"));
let initialized = false;
let lastAppliedRouteFilterKey = "";
interface PersistedSalesOrderState {
filter: SalesOrderListFilter;
customerId: string;
}
function queryValue(value: unknown): string { function queryValue(value: unknown): string {
return Array.isArray(value) ? "" : typeof value === "string" ? value : ""; return Array.isArray(value) ? "" : typeof value === "string" ? value : "";
} }
const packageConfigId = computed(() => queryValue(route.query.packageConfigId)); function readPersistedState(): PersistedSalesOrderState {
const selectedPackageName = computed(() => selectedPackage.value?.name || queryValue(route.query.packageConfigName) || "未命名套餐"); try {
const value = localStorage.getItem(filterStorageKey.value);
const source: unknown = value ? JSON.parse(value) : undefined;
const filter = normalizeSalesOrderFilter(source);
if (filter.dateFilterType !== "custom") {
filter.dateRange = buildPresetDateRange(filter.dateFilterType);
}
const customerId = source && typeof source === "object" && typeof (source as { customerId?: unknown }).customerId === "string"
? (source as { customerId: string }).customerId
: "";
return { filter, customerId };
} catch {
return { filter: createDefaultSalesOrderFilter(), customerId: "" };
}
}
function persistFilter(): void {
try {
localStorage.setItem(filterStorageKey.value, JSON.stringify({
...appliedFilter.value,
customerId: customerId.value || undefined
}));
} catch {
return;
}
}
function applyRouteFilters(): void { function applyRouteFilters(): void {
const persistedState = readPersistedState();
const nextFilter = persistedState.filter;
customerId.value = queryValue(route.query.customerId) || persistedState.customerId;
const routeOrderType = queryValue(route.query.orderType);
const routeStatus = queryValue(route.query.status);
const routePaymentStatus = queryValue(route.query.paymentStatus);
const routeRefundStatus = queryValue(route.query.refundStatus);
const pendingReceive = queryValue(route.query.pendingReceive) === "true"; const pendingReceive = queryValue(route.query.pendingReceive) === "true";
customerId.value = queryValue(route.query.customerId); if (packageConfigId.value) {
orderType.value = packageConfigId.value ? "PACKAGE" : queryValue(route.query.orderType) || "ALL"; nextFilter.orderTypeList = ["PACKAGE"];
status.value = queryValue(route.query.status) || "ALL"; } else if (routeOrderType) {
paymentStatus.value = pendingReceive ? "PENDING_RECEIVE" : queryValue(route.query.paymentStatus) as PaymentFilter || "ALL"; nextFilter.orderTypeList = routeOrderType === "ALL" ? orderTypeOptions.map(item => item.value) : [routeOrderType as GaoSalesOrderType];
refundStatus.value = queryValue(route.query.refundStatus) as RefundFilter || "ALL"; }
if (routeStatus) {
nextFilter.statusList = routeStatus === "ALL" ? statusOptions.map(item => item.value) : [routeStatus as GaoSalesOrderStatus];
}
if (pendingReceive) {
nextFilter.statusList = ["ACTIVE", "FULFILLED"];
nextFilter.paymentStatusList = ["PENDING_RECEIVE"];
} else if (routePaymentStatus) {
nextFilter.paymentStatusList = routePaymentStatus === "PENDING_RECEIVE"
? ["PENDING_RECEIVE"]
: routePaymentStatus === "ALL" ? paymentOptions.map(item => item.value) : [routePaymentStatus as GaoSalesPaymentStatus];
}
if (routeRefundStatus) {
nextFilter.refundStatusList = routeRefundStatus === "ALL" ? refundOptions.map(item => item.value) : [routeRefundStatus as GaoSalesRefundStatus];
}
appliedFilter.value = nextFilter;
} }
async function loadRoutePackage(): Promise<void> { async function loadRoutePackage(): Promise<void> {
@@ -254,27 +328,34 @@ async function loadRouteCustomer(): Promise<void> {
setCustomerKeyword(selectedCustomer.value); setCustomerKeyword(selectedCustomer.value);
} }
async function handleCustomerChange(): Promise<void> { // ---------- 请求筛选 ----------
try {
await loadRouteCustomer(); function getEffectiveList<T>(value: T[], allValues: T[]): T[] | undefined {
await fetchList(true); return value.length && value.length < allValues.length ? value : undefined;
} catch (error) { }
selectedCustomer.value = undefined;
Message.error(getErrorMessage(error, "客户加载失败")); function getPaymentStatusValues(valueList: SalesOrderPaymentFilter[]): GaoSalesPaymentStatus[] {
const valueSet = new Set<GaoSalesPaymentStatus>(valueList.filter(value => value !== "PENDING_RECEIVE") as GaoSalesPaymentStatus[]);
if (valueList.includes("PENDING_RECEIVE")) {
valueSet.add("UNPAID");
valueSet.add("PARTIALLY_PAID");
} }
return paymentStatusValues.filter(value => valueSet.has(value));
} }
function buildPageFilter(): SalesOrderPage { function buildPageFilter(): SalesOrderPage {
const pendingReceive = paymentStatus.value === "PENDING_RECEIVE"; const filter = appliedFilter.value;
const [beginText, endText] = filter.dateRange;
return { return {
customerId: customerId.value || undefined, customerId: customerId.value || undefined,
packageConfigId: packageConfigId.value || undefined, packageConfigId: packageConfigId.value || undefined,
keyword: keyword.value || undefined, keyword: keyword.value || undefined,
orderType: orderType.value === "ALL" ? undefined : orderType.value as GaoSalesOrderType, orderTypeList: packageConfigId.value ? ["PACKAGE"] : getEffectiveList(filter.orderTypeList, orderTypeOptions.map(item => item.value)),
status: status.value === "ALL" ? undefined : status.value as GaoSalesOrderStatus, statusList: getEffectiveList(filter.statusList, statusOptions.map(item => item.value)),
paymentStatus: pendingReceive || paymentStatus.value === "ALL" ? undefined : paymentStatus.value as GaoSalesPaymentStatus, paymentStatusList: getEffectiveList(getPaymentStatusValues(filter.paymentStatusList), paymentStatusValues),
refundStatus: refundStatus.value === "ALL" ? undefined : refundStatus.value as GaoSalesRefundStatus, refundStatusList: getEffectiveList(filter.refundStatusList, refundOptions.map(item => item.value)),
pendingReceive: pendingReceive ? true : undefined, beginAt: beginText ? parseDateTime(beginText, false).getTime() : undefined,
endAt: endText ? parseDateTime(endText, true).getTime() : undefined,
index: pageIndex.value, index: pageIndex.value,
size: pageSize size: pageSize
}; };
@@ -282,7 +363,12 @@ function buildPageFilter(): SalesOrderPage {
async function fetchList(reset: boolean): Promise<void> { async function fetchList(reset: boolean): Promise<void> {
if (loading.value || (!reset && noMore.value)) return; if (loading.value || (!reset && noMore.value)) return;
if (reset) { pageIndex.value = 0; total.value = 0; noMore.value = false; list.value = []; } if (reset) {
pageIndex.value = 0;
total.value = 0;
noMore.value = false;
list.value = [];
}
loading.value = true; loading.value = true;
try { try {
const result = await SalesOrderAPI.list(buildPageFilter()); const result = await SalesOrderAPI.list(buildPageFilter());
@@ -291,13 +377,19 @@ async function fetchList(reset: boolean): Promise<void> {
total.value = result.total || 0; total.value = result.total || 0;
pageIndex.value += 1; pageIndex.value += 1;
noMore.value = rows.length < pageSize || total.value <= list.value.length; noMore.value = rows.length < pageSize || total.value <= list.value.length;
} finally { loading.value = false; } } finally {
loading.value = false;
}
}
async function reloadList(): Promise<void> {
await customerOrderSummaryRef.value?.refresh();
await fetchList(true);
} }
async function refresh(): Promise<void> { async function refresh(): Promise<void> {
try { try {
await customerOrderSummaryRef.value?.refresh(); await reloadList();
await fetchList(true);
} catch (error) { } catch (error) {
Message.error(getErrorMessage(error, "销售订单加载失败")); Message.error(getErrorMessage(error, "销售订单加载失败"));
} finally { } finally {
@@ -313,31 +405,26 @@ async function loadMore(): Promise<void> {
} }
} }
async function selectOrderType(value: DropdownValue): Promise<void> { async function confirmFilter(value: SalesOrderListFilter): Promise<void> {
if (!Array.isArray(value)) { appliedFilter.value = cloneSalesOrderFilter(value);
orderType.value = String(value); persistFilter();
await fetchList(true); try {
await reloadList();
} catch (error) {
Message.error(getErrorMessage(error, "销售订单加载失败"));
} }
} }
async function selectStatus(value: DropdownValue): Promise<void> { // ---------- 客户、路由和导航 ----------
if (!Array.isArray(value)) {
status.value = String(value);
await fetchList(true);
}
}
async function selectPaymentStatus(value: DropdownValue): Promise<void> { async function handleCustomerChange(): Promise<void> {
if (!Array.isArray(value)) { try {
paymentStatus.value = String(value) as PaymentFilter; await loadRouteCustomer();
await fetchList(true); persistFilter();
} await reloadList();
} } catch (error) {
selectedCustomer.value = undefined;
async function selectRefundStatus(value: DropdownValue): Promise<void> { Message.error(getErrorMessage(error, "客户加载失败"));
if (!Array.isArray(value)) {
refundStatus.value = String(value) as RefundFilter;
await fetchList(true);
} }
} }
@@ -372,6 +459,7 @@ const search = Toolkit.debounce(async () => {
Message.error(getErrorMessage(error, "销售订单加载失败")); Message.error(getErrorMessage(error, "销售订单加载失败"));
} }
}, false); }, false);
watch(keyword, async () => { watch(keyword, async () => {
const isCustomerKeyword = !!customerId.value && keyword.value.trim() === customerKeyword; const isCustomerKeyword = !!customerId.value && keyword.value.trim() === customerKeyword;
customerKeyword = ""; customerKeyword = "";
@@ -380,31 +468,67 @@ watch(keyword, async () => {
} }
customerId.value = ""; customerId.value = "";
selectedCustomer.value = undefined; selectedCustomer.value = undefined;
persistFilter();
await search(); await search();
}); });
watch(routeFilterKey, async () => {
watch(routeFilterKey, async value => {
if (!initialized || route.name !== "SalesOrderList" || value === lastAppliedRouteFilterKey) {
return;
}
lastAppliedRouteFilterKey = value;
applyRouteFilters(); applyRouteFilters();
try { try {
await loadRouteCustomer(); await loadRouteCustomer();
await loadRoutePackage();
await fetchList(true);
} catch { } catch {
selectedCustomer.value = undefined; selectedCustomer.value = undefined;
} }
await loadRoutePackage();
await fetchList(true);
}); });
watch(() => currentStoreStore.currentStoreId, async (value, oldValue) => {
if (!initialized || value === oldValue || route.name !== "SalesOrderList") {
return;
}
const persistedState = readPersistedState();
appliedFilter.value = persistedState.filter;
if (!queryValue(route.query.customerId)) {
customerId.value = persistedState.customerId;
}
await loadRouteCustomer();
await refresh();
});
onMounted(async () => { onMounted(async () => {
navBarStore.clearRight(); navBarStore.clearRight();
navBarStore.setCustomRightVisible(!!actions.value.length); navBarStore.setCustomRightVisible(!!actions.value.length);
applyRouteFilters(); applyRouteFilters();
lastAppliedRouteFilterKey = routeFilterKey.value;
try { try {
await loadRouteCustomer(); await loadRouteCustomer();
await loadRoutePackage(); await loadRoutePackage();
await fetchList(true); await fetchList(true);
initialized = true;
} catch (error) { } catch (error) {
Message.error(getErrorMessage(error, "销售订单加载失败")); Message.error(getErrorMessage(error, "销售订单加载失败"));
} }
}); });
onUnmounted(() => { navBarStore.clearRight(); search.cancel(); });
onActivated(() => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(!!actions.value.length);
});
onDeactivated(() => {
filterVisible.value = false;
navBarStore.clearRight();
});
onUnmounted(() => {
navBarStore.clearRight();
search.cancel();
});
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
@@ -416,23 +540,24 @@ onUnmounted(() => { navBarStore.clearRight(); search.cancel(); });
left: 0; left: 0;
right: 0; right: 0;
z-index: 10; z-index: 10;
display: flex; padding: .75rem 1rem .5rem;
padding: .75rem 1rem;
position: sticky; position: sticky;
background: #F5F5F8; background: var(--td-bg-color-page);
box-shadow: var(--td-shadow-1); box-shadow: var(--td-shadow-1);
flex-direction: column;
.search-status { .search-row {
gap: .5rem;
display: flex; display: flex;
align-items: center;
}
.keyword { .keyword {
--td-button-border-radius: .75rem; --td-button-border-radius: .75rem;
--td-input-vertical-padding: .25rem 1rem; --td-input-vertical-padding: .25rem 1rem;
flex: 1; flex: 1;
min-width: 0;
border: 1px solid var(--td-component-border); border: 1px solid var(--td-component-border);
z-index: 2;
background: var(--td-bg-color-container); background: var(--td-bg-color-container);
padding-right: .3rem; padding-right: .3rem;
border-radius: 1rem; border-radius: 1rem;
@@ -444,34 +569,12 @@ onUnmounted(() => { navBarStore.clearRight(); search.cancel(); });
} }
} }
.status { .filter-button {
--td-spacer: 4px; --td-button-border-radius: 99rem;
--td-spacer-1: 6px; flex: none;
height: auto;
border: 1px solid rgba(148, 163, 184, .18); padding: 0 1.25rem;
z-index: 1; align-self: stretch;
margin-left: -2.5rem;
padding-left: 3rem;
padding-right: .7rem;
border-radius: 1rem;
&:after {
display: none;
}
}
}
.menus {
--td-spacer-1: 6px;
width: 100%;
border: 1px solid rgba(148, 163, 184, .18);
overflow: hidden;
margin-top: -1rem;
background: #fff;
border-top: none;
padding-top: .8rem;
border-radius: 0 0 1rem 1rem;
} }
} }
@@ -484,13 +587,6 @@ onUnmounted(() => { navBarStore.clearRight(); search.cancel(); });
padding: 0 1rem; padding: 0 1rem;
flex-direction: column; flex-direction: column;
.selected-package {
:deep(.t-cell-group) {
margin: 0;
}
}
.item { .item {
border-radius: 1rem; border-radius: 1rem;
} }
@@ -503,5 +599,4 @@ onUnmounted(() => { navBarStore.clearRight(); search.cancel(); });
text-align: center; text-align: center;
} }
} }
</style> </style>
@@ -0,0 +1,353 @@
<template>
<filter-popup
v-model:visible="popupVisible"
:model-value="filterModel"
:reset-value="resetFilterModel"
:groups="visibleFilterGroups"
:title="popupTitle"
@update:model-value="updateFilterModel"
@reset="reset"
@confirm="confirm"
>
<template #group-date-after>
<t-cell-group
v-if="draftFilter.dateFilterType === 'custom'"
class="date-group"
>
<t-cell
class="date-cell"
title="开始时间"
:note="draftFilter.dateRange[0] || '请选择'"
arrow
@click="openStartTime"
/>
<t-cell
class="date-cell"
title="结束时间"
:note="draftFilter.dateRange[1] || '请选择'"
arrow
@click="openEndTime"
/>
</t-cell-group>
</template>
</filter-popup>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
:default-value="startDraft"
@cancel="cancelStartTime"
@confirm="confirmStartTime"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
:default-value="endDraft"
@cancel="cancelEndTime"
@confirm="confirmEndTime"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import FilterPopup from "@/components/filter-popup/FilterPopup.vue";
import type { FilterGroup, FilterModel, FilterValueList } from "@/components/filter-popup/FilterPopup";
import { buildPresetDateRange } from "@/utils/date";
import type { GaoSalesOrderStatus, GaoSalesOrderType, GaoSalesRefundStatus } from "@/types/SalesOrder";
import {
cloneSalesOrderFilter,
createDefaultSalesOrderFilter,
type SalesOrderDateFilterType,
type SalesOrderFilterGroupKey,
type SalesOrderListFilter,
type SalesOrderPaymentFilter
} from "@/types/SalesOrderFilter";
import { Message } from "@/utils/message";
interface Props {
modelValue: SalesOrderListFilter;
visible: boolean;
packageConfigId?: string;
filterKey?: SalesOrderFilterGroupKey;
}
const props = withDefaults(defineProps<Props>(), { packageConfigId: "" });
const emit = defineEmits<{
"update:visible": [value: boolean];
confirm: [value: SalesOrderListFilter];
}>();
const popupVisible = computed({
get: () => props.visible,
set: value => emit("update:visible", value)
});
const draftFilter = ref<SalesOrderListFilter>(cloneSalesOrderFilter(props.modelValue));
const startVisible = ref(false);
const endVisible = ref(false);
const startDraft = ref("");
const endDraft = ref("");
const dateOptions: Array<{ label: string; value: SalesOrderDateFilterType }> = [
{ label: "全部时间", value: "all" },
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "本周", value: "thisWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "最近 30 天", value: "last30" },
{ label: "自定义范围", value: "custom" }
];
const orderTypeOptions: Array<{ label: string; value: GaoSalesOrderType }> = [
{ label: "套餐订单", value: "PACKAGE" },
{ label: "产品订单", value: "DIRECT_PRODUCT" }
];
const statusOptions: Array<{ label: string; value: GaoSalesOrderStatus }> = [
{ label: "进行中", value: "ACTIVE" },
{ label: "已履约", value: "FULFILLED" },
{ label: "已取消", value: "CANCELLED" }
];
const paymentOptions: Array<{ label: string; value: SalesOrderPaymentFilter }> = [
{ label: "已收款", value: "PAID" },
{ label: "部分收款", value: "PARTIALLY_PAID" },
{ label: "未收清", value: "PENDING_RECEIVE" },
{ label: "未收款", value: "UNPAID" }
];
const refundOptions: Array<{ label: string; value: GaoSalesRefundStatus }> = [
{ label: "无需退款", value: "NOT_REQUIRED" },
{ label: "待退款", value: "PENDING" },
{ label: "已退款", value: "REFUNDED" }
];
const filterGroups = computed<FilterGroup[]>(() => [
{
key: "date",
title: "下单时间",
options: dateOptions,
control: "radio",
multiple: false,
columns: 2
},
{
key: "orderTypeList",
title: "订单类型",
description: props.packageConfigId ? "当前套餐固定" : "",
options: orderTypeOptions,
multiple: true,
columns: 2,
readonly: Boolean(props.packageConfigId),
readonlyText: "套餐订单(已由当前套餐固定)"
},
{
key: "statusList",
title: "订单状态",
options: statusOptions,
multiple: true,
columns: 3
},
{
key: "paymentStatusList",
title: "收款状态",
options: paymentOptions,
multiple: true,
columns: 2,
onChange: normalizePaymentFilter
},
{
key: "refundStatusList",
title: "退款状态",
options: refundOptions,
multiple: true,
columns: 2
}
]);
const visibleFilterGroups = computed(() => props.filterKey
? filterGroups.value.filter(group => group.key === props.filterKey)
: filterGroups.value);
const popupTitle = computed(() => {
if (!props.filterKey) {
return "筛选销售订单";
}
const group = visibleFilterGroups.value[0];
return group ? `筛选${group.title}` : "筛选销售订单";
});
const filterModel = computed<FilterModel>(() => ({
date: [draftFilter.value.dateFilterType],
orderTypeList: [...draftFilter.value.orderTypeList],
statusList: [...draftFilter.value.statusList],
paymentStatusList: [...draftFilter.value.paymentStatusList],
refundStatusList: [...draftFilter.value.refundStatusList]
}));
const resetFilterModel = computed<FilterModel>(() => {
if (!props.filterKey) {
return toFilterModel(createResetFilter());
}
const currentModel = toFilterModel(draftFilter.value);
const resetModel = toFilterModel(createResetFilter());
currentModel[props.filterKey] = resetModel[props.filterKey];
return currentModel;
});
watch(() => props.visible, visible => {
if (visible) {
draftFilter.value = cloneSalesOrderFilter(props.modelValue);
if (props.packageConfigId) {
draftFilter.value.orderTypeList = ["PACKAGE"];
}
}
});
function selectDateFilter(value: unknown): void {
const dateFilterType = String(value) as SalesOrderDateFilterType;
draftFilter.value.dateFilterType = dateFilterType;
if (dateFilterType !== "custom") {
draftFilter.value.dateRange = buildPresetDateRange(dateFilterType);
}
if (dateFilterType === "custom" && !draftFilter.value.dateRange[0] && !draftFilter.value.dateRange[1]) {
const today = Time.formatDate(new Date());
draftFilter.value.dateRange = [`${today} 00:00`, `${today} 23:59`];
}
}
function updateFilterModel(value: FilterModel): void {
const dateFilterType = getStringList<SalesOrderDateFilterType>(value.date)[0];
if (dateFilterType && dateFilterType !== draftFilter.value.dateFilterType) {
selectDateFilter(dateFilterType);
}
draftFilter.value.orderTypeList = getStringList<GaoSalesOrderType>(value.orderTypeList);
draftFilter.value.statusList = getStringList<GaoSalesOrderStatus>(value.statusList);
draftFilter.value.paymentStatusList = getStringList<SalesOrderPaymentFilter>(value.paymentStatusList);
draftFilter.value.refundStatusList = getStringList<GaoSalesRefundStatus>(value.refundStatusList);
}
function toFilterModel(filter: SalesOrderListFilter): FilterModel {
return {
date: [filter.dateFilterType],
orderTypeList: [...filter.orderTypeList],
statusList: [...filter.statusList],
paymentStatusList: [...filter.paymentStatusList],
refundStatusList: [...filter.refundStatusList]
};
}
function createResetFilter(): SalesOrderListFilter {
const filter = createDefaultSalesOrderFilter();
if (props.packageConfigId) {
filter.orderTypeList = ["PACKAGE"];
}
return filter;
}
function getStringList<T extends string>(value?: FilterValueList): T[] {
return (value || []).filter(item => typeof item === "string") as T[];
}
function normalizePaymentFilter(context: { value: FilterValueList; changedValue?: string | number | boolean; checked?: boolean }): FilterValueList {
const value = [...context.value];
if (context.checked && context.changedValue === "PENDING_RECEIVE") {
return value.filter(item => item !== "UNPAID" && item !== "PARTIALLY_PAID");
}
if (context.checked && (context.changedValue === "UNPAID" || context.changedValue === "PARTIALLY_PAID")) {
return value.filter(item => item !== "PENDING_RECEIVE");
}
return value;
}
function openStartTime(): void {
startDraft.value = draftFilter.value.dateRange[0] || `${Time.formatDate(new Date())} 00:00`;
startVisible.value = true;
}
function openEndTime(): void {
endDraft.value = draftFilter.value.dateRange[1] || `${Time.formatDate(new Date())} 23:59`;
endVisible.value = true;
}
function cancelStartTime(): void {
startVisible.value = false;
startDraft.value = draftFilter.value.dateRange[0];
}
function cancelEndTime(): void {
endVisible.value = false;
endDraft.value = draftFilter.value.dateRange[1];
}
function confirmStartTime(value?: string | number): void {
startVisible.value = false;
draftFilter.value.dateRange = [String(value || startDraft.value), draftFilter.value.dateRange[1]];
checkDateRangeOrder();
}
function confirmEndTime(value?: string | number): void {
endVisible.value = false;
draftFilter.value.dateRange = [draftFilter.value.dateRange[0], String(value || endDraft.value)];
checkDateRangeOrder();
}
function checkDateRangeOrder(): void {
if (draftFilter.value.dateRange[0] && draftFilter.value.dateRange[1] && draftFilter.value.dateRange[1] < draftFilter.value.dateRange[0]) {
draftFilter.value.dateRange = [draftFilter.value.dateRange[1], draftFilter.value.dateRange[0]];
}
draftFilter.value.dateFilterType = "custom";
}
function reset(): void {
const resetFilter = createResetFilter();
if (!props.filterKey) {
draftFilter.value = resetFilter;
return;
}
const nextFilter = cloneSalesOrderFilter(draftFilter.value);
resetFilterGroup(nextFilter, resetFilter, props.filterKey);
draftFilter.value = nextFilter;
}
function resetFilterGroup(target: SalesOrderListFilter, source: SalesOrderListFilter, filterKey: SalesOrderFilterGroupKey): void {
switch (filterKey) {
case "date":
target.dateFilterType = source.dateFilterType;
target.dateRange = [...source.dateRange];
break;
case "orderTypeList":
target.orderTypeList = [...source.orderTypeList];
break;
case "statusList":
target.statusList = [...source.statusList];
break;
case "paymentStatusList":
target.paymentStatusList = [...source.paymentStatusList];
break;
case "refundStatusList":
target.refundStatusList = [...source.refundStatusList];
break;
}
}
function confirm(): void {
if ((!props.filterKey || props.filterKey === "date")
&& draftFilter.value.dateFilterType === "custom"
&& (!draftFilter.value.dateRange[0] || !draftFilter.value.dateRange[1])) {
Message.warning("请选择完整的时间范围");
return;
}
emit("confirm", cloneSalesOrderFilter(draftFilter.value));
popupVisible.value = false;
}
</script>
<style scoped lang="less">
.date-group {
border: 1px solid var(--td-component-border);
overflow: auto;
margin-top: .25rem;
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
</style>
@@ -0,0 +1,250 @@
<template>
<t-cell-group class="filter-summary" theme="card">
<t-cell title="开始时间" :note="filterBeginAtText" arrow @click="openStartTime" />
<t-cell title="结束时间" :note="filterEndAtText" arrow @click="openEndTime" />
<t-cell title="状态" class="filter-cell" arrow @click="openFilterPopup('statusList')">
<template #note>
<div class="filter-tags">
<t-tag
v-for="item in selectedStatusTags"
:key="item"
theme="primary"
variant="light-outline"
:content="item"
/>
</div>
</template>
</t-cell>
<t-cell title="订单条件" class="filter-cell" arrow @click="openFilterPopup('orderTypeList')">
<template #note>
<div class="filter-tags">
<t-tag
v-for="item in selectedOrderTypeTags"
:key="item"
theme="primary"
variant="light-outline"
:content="item"
/>
</div>
</template>
</t-cell>
<t-cell title="资金条件" class="filter-cell" arrow @click="openFilterPopup('paymentStatusList')">
<template #note>
<div class="filter-tags">
<t-tag
v-for="item in selectedPaymentTags"
:key="item"
theme="primary"
variant="light-outline"
:content="item"
/>
</div>
</template>
</t-cell>
<t-cell title="退款条件" class="filter-cell" arrow @click="openFilterPopup('refundStatusList')">
<template #note>
<div class="filter-tags">
<t-tag
v-for="item in selectedRefundTags"
:key="item"
theme="primary"
variant="light-outline"
:content="item"
/>
</div>
</template>
</t-cell>
<t-cell title="订单数量" :note="String(total)" />
</t-cell-group>
<sales-order-filter-popup
v-model:visible="filterPopupVisible"
:model-value="props.filter"
:filter-key="activeFilterKey"
:package-config-id="props.packageConfigId"
@confirm="confirmFilter"
/>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
:default-value="startDraft"
@cancel="cancelStartTime"
@confirm="confirmStartTime"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
:default-value="endDraft"
@cancel="cancelEndTime"
@confirm="confirmEndTime"
/>
</t-popup>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import SalesOrderFilterPopup from "@/pages/salesOrder/components/SalesOrderFilterPopup.vue";
import type { GaoSalesOrderStatus, GaoSalesOrderType, GaoSalesRefundStatus } from "@/types/SalesOrder";
import {
cloneSalesOrderFilter,
type SalesOrderFilterGroupKey,
type SalesOrderListFilter,
type SalesOrderPaymentFilter
} from "@/types/SalesOrderFilter";
interface Props {
filter: SalesOrderListFilter;
total: number;
packageConfigId?: string;
}
defineOptions({ name: "SalesOrderFilterSummary" });
const props = defineProps<Props>();
const emit = defineEmits<{
confirm: [value: SalesOrderListFilter];
}>();
const orderTypeOptions: Array<{ label: string; value: GaoSalesOrderType }> = [
{ label: "套餐订单", value: "PACKAGE" },
{ label: "产品订单", value: "DIRECT_PRODUCT" }
];
const statusOptions: Array<{ label: string; value: GaoSalesOrderStatus }> = [
{ label: "进行中", value: "ACTIVE" },
{ label: "已履约", value: "FULFILLED" },
{ label: "已取消", value: "CANCELLED" }
];
const paymentOptions: Array<{ label: string; value: SalesOrderPaymentFilter }> = [
{ label: "未收清", value: "PENDING_RECEIVE" },
{ label: "未收款", value: "UNPAID" },
{ label: "部分收款", value: "PARTIALLY_PAID" },
{ label: "已收款", value: "PAID" }
];
const refundOptions: Array<{ label: string; value: GaoSalesRefundStatus }> = [
{ label: "无需退款", value: "NOT_REQUIRED" },
{ label: "待退款", value: "PENDING" },
{ label: "已退款", value: "REFUNDED" }
];
const filterBeginAtText = computed(() => props.filter.dateRange[0] || "不限");
const filterEndAtText = computed(() => props.filter.dateRange[1] || "不限");
const selectedStatusTags = computed(() => getSelectedOptionLabels(props.filter.statusList, statusOptions));
const selectedOrderTypeTags = computed(() => getSelectedOptionLabels(props.filter.orderTypeList, orderTypeOptions));
const selectedPaymentTags = computed(() => getSelectedOptionLabels(props.filter.paymentStatusList, paymentOptions));
const selectedRefundTags = computed(() => getSelectedOptionLabels(props.filter.refundStatusList, refundOptions));
const filterPopupVisible = ref(false);
const activeFilterKey = ref<SalesOrderFilterGroupKey>();
const startVisible = ref(false);
const endVisible = ref(false);
const startDraft = ref("");
const endDraft = ref("");
function getSelectedOptionLabels<T extends string>(valueList: T[], options: Array<{ label: string; value: T }>): string[] {
const labels = options.filter(item => valueList.includes(item.value)).map(item => item.label);
return labels.length ? labels : ["未选择"];
}
function openFilterPopup(filterKey: Exclude<SalesOrderFilterGroupKey, "date">): void {
activeFilterKey.value = filterKey;
filterPopupVisible.value = true;
}
function openStartTime(): void {
startDraft.value = props.filter.dateRange[0] || `${Time.formatDate(new Date())} 00:00`;
startVisible.value = true;
}
function openEndTime(): void {
endDraft.value = props.filter.dateRange[1] || `${Time.formatDate(new Date())} 23:59`;
endVisible.value = true;
}
function cancelStartTime(): void {
startVisible.value = false;
startDraft.value = props.filter.dateRange[0];
}
function cancelEndTime(): void {
endVisible.value = false;
endDraft.value = props.filter.dateRange[1];
}
function confirmStartTime(value?: string | number): void {
startVisible.value = false;
confirmDateRange(0, String(value || startDraft.value));
}
function confirmEndTime(value?: string | number): void {
endVisible.value = false;
confirmDateRange(1, String(value || endDraft.value));
}
function confirmDateRange(index: 0 | 1, value: string): void {
const nextFilter = cloneSalesOrderFilter(props.filter);
const nextRange = [...nextFilter.dateRange] as [string, string];
nextRange[index] = value;
if (nextRange[0] && nextRange[1] && nextRange[1] < nextRange[0]) {
nextFilter.dateRange = [nextRange[1], nextRange[0]];
} else {
nextFilter.dateRange = nextRange;
}
nextFilter.dateFilterType = "custom";
emit("confirm", nextFilter);
}
function confirmFilter(value: SalesOrderListFilter): void {
emit("confirm", value);
}
</script>
<style scoped lang="less">
.filter-summary {
gap: 1rem;
display: flex;
min-width: 0;
flex-direction: column;
:deep(.t-cell-group) {
margin: 0;
}
:deep(.t-cell__title) {
flex: 0 0 4.5rem;
margin-right: .75rem;
}
:deep(.t-cell__note) {
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.filter-cell {
.filter-tags {
gap: .4rem;
display: flex;
max-width: 100%;
overflow-x: auto;
justify-content: flex-start;
white-space: nowrap;
&::-webkit-scrollbar {
display: none;
}
:deep(.t-tag) {
flex: none;
}
}
}
}
</style>
+4
View File
@@ -78,9 +78,13 @@ export type SalesOrderPage = {
customerId?: string; customerId?: string;
packageConfigId?: string; packageConfigId?: string;
orderType?: GaoSalesOrderType; orderType?: GaoSalesOrderType;
orderTypeList?: GaoSalesOrderType[];
status?: GaoSalesOrderStatus; status?: GaoSalesOrderStatus;
statusList?: GaoSalesOrderStatus[];
paymentStatus?: GaoSalesPaymentStatus; paymentStatus?: GaoSalesPaymentStatus;
paymentStatusList?: GaoSalesPaymentStatus[];
refundStatus?: GaoSalesRefundStatus; refundStatus?: GaoSalesRefundStatus;
refundStatusList?: GaoSalesRefundStatus[];
pendingReceive?: boolean; pendingReceive?: boolean;
fulfilledButUnpaid?: boolean; fulfilledButUnpaid?: boolean;
keyword?: string; keyword?: string;
+79
View File
@@ -0,0 +1,79 @@
import type {
GaoSalesOrderStatus,
GaoSalesOrderType,
GaoSalesPaymentStatus,
GaoSalesRefundStatus
} from "@/types/SalesOrder";
import type { DateTimeRange } from "@/utils/date";
/** 销售订单时间筛选类型。 */
export type SalesOrderDateFilterType = "all" | "today" | "yesterday" | "thisWeek" | "thisMonth" | "last30" | "custom";
/** 销售订单资金筛选类型,未收清为未收款和部分收款的组合条件。 */
export type SalesOrderPaymentFilter = GaoSalesPaymentStatus | "PENDING_RECEIVE";
/** 销售订单筛选组。 */
export type SalesOrderFilterGroupKey = "date" | "orderTypeList" | "statusList" | "paymentStatusList" | "refundStatusList";
/** 销售订单列表筛选条件。 */
export type SalesOrderListFilter = {
dateFilterType: SalesOrderDateFilterType;
dateRange: DateTimeRange;
orderTypeList: GaoSalesOrderType[];
statusList: GaoSalesOrderStatus[];
paymentStatusList: SalesOrderPaymentFilter[];
refundStatusList: GaoSalesRefundStatus[];
};
/** 创建销售订单筛选默认值。 */
export function createDefaultSalesOrderFilter(): SalesOrderListFilter {
return {
dateFilterType: "all",
dateRange: ["", ""],
orderTypeList: ["PACKAGE", "DIRECT_PRODUCT"],
statusList: ["ACTIVE", "FULFILLED"],
paymentStatusList: ["UNPAID", "PARTIALLY_PAID", "PAID"],
refundStatusList: ["NOT_REQUIRED", "PENDING", "REFUNDED"]
};
}
/** 复制销售订单筛选条件。 */
export function cloneSalesOrderFilter(filter: SalesOrderListFilter): SalesOrderListFilter {
return {
dateFilterType: filter.dateFilterType,
dateRange: [...filter.dateRange] as DateTimeRange,
orderTypeList: [...filter.orderTypeList],
statusList: [...filter.statusList],
paymentStatusList: [...filter.paymentStatusList],
refundStatusList: [...filter.refundStatusList]
};
}
/** 将本地存储内容归一化为合法筛选条件。 */
export function normalizeSalesOrderFilter(value: unknown): SalesOrderListFilter {
const defaultFilter = createDefaultSalesOrderFilter();
if (!value || typeof value !== "object") {
return defaultFilter;
}
const source = value as Partial<SalesOrderListFilter>;
const dateFilterType = ["all", "today", "yesterday", "thisWeek", "thisMonth", "last30", "custom"].includes(source.dateFilterType || "")
? source.dateFilterType!
: defaultFilter.dateFilterType;
const dateRange = Array.isArray(source.dateRange) && 2 <= source.dateRange.length
? [String(source.dateRange[0] || ""), String(source.dateRange[1] || "")] as DateTimeRange
: defaultFilter.dateRange;
return {
dateFilterType,
dateRange,
orderTypeList: normalizeList(source.orderTypeList, defaultFilter.orderTypeList),
statusList: normalizeList(source.statusList, defaultFilter.statusList),
paymentStatusList: normalizeList(source.paymentStatusList, defaultFilter.paymentStatusList),
refundStatusList: normalizeList(source.refundStatusList, defaultFilter.refundStatusList)
};
}
function normalizeList<T>(value: unknown, fallback: T[]): T[] {
return Array.isArray(value) ? value.filter(item => typeof item === "string") as T[] : [...fallback];
}
+8
View File
@@ -5,6 +5,7 @@ export type DateFilterPreset =
| "all" | "all"
| "today" | "today"
| "yesterday" | "yesterday"
| "thisWeek"
| "lastWeek" | "lastWeek"
| "thisMonth" | "thisMonth"
| "lastMonth" | "lastMonth"
@@ -33,6 +34,13 @@ export function buildPresetDateRange(type: DateFilterPreset, now = new Date()):
return buildDayRange(lastWeekMonday, lastWeekSunday); return buildDayRange(lastWeekMonday, lastWeekSunday);
} }
if (type === "thisWeek") {
const current = getDateStart(now);
const day = current.getDay() || 7;
const thisWeekMonday = addDays(current, 1 - day);
return buildDayRange(thisWeekMonday, current);
}
if (type === "thisMonth") { if (type === "thisMonth") {
const current = new Date(now); const current = new Date(now);
const begin = new Date(current.getFullYear(), current.getMonth(), 1); const begin = new Date(current.getFullYear(), current.getMonth(), 1);