diff --git a/src/api/EventAPI.ts b/src/api/EventAPI.ts index 6cf219a..ec70ee8 100644 --- a/src/api/EventAPI.ts +++ b/src/api/EventAPI.ts @@ -1,6 +1,6 @@ import { axios } from "timi-web-api"; import type { Page, PageResult } from "timi-web-api"; -import type { EventItem } from "@/types/EventItem"; +import type { EventItem, EventOptionGroup } from "@/types/EventItem"; import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store"; const BASE_URI = "/gao/event"; @@ -83,6 +83,12 @@ async function update(event: EventItem): Promise { return await axios.post(`${BASE_URI}/update`, withCurrentStoreId(event)); } +async function updateOptionGroupList(eventId: string, optionGroupList: EventOptionGroup[]): Promise { + await axios.post(`${BASE_URI}/options/update`, optionGroupList, { + params: { id: eventId } + }); +} + async function sort(idList: string[]): Promise { return await axios.post(`${BASE_URI}/sort`, idList); } @@ -99,6 +105,7 @@ export default { detail, create, update, + updateOptionGroupList, sort, remove }; diff --git a/src/api/EventRecordAPI.ts b/src/api/EventRecordAPI.ts index f865559..53e6a62 100644 --- a/src/api/EventRecordAPI.ts +++ b/src/api/EventRecordAPI.ts @@ -1,6 +1,6 @@ import { axios } from "timi-web-api"; import type { PageResult } from "timi-web-api"; -import type { CustomerEventRankView, EventRecord, EventRecordPage, EventRecordPageResult, EventRecordRankPage } from "@/types/EventRecord"; +import type { CustomerEventRankView, EventOptionStat, EventRecord, EventRecordPage, EventRecordPageResult, EventRecordRankPage } from "@/types/EventRecord"; import type { Customer } from "@/types/Customer.ts"; import { withCurrentStoreId } from "@/utils/store"; @@ -48,6 +48,10 @@ async function eventRank(page: EventRecordRankPage): Promise { + return await axios.post(`${BASE_URI}/option/stat`, withCurrentStoreId(page)); +} + async function exportXSSF(page: EventRecordPage): Promise { return await axios.post(`${BASE_URI}/export`, withCurrentStoreId(page), { responseType: "blob" @@ -64,5 +68,6 @@ export default { update, verify, remove, - eventRank + eventRank, + optionStat }; diff --git a/src/assets/styles/global.less b/src/assets/styles/global.less index b5dd72e..d5460d4 100644 --- a/src/assets/styles/global.less +++ b/src/assets/styles/global.less @@ -49,12 +49,16 @@ html.theme-dark body { margin: 0 1rem; } +.form { + border-radius: 1rem; +} + .t-input--password { letter-spacing: 0; } .t-form__label { - color: #555; + color: var(--td-gray-color-9); flex: none; min-width: unset; white-space: nowrap; diff --git a/src/components/CustomerSelector.vue b/src/components/CustomerSelector.vue index 017d439..95e4de8 100644 --- a/src/components/CustomerSelector.vue +++ b/src/components/CustomerSelector.vue @@ -1,18 +1,13 @@ + + diff --git a/src/pages/event/EventDetail.vue b/src/pages/event/EventDetail.vue index 45d5f58..718bc66 100644 --- a/src/pages/event/EventDetail.vue +++ b/src/pages/event/EventDetail.vue @@ -46,6 +46,13 @@ + @@ -53,17 +60,48 @@ - + + + +
+ +
+
diff --git a/src/pages/event/component/EventRecordCell.vue b/src/pages/event/component/EventRecordCell.vue new file mode 100644 index 0000000..f8df31c --- /dev/null +++ b/src/pages/event/component/EventRecordCell.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/src/pages/home/QuickEntryPage.vue b/src/pages/home/QuickEntryPage.vue index 98e4bf5..1ecda20 100644 --- a/src/pages/home/QuickEntryPage.vue +++ b/src/pages/home/QuickEntryPage.vue @@ -71,6 +71,11 @@ :event-list="eventList" /> +
userStore.hasPermissions(perm const eventList = ref([]); const selectedEventIdList = ref([]); +const selectedOptionSelectionMap = reactive({}); const numberValueTextMap = reactive>({}); const selectedNumberEventList = computed(() => selectedEventIdList.value .map(eventId => eventList.value.find(item => item.id === eventId)) @@ -669,6 +677,7 @@ async function saveRecord(): Promise { name: customerName.value.trim(), eventRecordList: selectedEventIdList.value.map(eventId => ({ eventId, + selectedOptionGroupList: resolveRecordOptionGroupList(eventId), numberValue: resolveNumberValue(eventId), pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined, remark: note.value || "快速补录登记" @@ -720,6 +729,9 @@ function resetFormAfterSubmit(): void { for (const eventId of Object.keys(numberValueTextMap)) { delete numberValueTextMap[eventId]; } + for (const eventId of Object.keys(selectedOptionSelectionMap)) { + delete selectedOptionSelectionMap[eventId]; + } refreshInputTips(); } @@ -757,6 +769,9 @@ function validateForm(): string { if (!validateRequiredPhotoList()) { return ""; } + if (!validateEventOptionSelectionList()) { + return ""; + } if (!validateNumberValueList()) { return ""; } @@ -780,6 +795,26 @@ function validateRequiredPhotoList(): boolean { return true; } +function resolveRecordOptionGroupList(eventId: string) { + const event = eventList.value.find(item => item.id === eventId); + return event ? resolveSelectedOptionGroupList(event, selectedOptionSelectionMap) : []; +} + +function validateEventOptionSelectionList(): boolean { + for (const eventId of selectedEventIdList.value) { + const event = eventList.value.find(item => item.id === eventId); + if (!event) { + continue; + } + const optionError = validateEventOptionSelection(event, selectedOptionSelectionMap); + if (optionError) { + Message.warning(optionError); + return false; + } + } + return true; +} + // ---------- 初始化 ---------- onMounted(async () => { diff --git a/src/pages/home/QuickSignInPage.vue b/src/pages/home/QuickSignInPage.vue index c8ed6b3..9e84a1a 100644 --- a/src/pages/home/QuickSignInPage.vue +++ b/src/pages/home/QuickSignInPage.vue @@ -16,6 +16,11 @@
事件可多选,请注意勾选的登记事件
+ ([]); const eventList = ref([]); +const selectedOptionSelectionMap = reactive({}); const isEventLoading = ref(false); const eventLoadFailed = ref(false); const selectedEventList = computed(() => selectedEventIdList.value @@ -225,6 +233,26 @@ async function handleEventSelectionChange(eventIdList: string[]): Promise await loadSourceValueRule(); } +function resolveRecordOptionGroupList(eventId: string) { + const event = eventList.value.find(item => normalizeEventId(item) === eventId); + return event ? resolveSelectedOptionGroupList(event, selectedOptionSelectionMap) : []; +} + +function validateEventOptionSelectionList(): boolean { + for (const eventId of selectedEventIdList.value) { + const event = eventList.value.find(item => normalizeEventId(item) === eventId); + if (!event) { + continue; + } + const optionError = validateEventOptionSelection(event, selectedOptionSelectionMap); + if (optionError) { + Message.warning(optionError); + return false; + } + } + return true; +} + function syncStoredEventId(): void { const firstEventId = selectedEventIdList.value[0]; if (firstEventId) { @@ -449,6 +477,9 @@ async function doSubmit(): Promise { if (!validateRequiredPhotoList()) { return; } + if (!validateEventOptionSelectionList()) { + return; + } if (!validateNumberValueList()) { return; } @@ -463,6 +494,7 @@ async function doSubmit(): Promise { await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({ eventId, customerId: customer.id, + selectedOptionGroupList: resolveRecordOptionGroupList(eventId), pointValue: showSourceValueInput.value ? pointValue : undefined, numberValue: resolveNumberValue(eventId), remark: "客户快速扫码登记", @@ -476,6 +508,9 @@ async function doSubmit(): Promise { for (const eventId of Object.keys(numberValueTextMap)) { delete numberValueTextMap[eventId]; } + for (const eventId of Object.keys(selectedOptionSelectionMap)) { + delete selectedOptionSelectionMap[eventId]; + } recentRecordList.value = []; latestDecodedText = ""; scannerSessionKey.value += 1; diff --git a/src/types/EventItem.ts b/src/types/EventItem.ts index a3b79fd..f9de807 100644 --- a/src/types/EventItem.ts +++ b/src/types/EventItem.ts @@ -7,6 +7,28 @@ export type EventValueUnit = "CENT" | "COUNT"; export type EventLimitType = "NONE" | "DAILY_HALF_DAY" | "NATURAL_DAY" | "INTERVAL_HOUR" | "NATURAL_MONTH" | "TOTAL_LIMIT" | "CUSTOMER_TOTAL_LIMIT"; +export type EventOptionSelectionMode = "SINGLE" | "MULTIPLE"; + +export type EventOption = { + id?: string; + clientKey?: string; + label: string; + sort?: number; +}; + +export type EventOptionGroup = { + id?: string; + clientKey?: string; + eventId?: string; + name: string; + selectionMode: EventOptionSelectionMode; + required?: boolean; + sort?: number; + optionList: EventOption[]; +}; + +export type EventOptionSelectionMap = Record>; + export type EventItem = { storeId?: string; name: string; @@ -24,4 +46,5 @@ export type EventItem = { endAt?: number; sort?: number; roleCodeList?: string[]; + optionGroupList?: EventOptionGroup[]; } & Model; diff --git a/src/types/EventRecord.ts b/src/types/EventRecord.ts index 83374e1..d67d39e 100644 --- a/src/types/EventRecord.ts +++ b/src/types/EventRecord.ts @@ -1,6 +1,12 @@ import type { Attachment, BasePage, Model, PageResult, User } from "timi-web-api"; import type { Customer } from "@/types/Customer"; -import type { EventItem } from "@/types/EventItem"; +import type { EventItem, EventOption } from "@/types/EventItem"; + +export type EventRecordOptionGroup = { + groupId: string; + groupName: string; + selectedOptionList: EventOption[]; +}; import type { PointLedgerChangeType } from "@/types/Point"; export type EventRecord = { @@ -20,6 +26,7 @@ export type EventRecord = { registeredAt?: number; registeredDateValue?: number; businessDayNo?: number; + selectedOptionGroupList?: EventRecordOptionGroup[]; customer?: Customer; event?: EventItem; @@ -36,6 +43,7 @@ export type EventRecordListItemView = { customer?: Customer; eventId?: string; event?: EventItem; + selectedOptionGroupList?: EventRecordOptionGroup[]; registeredAt?: number; verificationStatus?: VerificationStatus; verifierUserId?: string; @@ -54,8 +62,17 @@ export type EventRecordPage = { keyword?: string; beginAt?: number; endAt?: number; + optionIdList?: string[]; } & BasePage; +export type EventOptionStat = { + groupId?: string; + groupName?: string; + optionId?: string; + optionLabel?: string; + recordCount?: number; +}; + export type EventRecordRankPage = { storeId?: string; customerId?: string; diff --git a/src/utils/eventOption.ts b/src/utils/eventOption.ts new file mode 100644 index 0000000..0fe7ce7 --- /dev/null +++ b/src/utils/eventOption.ts @@ -0,0 +1,147 @@ +import type { EventItem, EventOption, EventOptionGroup, EventOptionSelectionMap } from "@/types/EventItem"; +import type { EventRecordOptionGroup } from "@/types/EventRecord"; + +export function createEventOptionId(): string { + const uuid = globalThis.crypto?.randomUUID?.(); + return uuid || `${Date.now()}-${Math.random().toString(36).slice(2)}`; +} + +export function normalizeEventOptionGroupList(groupList?: EventOptionGroup[]): EventOptionGroup[] { + return (groupList || []).map(group => ({ + ...group, + name: group.name || "", + selectionMode: group.selectionMode || "SINGLE", + required: group.required ?? true, + optionList: (group.optionList || []).map(option => ({ + ...option, + label: option.label || "" + })) + })); +} + +export function prepareEventOptionGroupList(groupList?: EventOptionGroup[]): EventOptionGroup[] { + return normalizeEventOptionGroupList(groupList).map(group => ({ + id: group.id, + eventId: group.eventId, + name: group.name, + selectionMode: group.selectionMode, + required: group.required, + sort: group.sort, + optionList: group.optionList.map(option => ({ + id: option.id, + label: option.label, + sort: option.sort + })) + })); +} + +export function validateEventOptionGroupList(groupList?: EventOptionGroup[]): string | undefined { + const normalizedGroupList = normalizeEventOptionGroupList(groupList); + const groupNameSet = new Set(); + const groupIdSet = new Set(); + const optionIdSet = new Set(); + for (const group of normalizedGroupList) { + const groupName = group.name.trim(); + if (!groupName) { + return "选项组名称不能为空"; + } + if (groupNameSet.has(groupName)) { + return `选项组名称重复:${groupName}`; + } + groupNameSet.add(groupName); + const groupId = normalizeOptionId(group); + if (groupId && groupIdSet.has(groupId)) { + return "选项组标识重复,请重新添加"; + } + if (groupId) { + groupIdSet.add(groupId); + } + if (!group.optionList.length) { + return `请为${groupName}添加选项`; + } + const labelSet = new Set(); + for (const option of group.optionList) { + const label = option.label.trim(); + if (!label) { + return `${groupName}中存在空选项`; + } + if (labelSet.has(label)) { + return `${groupName}中的选项名称重复:${label}`; + } + labelSet.add(label); + const optionId = normalizeOptionId(option); + if (optionId && optionIdSet.has(optionId)) { + return "选项标识重复,请重新添加"; + } + if (optionId) { + optionIdSet.add(optionId); + } + } + } +} + +export function resolveSelectedOptionGroupList(event: EventItem, selectionMap: EventOptionSelectionMap): EventRecordOptionGroup[] { + const eventId = normalizeEventId(event); + if (!eventId) { + return []; + } + + const eventSelectionMap = selectionMap[eventId] || {}; + return normalizeEventOptionGroupList(event.optionGroupList) + .map(group => { + const groupId = normalizeOptionId(group); + const selectedIdSet = new Set(eventSelectionMap[groupId] || []); + return { + groupId, + groupName: group.name, + selectedOptionList: group.optionList.filter(option => selectedIdSet.has(normalizeOptionId(option))) + }; + }) + .filter(group => group.groupId && group.selectedOptionList.length); +} + +export function validateEventOptionSelection(event: EventItem, selectionMap: EventOptionSelectionMap): string | undefined { + const eventId = normalizeEventId(event); + if (!eventId) { + return; + } + + const eventSelectionMap = selectionMap[eventId] || {}; + for (const group of normalizeEventOptionGroupList(event.optionGroupList)) { + const groupId = normalizeOptionId(group); + const selectedIdList = [...new Set(eventSelectionMap[groupId] || [])]; + if (group.required && !selectedIdList.length) { + return `请选择${group.name || "当前选项组"}`; + } + if (group.selectionMode === "SINGLE" && 1 < selectedIdList.length) { + return `${group.name || "当前选项组"}只能选择一个选项`; + } + const optionIdSet = new Set(group.optionList.map(normalizeOptionId)); + if (selectedIdList.some(optionId => !optionIdSet.has(optionId))) { + return `${group.name || "当前选项组"}包含无效选项`; + } + } +} + +export function normalizeEventId(event: EventItem): string { + return event.id === undefined || event.id === null ? "" : String(event.id); +} + +export function normalizeOptionId(option: EventOption | EventOptionGroup): string { + return option.id === undefined || option.id === null ? "" : String(option.id); +} + +export function buildEventOptionSelectionMap( + eventId: string, + selectedGroupList?: EventRecordOptionGroup[] +): EventOptionSelectionMap { + const groupMap: Record = {}; + for (const group of selectedGroupList || []) { + if (group.groupId) { + groupMap[group.groupId] = (group.selectedOptionList || []) + .map(option => normalizeOptionId(option)) + .filter(Boolean); + } + } + return eventId ? {[eventId]: groupMap} : {}; +}