more filter for sales order
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user