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>