add event option

This commit is contained in:
Timi
2026-08-30 23:36:59 +08:00
parent 82e2359857
commit f65c88920a
17 changed files with 1620 additions and 398 deletions
+12 -34
View File
@@ -1,18 +1,13 @@
<template>
<t-popup v-model="popupVisible" placement="bottom">
<div class="customer-selector bg-white">
<div class="title-bar">
<t-button variant="text" size="small" @click="cancelSelection">取消</t-button>
<h3 class="title">选择客户</h3>
<t-button
v-if="isMultiple"
theme="primary"
variant="text"
size="small"
@click="confirmSelection"
>确定</t-button>
<span v-else class="title-placeholder" />
</div>
<bottom-popup
v-model="popupVisible"
title="选择客户"
height="80vh"
:right-button-text="isMultiple ? '确定' : ''"
@left-button-click="cancelSelection"
@right-button-click="confirmSelection"
>
<div class="customer-selector">
<div class="search-wrapper">
<t-input
v-model.trim="keyword"
@@ -32,11 +27,12 @@
/>
</div>
</div>
</t-popup>
</bottom-popup>
</template>
<script setup lang="ts">
import { Scroller } from "timi-web";
import { BottomPopup } from "timi-tdesign-mobile";
import CustomerAPI from "@/api/CustomerAPI";
import CustomerList from "@/components/CustomerList.vue";
import type { Customer } from "@/types/Customer";
@@ -223,27 +219,9 @@ onUnmounted(() => {
<style scoped lang="less">
.customer-selector {
display: flex;
height: 80vh;
padding-top: 1rem;
border-radius: 1.25rem 1.25rem 0 0;
height: 100%;
flex-direction: column;
.title-bar {
padding: 0 1rem;
display: grid;
grid-template-columns: 4rem 1fr 4rem;
align-items: center;
}
.title {
margin: 0;
text-align: center;
}
.title-placeholder {
height: 1px;
}
.search-wrapper {
z-index: 1;
padding: 1rem 0 .5rem 0;
+216
View File
@@ -0,0 +1,216 @@
<template>
<div v-if="selectedEventList.length" class="event-option-selector" :class="{ readonly: props.readonly }">
<section v-for="event in selectedEventList" :key="event.id" class="groups">
<div v-if="1 < selectedEventList.length" v-text="event.name || '未命名事件'" />
<div v-for="group in event.optionGroupList || []" :key="group.id" class="group">
<div class="title">
<span v-text="group.name" />
<span class="gray tips" v-text="getGroupHint(group)" />
</div>
<div
class="options"
:role="group.selectionMode === 'SINGLE' ? 'radiogroup' : 'group'"
:aria-label="group.name || '选项组'"
>
<button
v-for="option in group.optionList"
:key="option.id"
type="button"
class="option bg-white"
:class="{
active: isOptionSelected(event, group, option),
readonly
}"
:role="group.selectionMode === 'SINGLE' ? 'radio' : 'checkbox'"
:aria-checked="isOptionSelected(event, group, option)"
:aria-disabled="props.readonly"
:disabled="props.disabled"
:tabindex="props.readonly ? -1 : undefined"
@click="toggleOption(event, group, option)"
>
<span v-text="option.label" />
</button>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import type { EventItem, EventOptionGroup, EventOptionSelectionMap } from "@/types/EventItem";
import { normalizeEventId, normalizeOptionId } from "@/utils/eventOption";
defineOptions({
name: "EventOptionSelector"
});
interface Props {
eventList: EventItem[];
selectedEventIdList: string[];
disabled?: boolean;
readonly?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
readonly: false
});
const selectionMap = defineModel<EventOptionSelectionMap>({required: true});
const selectedEventList = computed(() => props.selectedEventIdList
.map(eventId => props.eventList.find(item => normalizeEventId(item) === eventId))
.filter((item): item is EventItem => !!item && hasOptionGroup(item)));
function hasOptionGroup(event: EventItem): boolean {
return !!event.optionGroupList?.some(group => group.optionList?.length);
}
function getSelectedIdList(event: EventItem, group: EventOptionGroup): string[] {
return selectionMap.value[normalizeEventId(event)]?.[normalizeOptionId(group)] || [];
}
function isOptionSelected(event: EventItem, group: EventOptionGroup, option: EventOptionGroup["optionList"][number]): boolean {
const optionId = normalizeOptionId(option);
return !!optionId && getSelectedIdList(event, group).includes(optionId);
}
function toggleOption(event: EventItem, group: EventOptionGroup, option: EventOptionGroup["optionList"][number]): void {
const optionId = normalizeOptionId(option);
if (!optionId || props.disabled || props.readonly) {
return;
}
const selectedIdList = getSelectedIdList(event, group);
if (group.selectionMode === "SINGLE") {
if (selectedIdList.includes(optionId) && !group.required) {
updateSelection(event, group, []);
return;
}
updateSelection(event, group, optionId);
return;
}
updateSelection(
event,
group,
selectedIdList.includes(optionId)
? selectedIdList.filter(selectedId => selectedId !== optionId)
: [...selectedIdList, optionId]
);
}
function updateSelection(event: EventItem, group: EventOptionGroup, value: string | string[] | number): void {
const eventId = normalizeEventId(event);
const groupId = normalizeOptionId(group);
if (!eventId || !groupId || props.disabled || props.readonly) {
return;
}
const selectedIdList = Array.isArray(value) ? value.map(String) : value ? [String(value)] : [];
const eventSelectionMap = selectionMap.value[eventId] || {};
selectionMap.value[eventId] = {
...eventSelectionMap,
[groupId]: selectedIdList
};
}
function getGroupHint(group: EventOptionGroup): string {
const modeText = group.selectionMode === "MULTIPLE" ? "可多选" : "单选";
return group.required ? `${modeText},必选` : `${modeText},非必选`;
}
</script>
<style scoped lang="less">
.event-option-selector {
gap: .75rem;
display: flex;
border-radius: 1rem;
flex-direction: column;
.groups {
display: flex;
flex-direction: column;
.group {
gap: .75rem;
display: flex;
padding: .75rem .75rem .75rem 0;
position: relative;
border-radius: .75rem;
flex-direction: column;
&:not(:last-child):after {
content: "";
right: 0;
bottom: 0;
width: 100%;
height: 1px;
position: absolute;
background: var(--td-border-level-1-color);
}
.title {
gap: .5rem;
color: var(--td-gray-color-9);
display: flex;
align-items: center;
justify-content: space-between;
.tips {
font-size: .8125rem;
}
}
.options {
gap: 1rem;
display: flex;
flex-wrap: wrap;
min-width: 0;
.option {
font: inherit;
color: #AAA;
width: fit-content;
max-width: 100%;
border: none;
outline: 1px solid #BBB;
padding: .25rem .875rem;
min-width: 0;
white-space: normal;
overflow-wrap: anywhere;
transition: border-color .2s ease, color .2s ease;
appearance: none;
line-height: 1.5;
border-radius: .5rem;
&.active {
color: var(--td-brand-color);
outline: 2px solid var(--td-brand-color);
}
&.readonly {
color: var(--td-text-color-secondary);
border-color: var(--td-text-color-secondary);
}
&:disabled {
cursor: not-allowed;
opacity: .6;
}
}
}
}
}
}
:global(html.theme-dark) {
.event-option-selector {
.group {
background: rgba(15, 23, 42, .72);
}
.hint {
color: #94a3b8;
}
}
}
</style>