add event option
This commit is contained in:
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
import { axios } from "timi-web-api";
|
import { axios } from "timi-web-api";
|
||||||
import type { Page, PageResult } 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";
|
import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
|
||||||
|
|
||||||
const BASE_URI = "/gao/event";
|
const BASE_URI = "/gao/event";
|
||||||
@@ -83,6 +83,12 @@ async function update(event: EventItem): Promise<void> {
|
|||||||
return await axios.post(`${BASE_URI}/update`, withCurrentStoreId(event));
|
return await axios.post(`${BASE_URI}/update`, withCurrentStoreId(event));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateOptionGroupList(eventId: string, optionGroupList: EventOptionGroup[]): Promise<void> {
|
||||||
|
await axios.post(`${BASE_URI}/options/update`, optionGroupList, {
|
||||||
|
params: { id: eventId }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function sort(idList: string[]): Promise<void> {
|
async function sort(idList: string[]): Promise<void> {
|
||||||
return await axios.post(`${BASE_URI}/sort`, idList);
|
return await axios.post(`${BASE_URI}/sort`, idList);
|
||||||
}
|
}
|
||||||
@@ -99,6 +105,7 @@ export default {
|
|||||||
detail,
|
detail,
|
||||||
create,
|
create,
|
||||||
update,
|
update,
|
||||||
|
updateOptionGroupList,
|
||||||
sort,
|
sort,
|
||||||
remove
|
remove
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { axios } from "timi-web-api";
|
import { axios } from "timi-web-api";
|
||||||
import type { PageResult } 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 type { Customer } from "@/types/Customer.ts";
|
||||||
import { withCurrentStoreId } from "@/utils/store";
|
import { withCurrentStoreId } from "@/utils/store";
|
||||||
|
|
||||||
@@ -48,6 +48,10 @@ async function eventRank(page: EventRecordRankPage): Promise<PageResult<Customer
|
|||||||
return await axios.post(`${BASE_URI}/rank`, withCurrentStoreId(page));
|
return await axios.post(`${BASE_URI}/rank`, withCurrentStoreId(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function optionStat(page: EventRecordPage): Promise<EventOptionStat[]> {
|
||||||
|
return await axios.post(`${BASE_URI}/option/stat`, withCurrentStoreId(page));
|
||||||
|
}
|
||||||
|
|
||||||
async function exportXSSF(page: EventRecordPage): Promise<Blob> {
|
async function exportXSSF(page: EventRecordPage): Promise<Blob> {
|
||||||
return await axios.post(`${BASE_URI}/export`, withCurrentStoreId(page), {
|
return await axios.post(`${BASE_URI}/export`, withCurrentStoreId(page), {
|
||||||
responseType: "blob"
|
responseType: "blob"
|
||||||
@@ -64,5 +68,6 @@ export default {
|
|||||||
update,
|
update,
|
||||||
verify,
|
verify,
|
||||||
remove,
|
remove,
|
||||||
eventRank
|
eventRank,
|
||||||
|
optionStat
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,12 +49,16 @@ html.theme-dark body {
|
|||||||
margin: 0 1rem;
|
margin: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.t-input--password {
|
.t-input--password {
|
||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.t-form__label {
|
.t-form__label {
|
||||||
color: #555;
|
color: var(--td-gray-color-9);
|
||||||
flex: none;
|
flex: none;
|
||||||
min-width: unset;
|
min-width: unset;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<t-popup v-model="popupVisible" placement="bottom">
|
<bottom-popup
|
||||||
<div class="customer-selector bg-white">
|
v-model="popupVisible"
|
||||||
<div class="title-bar">
|
title="选择客户"
|
||||||
<t-button variant="text" size="small" @click="cancelSelection">取消</t-button>
|
height="80vh"
|
||||||
<h3 class="title">选择客户</h3>
|
:right-button-text="isMultiple ? '确定' : ''"
|
||||||
<t-button
|
@left-button-click="cancelSelection"
|
||||||
v-if="isMultiple"
|
@right-button-click="confirmSelection"
|
||||||
theme="primary"
|
>
|
||||||
variant="text"
|
<div class="customer-selector">
|
||||||
size="small"
|
|
||||||
@click="confirmSelection"
|
|
||||||
>确定</t-button>
|
|
||||||
<span v-else class="title-placeholder" />
|
|
||||||
</div>
|
|
||||||
<div class="search-wrapper">
|
<div class="search-wrapper">
|
||||||
<t-input
|
<t-input
|
||||||
v-model.trim="keyword"
|
v-model.trim="keyword"
|
||||||
@@ -32,11 +27,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</t-popup>
|
</bottom-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Scroller } from "timi-web";
|
import { Scroller } from "timi-web";
|
||||||
|
import { BottomPopup } from "timi-tdesign-mobile";
|
||||||
import CustomerAPI from "@/api/CustomerAPI";
|
import CustomerAPI from "@/api/CustomerAPI";
|
||||||
import CustomerList from "@/components/CustomerList.vue";
|
import CustomerList from "@/components/CustomerList.vue";
|
||||||
import type { Customer } from "@/types/Customer";
|
import type { Customer } from "@/types/Customer";
|
||||||
@@ -223,27 +219,9 @@ onUnmounted(() => {
|
|||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.customer-selector {
|
.customer-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
height: 80vh;
|
height: 100%;
|
||||||
padding-top: 1rem;
|
|
||||||
border-radius: 1.25rem 1.25rem 0 0;
|
|
||||||
flex-direction: column;
|
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 {
|
.search-wrapper {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 1rem 0 .5rem 0;
|
padding: 1rem 0 .5rem 0;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -46,6 +46,13 @@
|
|||||||
<t-cell title="需要拍照" :note="event.requirePhoto ? '是' : '否'" />
|
<t-cell title="需要拍照" :note="event.requirePhoto ? '是' : '否'" />
|
||||||
<t-cell title="需要核销" :note="event.requireVerification ? '是' : '否'" />
|
<t-cell title="需要核销" :note="event.requireVerification ? '是' : '否'" />
|
||||||
<t-cell title="次数约束" :note="getLimitText(event)" />
|
<t-cell title="次数约束" :note="getLimitText(event)" />
|
||||||
|
<t-cell
|
||||||
|
title="登记可选项"
|
||||||
|
:note="`${(event?.optionGroupList || []).length} 个选项组`"
|
||||||
|
:arrow="hasOptionGroup"
|
||||||
|
:hover="hasOptionGroup"
|
||||||
|
@click="openOptionPopup"
|
||||||
|
/>
|
||||||
</t-cell-group>
|
</t-cell-group>
|
||||||
<t-cell-group v-entrance-transition="210" v-if="event" title="生效时间" theme="card">
|
<t-cell-group v-entrance-transition="210" v-if="event" title="生效时间" theme="card">
|
||||||
<t-cell title="起始时间" :note="formatDate(event.beginAt)" />
|
<t-cell title="起始时间" :note="formatDate(event.beginAt)" />
|
||||||
@@ -53,17 +60,48 @@
|
|||||||
<t-cell title="创建时间" :note="formatDate(event.createdAt, '未知时间')" />
|
<t-cell title="创建时间" :note="formatDate(event.createdAt, '未知时间')" />
|
||||||
<t-cell title="更新时间" :note="formatDate(event.updatedAt, '未知时间')" />
|
<t-cell title="更新时间" :note="formatDate(event.updatedAt, '未知时间')" />
|
||||||
</t-cell-group>
|
</t-cell-group>
|
||||||
<t-empty v-else :description="isLoading ? '正在加载登记事件' : '登记事件不存在'" />
|
<t-button
|
||||||
|
v-if="event && canDeleteEvent"
|
||||||
|
v-can="permissionCodes.event.delete"
|
||||||
|
class="cell-button"
|
||||||
|
theme="danger"
|
||||||
|
:loading="isRemoving"
|
||||||
|
:disabled="isRemoving"
|
||||||
|
@click="confirmRemoveEvent"
|
||||||
|
v-entrance-transition="280"
|
||||||
|
content="删除事件"
|
||||||
|
/>
|
||||||
|
<t-empty v-if="!event" :description="isLoading ? '正在加载登记事件' : '登记事件不存在'" />
|
||||||
</div>
|
</div>
|
||||||
|
<bottom-popup
|
||||||
|
v-model="optionPopupVisible"
|
||||||
|
title="登记可选项"
|
||||||
|
height="min(88vh, 48rem)"
|
||||||
|
left-button-text="关闭"
|
||||||
|
right-button-text=""
|
||||||
|
@left-button-click="closeOptionPopup"
|
||||||
|
>
|
||||||
|
<div v-if="event && hasOptionGroup" class="option-popup">
|
||||||
|
<event-option-selector
|
||||||
|
v-model="optionSelectionMap"
|
||||||
|
:event-list="[event]"
|
||||||
|
:selected-event-id-list="selectedEventIdList"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</bottom-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||||
|
import { BottomPopup } from "timi-tdesign-mobile";
|
||||||
import { Time, Text } from "timi-web";
|
import { Time, Text } from "timi-web";
|
||||||
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
|
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
|
||||||
import EventAPI from "@/api/EventAPI";
|
import EventAPI from "@/api/EventAPI";
|
||||||
|
import EventOptionSelector from "@/components/EventOptionSelector.vue";
|
||||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { EventItem, EventLimitType, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem";
|
import type { EventItem, EventLimitType, EventOptionSelectionMap, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem";
|
||||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
|
|
||||||
@@ -82,6 +120,7 @@ const defaultColor = "#2f88ff";
|
|||||||
// ---------- 权限与门店 ----------
|
// ---------- 权限与门店 ----------
|
||||||
|
|
||||||
const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.event.update));
|
const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.event.update));
|
||||||
|
const canDeleteEvent = computed(() => userStore.hasPermissions(permissionCodes.event.delete));
|
||||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||||
const storeName = computed(() => {
|
const storeName = computed(() => {
|
||||||
const storeId = event.value?.storeId;
|
const storeId = event.value?.storeId;
|
||||||
@@ -98,6 +137,15 @@ const storeName = computed(() => {
|
|||||||
const event = ref<EventItem>();
|
const event = ref<EventItem>();
|
||||||
const roleOptions = ref<Role[]>([]);
|
const roleOptions = ref<Role[]>([]);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
const isRemoving = ref(false);
|
||||||
|
const optionPopupVisible = ref(false);
|
||||||
|
const optionSelectionMap = reactive<EventOptionSelectionMap>({});
|
||||||
|
|
||||||
|
const hasOptionGroup = computed(() => !!event.value?.optionGroupList?.some(group => group.optionList?.length));
|
||||||
|
const selectedEventIdList = computed(() => {
|
||||||
|
const eventId = event.value?.id;
|
||||||
|
return eventId === undefined || eventId === null ? [] : [String(eventId)];
|
||||||
|
});
|
||||||
|
|
||||||
async function loadStoreScope(): Promise<void> {
|
async function loadStoreScope(): Promise<void> {
|
||||||
if (isGlobalManager.value) {
|
if (isGlobalManager.value) {
|
||||||
@@ -193,6 +241,58 @@ function formatDate(timestamp?: number, emptyText = "不限"): string {
|
|||||||
return timestamp === undefined || timestamp === null ? emptyText : Time.toDateTime(timestamp);
|
return timestamp === undefined || timestamp === null ? emptyText : Time.toDateTime(timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openOptionPopup(): void {
|
||||||
|
if (!hasOptionGroup.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
optionPopupVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeOptionPopup(): void {
|
||||||
|
optionPopupVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 删除 ----------
|
||||||
|
|
||||||
|
async function confirmRemoveEvent(): Promise<void> {
|
||||||
|
const eventId = String(route.params.id || "");
|
||||||
|
if (!eventId || !canDeleteEvent.value || isRemoving.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventName = event.value?.name || "未命名事件";
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "删除登记事件",
|
||||||
|
content: `确定删除登记事件 ${eventName} 吗?删除后无法恢复。`,
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "删除",
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
async onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
await removeEvent(eventId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEvent(eventId: string): Promise<void> {
|
||||||
|
if (isRemoving.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRemoving.value = true;
|
||||||
|
try {
|
||||||
|
await EventAPI.remove(eventId);
|
||||||
|
Message.success("登记事件已删除");
|
||||||
|
await router.replace("/management/registration-events");
|
||||||
|
} catch {
|
||||||
|
Message.error("登记事件删除失败");
|
||||||
|
} finally {
|
||||||
|
isRemoving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- 导航 ----------
|
// ---------- 导航 ----------
|
||||||
|
|
||||||
async function toEdit(): Promise<void> {
|
async function toEdit(): Promise<void> {
|
||||||
@@ -286,5 +386,14 @@ onUnmounted(() => {
|
|||||||
height: 1rem;
|
height: 1rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
width: calc(100% - 2rem);
|
||||||
|
margin: 0 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-popup {
|
||||||
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-can="pageAccess" class="registration-event-edit">
|
<div v-can="pageAccess" class="event-edit">
|
||||||
<t-form class="form" label-align="left" label-width="7rem" v-entrance-transition>
|
<t-form class="form" label-align="left" label-width="7rem" v-entrance-transition>
|
||||||
<t-form-item label="颜色">
|
<t-form-item label="颜色">
|
||||||
<t-button
|
<t-button
|
||||||
@@ -86,6 +86,7 @@
|
|||||||
<div class="picker-text" v-text="getStatusText(formModel.status)" />
|
<div class="picker-text" v-text="getStatusText(formModel.status)" />
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
</t-form>
|
</t-form>
|
||||||
|
<event-option-editor v-model="optionGroupList" />
|
||||||
<section class="roles" v-entrance-transition="70">
|
<section class="roles" v-entrance-transition="70">
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<span>可登记角色</span>
|
<span>可登记角色</span>
|
||||||
@@ -103,19 +104,6 @@
|
|||||||
</t-checkbox>
|
</t-checkbox>
|
||||||
</t-checkbox-group>
|
</t-checkbox-group>
|
||||||
</section>
|
</section>
|
||||||
<div v-if="!isCreateMode && canDeleteEvent" class="bottom-actions">
|
|
||||||
<t-button
|
|
||||||
v-can="deletePermissionCode"
|
|
||||||
class="delete bg-white"
|
|
||||||
theme="danger"
|
|
||||||
variant="outline"
|
|
||||||
:loading="isRemoving"
|
|
||||||
:disabled="isSaving || isRemoving"
|
|
||||||
@click="confirmRemoveEventItem"
|
|
||||||
>
|
|
||||||
删除事件
|
|
||||||
</t-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<t-popup v-model="colorPickerVisible" placement="bottom" @opened="handleColorPickerOpened">
|
<t-popup v-model="colorPickerVisible" placement="bottom" @opened="handleColorPickerOpened">
|
||||||
<div class="color-popup">
|
<div class="color-popup">
|
||||||
@@ -214,12 +202,13 @@
|
|||||||
import { Time } from "timi-web";
|
import { Time } from "timi-web";
|
||||||
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
|
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
|
||||||
import type { ColorObject } from "tdesign-mobile-vue";
|
import type { ColorObject } from "tdesign-mobile-vue";
|
||||||
import { DialogPlugin } from "tdesign-mobile-vue";
|
|
||||||
import EventAPI from "@/api/EventAPI";
|
import EventAPI from "@/api/EventAPI";
|
||||||
|
import EventOptionEditor from "@/pages/event/component/EventOptionEditor.vue";
|
||||||
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
import { useCurrentStoreStore } from "@/store/currentStoreStore";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { EventItem, EventLimitType, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem";
|
import type { EventItem, EventLimitType, EventOptionGroup, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem";
|
||||||
import { getErrorMessage } from "@/utils/error";
|
import { getErrorMessage } from "@/utils/error";
|
||||||
|
import { normalizeEventOptionGroupList, prepareEventOptionGroupList, validateEventOptionGroupList } from "@/utils/eventOption";
|
||||||
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
|
||||||
import { Message } from "@/utils/message";
|
import { Message } from "@/utils/message";
|
||||||
|
|
||||||
@@ -245,8 +234,6 @@ const moduleCode: ModuleCode = "GAO";
|
|||||||
const isCreateMode = computed(() => !routeId.value);
|
const isCreateMode = computed(() => !routeId.value);
|
||||||
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
|
||||||
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.event.create : permissionCodes.event.update);
|
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.event.create : permissionCodes.event.update);
|
||||||
const deletePermissionCode = permissionCodes.event.delete;
|
|
||||||
const canDeleteEvent = computed(() => userStore.hasPermissions(deletePermissionCode));
|
|
||||||
const pageAccess = computed(() => ({
|
const pageAccess = computed(() => ({
|
||||||
value: [permissionCodes.event.read, permissionCodes.user.read, savePermissionCode.value],
|
value: [permissionCodes.event.read, permissionCodes.user.read, savePermissionCode.value],
|
||||||
mode: "and" as const
|
mode: "and" as const
|
||||||
@@ -256,7 +243,7 @@ const pageAccess = computed(() => ({
|
|||||||
|
|
||||||
const roleOptions = ref<Role[]>([]);
|
const roleOptions = ref<Role[]>([]);
|
||||||
const isSaving = ref(false);
|
const isSaving = ref(false);
|
||||||
const isRemoving = ref(false);
|
const optionGroupList = ref<EventOptionGroup[]>([]);
|
||||||
const formModel = reactive({
|
const formModel = reactive({
|
||||||
id: "",
|
id: "",
|
||||||
storeId: "",
|
storeId: "",
|
||||||
@@ -392,7 +379,6 @@ const limitValuePlaceholder = computed(() => {
|
|||||||
}
|
}
|
||||||
return "请输入总次数上限";
|
return "请输入总次数上限";
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- 颜色 ----------
|
// ---------- 颜色 ----------
|
||||||
|
|
||||||
function openColorPicker(): void {
|
function openColorPicker(): void {
|
||||||
@@ -571,6 +557,11 @@ function validateForm(): boolean {
|
|||||||
Message.warning("请至少选择一个可登记角色");
|
Message.warning("请至少选择一个可登记角色");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
const optionError = validateEventOptionGroupList(optionGroupList.value);
|
||||||
|
if (optionError) {
|
||||||
|
Message.warning(optionError);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
|
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
|
||||||
Message.warning("请输入有效的约束数值");
|
Message.warning("请输入有效的约束数值");
|
||||||
return false;
|
return false;
|
||||||
@@ -642,12 +633,14 @@ async function saveEventItem(): Promise<void> {
|
|||||||
limitValue: resolveLimitValue(),
|
limitValue: resolveLimitValue(),
|
||||||
beginAt: resolveTimestamp(formModel.beginAtText),
|
beginAt: resolveTimestamp(formModel.beginAtText),
|
||||||
endAt: resolveTimestamp(formModel.endAtText),
|
endAt: resolveTimestamp(formModel.endAtText),
|
||||||
roleCodeList: [...formModel.roleCodeList]
|
roleCodeList: [...formModel.roleCodeList],
|
||||||
|
optionGroupList: isCreateMode.value ? prepareEventOptionGroupList(optionGroupList.value) : undefined
|
||||||
};
|
};
|
||||||
if (isCreateMode.value) {
|
if (isCreateMode.value) {
|
||||||
await EventAPI.create(payload);
|
await EventAPI.create(payload);
|
||||||
} else {
|
} else {
|
||||||
await EventAPI.update(payload);
|
await EventAPI.update(payload);
|
||||||
|
await EventAPI.updateOptionGroupList(formModel.id || routeId.value, prepareEventOptionGroupList(optionGroupList.value));
|
||||||
}
|
}
|
||||||
Message.success("登记事件已保存");
|
Message.success("登记事件已保存");
|
||||||
await router.replace("/management/registration-events");
|
await router.replace("/management/registration-events");
|
||||||
@@ -658,45 +651,6 @@ async function saveEventItem(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 删除 ----------
|
|
||||||
|
|
||||||
async function confirmRemoveEventItem(): Promise<void> {
|
|
||||||
const id = formModel.id || routeId.value;
|
|
||||||
if (!id || isRemoving.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const eventName = formModel.name.trim() || "未命名事件";
|
|
||||||
const dialog = DialogPlugin.confirm({
|
|
||||||
title: "删除登记事件",
|
|
||||||
content: `确定删除登记事件 ${eventName} 吗?删除后无法恢复。`,
|
|
||||||
cancelBtn: "取消",
|
|
||||||
confirmBtn: "删除",
|
|
||||||
onCancel() {
|
|
||||||
dialog.destroy();
|
|
||||||
},
|
|
||||||
async onConfirm() {
|
|
||||||
dialog.destroy();
|
|
||||||
await removeEventItem(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeEventItem(id: string): Promise<void> {
|
|
||||||
if (isRemoving.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isRemoving.value = true;
|
|
||||||
try {
|
|
||||||
await EventAPI.remove(id);
|
|
||||||
Message.success("登记事件已删除");
|
|
||||||
await router.replace("/management/registration-events");
|
|
||||||
} catch {
|
|
||||||
Message.error("登记事件删除失败");
|
|
||||||
} finally {
|
|
||||||
isRemoving.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 页面数据 ----------
|
// ---------- 页面数据 ----------
|
||||||
|
|
||||||
async function prepareStoreScope(): Promise<void> {
|
async function prepareStoreScope(): Promise<void> {
|
||||||
@@ -737,6 +691,7 @@ async function fillForm(): Promise<void> {
|
|||||||
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
|
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
|
||||||
roleCodeList: [...(current.roleCodeList || [])]
|
roleCodeList: [...(current.roleCodeList || [])]
|
||||||
});
|
});
|
||||||
|
optionGroupList.value = normalizeEventOptionGroupList(current.optionGroupList);
|
||||||
formModel.limitValueText = resolveLimitValueText(current.limitValue);
|
formModel.limitValueText = resolveLimitValueText(current.limitValue);
|
||||||
colorDraft.value = formModel.color;
|
colorDraft.value = formModel.color;
|
||||||
}
|
}
|
||||||
@@ -757,11 +712,13 @@ onUnmounted(() => navBarStore.clearRight());
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="less">
|
<style scoped lang="less">
|
||||||
.registration-event-edit {
|
.event-edit {
|
||||||
|
gap: 1rem;
|
||||||
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
border-radius: .75rem;
|
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -849,15 +806,6 @@ onUnmounted(() => navBarStore.clearRight());
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottom-actions {
|
|
||||||
gap: .75rem;
|
|
||||||
margin: 1rem;
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
.delete {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.picker-popup {
|
.picker-popup {
|
||||||
|
|||||||
@@ -33,6 +33,19 @@
|
|||||||
<t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" />
|
<t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" />
|
||||||
<t-cell title="备注" :note="Text.display(record.remark)" />
|
<t-cell title="备注" :note="Text.display(record.remark)" />
|
||||||
</t-cell-group>
|
</t-cell-group>
|
||||||
|
<card-container
|
||||||
|
v-if="recordOptionEvent"
|
||||||
|
description="登记选项"
|
||||||
|
v-entrance-transition="140"
|
||||||
|
>
|
||||||
|
<event-option-selector
|
||||||
|
class="option-selector"
|
||||||
|
:model-value="recordOptionSelectionMap"
|
||||||
|
:event-list="[recordOptionEvent]"
|
||||||
|
:selected-event-id-list="recordOptionEventIdList"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
</card-container>
|
||||||
<t-button
|
<t-button
|
||||||
v-if="canVerifyRecord && record?.verificationStatus === 'PENDING'"
|
v-if="canVerifyRecord && record?.verificationStatus === 'PENDING'"
|
||||||
class="verify-button cell-button bg-orange white"
|
class="verify-button cell-button bg-orange white"
|
||||||
@@ -69,7 +82,9 @@
|
|||||||
import { PassTimeLabel, Text } from "timi-web";
|
import { PassTimeLabel, Text } from "timi-web";
|
||||||
import { useUserStore, type Attachment } from "timi-web-api";
|
import { useUserStore, type Attachment } from "timi-web-api";
|
||||||
import EventRecordAPI from "@/api/EventRecordAPI";
|
import EventRecordAPI from "@/api/EventRecordAPI";
|
||||||
|
import CardContainer from "@/components/CardContainer.vue";
|
||||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
|
import EventOptionSelector from "@/components/EventOptionSelector.vue";
|
||||||
import EventRecordVerifyDialog from "@/components/EventRecordVerifyDialog.vue";
|
import EventRecordVerifyDialog from "@/components/EventRecordVerifyDialog.vue";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
@@ -77,6 +92,7 @@ import type { EventRecord } from "@/types/EventRecord";
|
|||||||
import type { PhotoItem } from "@/types/Photo";
|
import type { PhotoItem } from "@/types/Photo";
|
||||||
import type { PointLedgerChangeType } from "@/types/Point";
|
import type { PointLedgerChangeType } from "@/types/Point";
|
||||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||||
|
import { buildEventOptionSelectionMap } from "@/utils/eventOption";
|
||||||
import { formatEventNumberValue } from "@/utils/eventValue";
|
import { formatEventNumberValue } from "@/utils/eventValue";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
|
|
||||||
@@ -163,6 +179,15 @@ function getVerificationStatusText(status?: EventRecord["verificationStatus"]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pointLedgerChangeType = computed(() => record.value?.changeType || parsePointLedgerChangeType(route.query.changeType));
|
const pointLedgerChangeType = computed(() => record.value?.changeType || parsePointLedgerChangeType(route.query.changeType));
|
||||||
|
const recordOptionEvent = computed(() => {
|
||||||
|
const event = record.value?.event;
|
||||||
|
return event?.id && event.optionGroupList?.some(group => group.optionList?.length) ? event : undefined;
|
||||||
|
});
|
||||||
|
const recordOptionEventIdList = computed(() => recordOptionEvent.value?.id ? [String(recordOptionEvent.value.id)] : []);
|
||||||
|
const recordOptionSelectionMap = computed(() => buildEventOptionSelectionMap(
|
||||||
|
recordOptionEventIdList.value[0] || "",
|
||||||
|
record.value?.selectedOptionGroupList
|
||||||
|
));
|
||||||
|
|
||||||
function getPointValueText(value?: number | null, changeType?: PointLedgerChangeType): string {
|
function getPointValueText(value?: number | null, changeType?: PointLedgerChangeType): string {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
@@ -322,6 +347,10 @@ onUnmounted(() => {
|
|||||||
width: calc(100% - 2rem);
|
width: calc(100% - 2rem);
|
||||||
margin: 0 1rem;
|
margin: 0 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-selector {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(html.theme-dark) {
|
:global(html.theme-dark) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
:customer="selectedCustomer"
|
:customer="selectedCustomer"
|
||||||
v-entrance-transition
|
v-entrance-transition
|
||||||
/>
|
/>
|
||||||
|
<div v-if="isEditMode" class="edit-point-tip red">涉及积分变动的数据不能修改,已产生积分流水的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建,删除会自动回流积分。</div>
|
||||||
<event-selector
|
<event-selector
|
||||||
v-model="selectedEventIdList"
|
v-model="selectedEventIdList"
|
||||||
class="event-selector"
|
class="event-selector"
|
||||||
@@ -51,6 +52,13 @@
|
|||||||
:disabled="isEditMode"
|
:disabled="isEditMode"
|
||||||
required-mark
|
required-mark
|
||||||
/>
|
/>
|
||||||
|
<event-option-selector
|
||||||
|
class="option-selector"
|
||||||
|
v-model="selectedOptionSelectionMap"
|
||||||
|
:event-list="eventList"
|
||||||
|
:selected-event-id-list="selectedEventIdList"
|
||||||
|
:disabled="isSaving"
|
||||||
|
/>
|
||||||
<div v-if="!isEditMode && selectedNumberEventList.length" class="event-value-list">
|
<div v-if="!isEditMode && selectedNumberEventList.length" class="event-value-list">
|
||||||
<t-form-item
|
<t-form-item
|
||||||
v-for="item in selectedNumberEventList"
|
v-for="item in selectedNumberEventList"
|
||||||
@@ -68,7 +76,6 @@
|
|||||||
/>
|
/>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isEditMode" class="edit-point-tip">已产生积分的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建。</div>
|
|
||||||
<t-form-item v-if="showSourceValueInput" label="积分动作">
|
<t-form-item v-if="showSourceValueInput" label="积分动作">
|
||||||
<span class="picker-text" v-text="getPointActionText(sourceValueAction)" />
|
<span class="picker-text" v-text="getPointActionText(sourceValueAction)" />
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
@@ -202,15 +209,17 @@ import CodeScanner from "@/components/CodeScanner.vue";
|
|||||||
import CustomerList from "@/components/CustomerList.vue";
|
import CustomerList from "@/components/CustomerList.vue";
|
||||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import EventOptionSelector from "@/components/EventOptionSelector.vue";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
import EventSelector from "@/components/EventSelector.vue";
|
import EventSelector from "@/components/EventSelector.vue";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
import type { Customer } from "@/types/Customer";
|
import type { Customer } from "@/types/Customer";
|
||||||
import type { EventItem } from "@/types/EventItem";
|
import type { EventItem, EventOptionSelectionMap } from "@/types/EventItem";
|
||||||
import type { EventRecord } from "@/types/EventRecord";
|
import type { EventRecord } from "@/types/EventRecord";
|
||||||
import type { PointAction } from "@/types/Point";
|
import type { PointAction } from "@/types/Point";
|
||||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||||
|
import { buildEventOptionSelectionMap, resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/utils/eventOption";
|
||||||
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
|
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
|
||||||
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
|
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
@@ -240,7 +249,7 @@ type EditablePhoto = {
|
|||||||
const routeId = computed(() => String(route.params.id || ""));
|
const routeId = computed(() => String(route.params.id || ""));
|
||||||
const isEditMode = computed(() => !!routeId.value);
|
const isEditMode = computed(() => !!routeId.value);
|
||||||
const pageAccess = computed(() => isEditMode.value ? permissionCodes.eventRecord.update : permissionCodes.eventRecord.create);
|
const pageAccess = computed(() => isEditMode.value ? permissionCodes.eventRecord.update : permissionCodes.eventRecord.create);
|
||||||
const saveButtonText = computed(() => isEditMode.value ? "保存记录" : "提交");
|
const saveButtonText = computed(() => isEditMode.value ? "保存" : "提交");
|
||||||
|
|
||||||
// ---------- 客户 ----------
|
// ---------- 客户 ----------
|
||||||
|
|
||||||
@@ -406,6 +415,7 @@ function handleScanStatusChange(value: string): void {
|
|||||||
|
|
||||||
const eventList = ref<EventItem[]>([]);
|
const eventList = ref<EventItem[]>([]);
|
||||||
const selectedEventIdList = ref<string[]>([]);
|
const selectedEventIdList = ref<string[]>([]);
|
||||||
|
const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({});
|
||||||
const numberValueTextMap = reactive<Record<string, string>>({});
|
const numberValueTextMap = reactive<Record<string, string>>({});
|
||||||
const selectedNumberEventList = computed(() => selectedEventIdList.value
|
const selectedNumberEventList = computed(() => selectedEventIdList.value
|
||||||
.map(eventId => eventList.value.find(item => item.id === eventId))
|
.map(eventId => eventList.value.find(item => item.id === eventId))
|
||||||
@@ -432,6 +442,11 @@ function appendRecordEvent(record: EventRecord): void {
|
|||||||
eventList.value = [record.event, ...eventList.value];
|
eventList.value = [record.event, ...eventList.value];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveRecordOptionGroupList(eventId: string) {
|
||||||
|
const event = eventList.value.find(item => item.id === eventId);
|
||||||
|
return event ? resolveSelectedOptionGroupList(event, selectedOptionSelectionMap) : [];
|
||||||
|
}
|
||||||
|
|
||||||
function getNumberValuePlaceholder(event: EventItem): string {
|
function getNumberValuePlaceholder(event: EventItem): string {
|
||||||
return getEventNumberValuePlaceholder(event);
|
return getEventNumberValuePlaceholder(event);
|
||||||
}
|
}
|
||||||
@@ -629,6 +644,7 @@ async function submitRecord(): Promise<void> {
|
|||||||
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
|
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
|
||||||
eventId,
|
eventId,
|
||||||
customerIdList,
|
customerIdList,
|
||||||
|
selectedOptionGroupList: resolveRecordOptionGroupList(eventId),
|
||||||
remark: note.value || "客户现场登记",
|
remark: note.value || "客户现场登记",
|
||||||
registeredAt: buildRegisteredAt(),
|
registeredAt: buildRegisteredAt(),
|
||||||
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
|
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
|
||||||
@@ -646,6 +662,9 @@ async function submitRecord(): Promise<void> {
|
|||||||
for (const eventId of Object.keys(numberValueTextMap)) {
|
for (const eventId of Object.keys(numberValueTextMap)) {
|
||||||
delete numberValueTextMap[eventId];
|
delete numberValueTextMap[eventId];
|
||||||
}
|
}
|
||||||
|
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
|
||||||
|
delete selectedOptionSelectionMap[eventId];
|
||||||
|
}
|
||||||
await loadRecentRecordList();
|
await loadRecentRecordList();
|
||||||
await settingStore.playRegisterSuccessSound();
|
await settingStore.playRegisterSuccessSound();
|
||||||
Message.success("登记已提交");
|
Message.success("登记已提交");
|
||||||
@@ -664,6 +683,17 @@ function validateForm(): boolean {
|
|||||||
Message.warning("请至少选择一个登记事件");
|
Message.warning("请至少选择一个登记事件");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!validateRequiredPhotoList()) {
|
if (!validateRequiredPhotoList()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -698,9 +728,10 @@ async function updateRecord(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await EventRecordAPI.update({
|
await EventRecordAPI.update({
|
||||||
...editingRecord.value,
|
id: editingRecord.value.id,
|
||||||
customerId: editingRecord.value.customerId,
|
customerId: editingRecord.value.customerId,
|
||||||
eventId: editingRecord.value.eventId,
|
eventId: editingRecord.value.eventId,
|
||||||
|
selectedOptionGroupList: resolveRecordOptionGroupList(editingRecord.value.eventId),
|
||||||
remark: note.value,
|
remark: note.value,
|
||||||
registeredAt: editingRecord.value.registeredAt,
|
registeredAt: editingRecord.value.registeredAt,
|
||||||
attachmentList: buildPhotoAttachmentList()
|
attachmentList: buildPhotoAttachmentList()
|
||||||
@@ -749,6 +780,12 @@ async function loadRecordForEdit(): Promise<void> {
|
|||||||
customerCode.value = record.customer?.code || record.customerId || "";
|
customerCode.value = record.customer?.code || record.customerId || "";
|
||||||
selectedCustomerIdList.value = record.customer?.id ? [record.customer.id] : [];
|
selectedCustomerIdList.value = record.customer?.id ? [record.customer.id] : [];
|
||||||
selectedEventIdList.value = record.eventId ? [record.eventId] : [];
|
selectedEventIdList.value = record.eventId ? [record.eventId] : [];
|
||||||
|
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
|
||||||
|
delete selectedOptionSelectionMap[eventId];
|
||||||
|
}
|
||||||
|
if (record.eventId) {
|
||||||
|
Object.assign(selectedOptionSelectionMap, buildEventOptionSelectionMap(record.eventId, record.selectedOptionGroupList));
|
||||||
|
}
|
||||||
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
|
registeredAtText.value = record.registeredAt ? Time.formatDateTime(new Date(record.registeredAt)) : "";
|
||||||
note.value = record.remark || "";
|
note.value = record.remark || "";
|
||||||
pointValueText.value = "";
|
pointValueText.value = "";
|
||||||
@@ -793,7 +830,7 @@ watch(customerCode, async (value) => {
|
|||||||
.registration-record-edit {
|
.registration-record-edit {
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: calc(var(--app-nav-height) + 1rem) 0 4rem 0;
|
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
.form,
|
.form,
|
||||||
@@ -843,6 +880,10 @@ watch(customerCode, async (value) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option-selector {
|
||||||
|
margin: 1rem 0 1rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.time-actions {
|
.time-actions {
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -851,7 +892,6 @@ watch(customerCode, async (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.edit-point-tip {
|
.edit-point-tip {
|
||||||
color: #9a3412;
|
|
||||||
padding: 0 1rem 1rem;
|
padding: 0 1rem 1rem;
|
||||||
font-size: .8125rem;
|
font-size: .8125rem;
|
||||||
margin-top: .25rem;
|
margin-top: .25rem;
|
||||||
|
|||||||
+110
-272
@@ -28,6 +28,14 @@
|
|||||||
:label="verificationFilterLabel"
|
:label="verificationFilterLabel"
|
||||||
:options="verificationOptions"
|
:options="verificationOptions"
|
||||||
/>
|
/>
|
||||||
|
<t-dropdown-item
|
||||||
|
v-if="optionFilterOptions.length"
|
||||||
|
v-model="optionFilter"
|
||||||
|
:label="optionFilterLabel"
|
||||||
|
options-columns="2"
|
||||||
|
:options="optionFilterOptions"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
</t-dropdown-menu>
|
</t-dropdown-menu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,86 +60,36 @@
|
|||||||
@click="openEndTime"
|
@click="openEndTime"
|
||||||
/>
|
/>
|
||||||
<t-cell title="事件" :note="eventFilterLabel" />
|
<t-cell title="事件" :note="eventFilterLabel" />
|
||||||
|
<t-cell v-if="optionFilterLabel !== '全部选项'" title="选项" :note="optionFilterLabel" />
|
||||||
<t-cell title="数据量" :note="`共 ${pageResult.total} 条记录`" />
|
<t-cell title="数据量" :note="`共 ${pageResult.total} 条记录`" />
|
||||||
<t-cell v-if="summaryNumberValueText" title="数值合计" :note="summaryNumberValueText" />
|
<t-cell v-if="summaryNumberValueText" title="数值合计" :note="summaryNumberValueText" />
|
||||||
</t-cell-group>
|
</t-cell-group>
|
||||||
|
<t-cell-group v-if="hasSelectedOption && optionStatList.length" title="选项统计" class="option-stat" theme="card">
|
||||||
|
<t-cell
|
||||||
|
v-for="item in optionStatList"
|
||||||
|
:key="`${item.groupId}-${item.optionId}`"
|
||||||
|
:title="`${item.groupName || '选项组'}:${item.optionLabel || '未命名选项'}`"
|
||||||
|
:note="`${item.recordCount || 0} 条`"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
<t-cell-group
|
<t-cell-group
|
||||||
v-if="pageResult.list.length"
|
v-if="pageResult.list.length"
|
||||||
class="record-list"
|
class="record-list"
|
||||||
theme="card"
|
theme="card"
|
||||||
>
|
>
|
||||||
<t-swipe-cell
|
<event-record-cell
|
||||||
v-entrance-transition="index * 30"
|
v-entrance-transition="index * 30"
|
||||||
v-for="(item, index) in pageResult.list"
|
v-for="(item, index) in pageResult.list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="item"
|
:record="item"
|
||||||
>
|
@updated="reloadRecords"
|
||||||
<t-cell
|
|
||||||
class="cell"
|
|
||||||
:title="getRecordEventName(item)"
|
|
||||||
:note="getRecordRegisteredAt(item)"
|
|
||||||
arrow
|
|
||||||
hover
|
|
||||||
@click="toDetail(item.id || '')"
|
|
||||||
>
|
|
||||||
<template #description>
|
|
||||||
<div class="description-line">
|
|
||||||
<t-tag
|
|
||||||
v-if="item.verificationStatus && item.verificationStatus !== 'NOT_REQUIRED'"
|
|
||||||
:theme="getVerificationTag(item).theme"
|
|
||||||
variant="light-outline"
|
|
||||||
:content="getVerificationTag(item).text"
|
|
||||||
/>
|
/>
|
||||||
<span v-text="getRecordCustomerName(item)" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #leftIcon>
|
|
||||||
<t-image
|
|
||||||
v-if="getRecordPhotoURL(item)"
|
|
||||||
:src="getRecordPhotoURL(item)"
|
|
||||||
:alt="getRecordEventName(item)"
|
|
||||||
fit="cover"
|
|
||||||
class="avatar"
|
|
||||||
/>
|
|
||||||
<div v-else class="avatar fallback" v-text="getRecordCustomerInitial(item)" />
|
|
||||||
</template>
|
|
||||||
</t-cell>
|
|
||||||
<template v-if="canVerifyRecord || canDeleteRecord" #right>
|
|
||||||
<div class="actions">
|
|
||||||
<t-button
|
|
||||||
v-if="canVerifyRecord && item.verificationStatus === 'PENDING'"
|
|
||||||
class="action verify bg-orange white"
|
|
||||||
variant="text"
|
|
||||||
:loading="(isVerifying && verifyingId === item.id) || (isLoadingVerifyRecord && verifyLoadingId === item.id)"
|
|
||||||
@click.stop="confirmVerifyRecord(item)"
|
|
||||||
>
|
|
||||||
核销
|
|
||||||
</t-button>
|
|
||||||
<t-button
|
|
||||||
v-if="canDeleteRecord"
|
|
||||||
class="action delete"
|
|
||||||
theme="danger"
|
|
||||||
:loading="isDeleting && deletingId === item.id"
|
|
||||||
@click.stop="confirmRemoveRecord(item)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</t-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</t-swipe-cell>
|
|
||||||
</t-cell-group>
|
</t-cell-group>
|
||||||
<div v-else class="load-state" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
|
<div v-else class="load-state" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
|
||||||
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
|
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
|
||||||
</div>
|
</div>
|
||||||
</t-pull-down-refresh>
|
</t-pull-down-refresh>
|
||||||
</div>
|
</div>
|
||||||
<event-record-verify-dialog
|
|
||||||
v-model:visible="verifyDialogVisible"
|
|
||||||
:record="verifyTarget"
|
|
||||||
:can-update="canUpdateRecord"
|
|
||||||
:submit="submitVerifyRecord"
|
|
||||||
@verified="handleVerifySuccess"
|
|
||||||
/>
|
|
||||||
<nav-bar-action-menu v-if="canShowActionMenu" :actions="actionList" />
|
<nav-bar-action-menu v-if="canShowActionMenu" :actions="actionList" />
|
||||||
<t-popup v-model="startVisible" placement="bottom">
|
<t-popup v-model="startVisible" placement="bottom">
|
||||||
<t-date-time-picker
|
<t-date-time-picker
|
||||||
@@ -160,23 +118,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DialogPlugin, type DropdownValue } from "tdesign-mobile-vue";
|
import type { DropdownValue } from "tdesign-mobile-vue";
|
||||||
import { Time, WebToolkit } from "timi-web";
|
import { Time, WebToolkit } from "timi-web";
|
||||||
import { useUserStore, type Attachment } from "timi-web-api";
|
import { useUserStore } from "timi-web-api";
|
||||||
import EventAPI from "@/api/EventAPI";
|
import EventAPI from "@/api/EventAPI";
|
||||||
import EventRecordAPI from "@/api/EventRecordAPI";
|
import EventRecordAPI from "@/api/EventRecordAPI";
|
||||||
import EventRecordVerifyDialog from "@/components/EventRecordVerifyDialog.vue";
|
import EventRecordCell from "@/pages/event/component/EventRecordCell.vue";
|
||||||
import { useNavBarStore } from "@/store/navBarStore";
|
import { useNavBarStore } from "@/store/navBarStore";
|
||||||
import type { EventItem } from "@/types/EventItem";
|
import type { EventItem } from "@/types/EventItem";
|
||||||
import type { EventRecord, EventRecordListItemView, EventRecordPage, EventRecordPageResult, VerificationStatus } from "@/types/EventRecord";
|
import type { EventOptionStat, EventRecordPage, EventRecordPageResult, VerificationStatus } from "@/types/EventRecord";
|
||||||
import type { NavBarAction } from "@/types/NavBarAction";
|
import type { NavBarAction } from "@/types/NavBarAction";
|
||||||
import { getCustomerPhotoURL } from "@/utils/customer";
|
|
||||||
import { getErrorMessage } from "@/utils/error";
|
|
||||||
import { formatEventNumberValue } from "@/utils/eventValue";
|
import { formatEventNumberValue } from "@/utils/eventValue";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
import { Message } from "@/utils/message.ts";
|
import { Message } from "@/utils/message.ts";
|
||||||
|
|
||||||
type DateFilterType = "today" | "yesterday" | "beforeYesterday" | "lastWeek" | "all" | "custom";
|
type DateFilterType = "today" | "yesterday" | "lastWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
|
||||||
type VerificationFilter = "ALL" | VerificationStatus;
|
type VerificationFilter = "ALL" | VerificationStatus;
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -191,8 +147,9 @@ const userStore = useUserStore();
|
|||||||
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
|
||||||
{ label: "今天", value: "today" },
|
{ label: "今天", value: "today" },
|
||||||
{ label: "昨天", value: "yesterday" },
|
{ label: "昨天", value: "yesterday" },
|
||||||
{ label: "前天", value: "beforeYesterday" },
|
|
||||||
{ label: "上周", value: "lastWeek" },
|
{ label: "上周", value: "lastWeek" },
|
||||||
|
{ label: "本月", value: "thisMonth" },
|
||||||
|
{ label: "上个月", value: "lastMonth" },
|
||||||
{ label: "全部", value: "all" },
|
{ label: "全部", value: "all" },
|
||||||
{ label: "自定义范围", value: "custom" }
|
{ label: "自定义范围", value: "custom" }
|
||||||
];
|
];
|
||||||
@@ -206,6 +163,7 @@ const dateRange = ref(parseDateRange(dateFilterType.value));
|
|||||||
const startDraft = ref("");
|
const startDraft = ref("");
|
||||||
const endDraft = ref("");
|
const endDraft = ref("");
|
||||||
const actionFilter = ref<string[]>([]);
|
const actionFilter = ref<string[]>([]);
|
||||||
|
const optionFilter = ref<string[]>([]);
|
||||||
const verificationOptions: Array<{ label: string; value: VerificationFilter }> = [
|
const verificationOptions: Array<{ label: string; value: VerificationFilter }> = [
|
||||||
{ label: "全部核销状态", value: "ALL" },
|
{ label: "全部核销状态", value: "ALL" },
|
||||||
{ label: "待核销", value: "PENDING" },
|
{ label: "待核销", value: "PENDING" },
|
||||||
@@ -219,6 +177,7 @@ const endVisible = ref(false);
|
|||||||
let keywordTimer: number | undefined;
|
let keywordTimer: number | undefined;
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
const isExporting = ref(false);
|
const isExporting = ref(false);
|
||||||
|
let suppressOptionFilterReload = false;
|
||||||
|
|
||||||
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
|
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
|
||||||
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
|
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
|
||||||
@@ -236,6 +195,34 @@ const eventFilterLabel = computed(() => {
|
|||||||
.map(item => item.name || "未命名事件")
|
.map(item => item.name || "未命名事件")
|
||||||
.join("、");
|
.join("、");
|
||||||
});
|
});
|
||||||
|
const selectedOptionEvent = computed(() => {
|
||||||
|
if (actionFilter.value.length !== 1) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const event = eventList.value.find(item => item.id === actionFilter.value[0]);
|
||||||
|
return event?.optionGroupList?.some(group => group.optionList?.some(option => option.id)) ? event : undefined;
|
||||||
|
});
|
||||||
|
const optionFilterOptions = computed(() => {
|
||||||
|
const event = selectedOptionEvent.value;
|
||||||
|
if (!event) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (event.optionGroupList || []).flatMap(group => (group.optionList || [])
|
||||||
|
.filter(option => option.id)
|
||||||
|
.map(option => ({
|
||||||
|
label: `${group.name}:${option.label}`,
|
||||||
|
value: String(option.id)
|
||||||
|
})));
|
||||||
|
});
|
||||||
|
const optionFilterLabel = computed(() => {
|
||||||
|
if (!optionFilter.value.length) {
|
||||||
|
return "全部选项";
|
||||||
|
}
|
||||||
|
return optionFilterOptions.value
|
||||||
|
.filter(item => optionFilter.value.includes(item.value))
|
||||||
|
.map(item => item.label)
|
||||||
|
.join("、") || "全部选项";
|
||||||
|
});
|
||||||
const verificationFilterLabel = computed(() => verificationOptions.find(item => item.value === verificationFilter.value)?.label || "全部核销状态");
|
const verificationFilterLabel = computed(() => verificationOptions.find(item => item.value === verificationFilter.value)?.label || "全部核销状态");
|
||||||
const selectedNumberEvent = computed(() => {
|
const selectedNumberEvent = computed(() => {
|
||||||
if (actionFilter.value.length !== 1) {
|
if (actionFilter.value.length !== 1) {
|
||||||
@@ -244,10 +231,8 @@ const selectedNumberEvent = computed(() => {
|
|||||||
const event = eventList.value.find(item => item.id === actionFilter.value[0]);
|
const event = eventList.value.find(item => item.id === actionFilter.value[0]);
|
||||||
return event?.valueType === "NUMBER" ? event : undefined;
|
return event?.valueType === "NUMBER" ? event : undefined;
|
||||||
});
|
});
|
||||||
|
const hasSelectedOption = computed(() => !!selectedOptionEvent.value);
|
||||||
const canExportRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.export));
|
const canExportRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.export));
|
||||||
const canDeleteRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.delete));
|
|
||||||
const canVerifyRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.verify));
|
|
||||||
const canUpdateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.update));
|
|
||||||
const canShowActionMenu = computed(() => canCreateRecord.value || canExportRecord.value);
|
const canShowActionMenu = computed(() => canCreateRecord.value || canExportRecord.value);
|
||||||
|
|
||||||
const actionList = computed<NavBarAction[]>(() => {
|
const actionList = computed<NavBarAction[]>(() => {
|
||||||
@@ -288,6 +273,24 @@ watch(actionFilter, async () => {
|
|||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const optionIdSet = new Set(optionFilterOptions.value.map(item => item.value));
|
||||||
|
const nextOptionFilter = optionFilter.value.filter(optionId => optionIdSet.has(optionId));
|
||||||
|
if (nextOptionFilter.length !== optionFilter.value.length) {
|
||||||
|
suppressOptionFilterReload = true;
|
||||||
|
optionFilter.value = nextOptionFilter;
|
||||||
|
}
|
||||||
|
await replaceFilterQuery();
|
||||||
|
await reloadRecords();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(optionFilter, async () => {
|
||||||
|
if (!initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (suppressOptionFilterReload) {
|
||||||
|
suppressOptionFilterReload = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
await replaceFilterQuery();
|
await replaceFilterQuery();
|
||||||
await reloadRecords();
|
await reloadRecords();
|
||||||
});
|
});
|
||||||
@@ -328,6 +331,7 @@ const pageResult = reactive<EventRecordPageResult>({
|
|||||||
list: [],
|
list: [],
|
||||||
numberValueTotal: null
|
numberValueTotal: null
|
||||||
});
|
});
|
||||||
|
const optionStatList = ref<EventOptionStat[]>([]);
|
||||||
let loadToken = 0;
|
let loadToken = 0;
|
||||||
|
|
||||||
const summaryNumberValueText = computed(() => {
|
const summaryNumberValueText = computed(() => {
|
||||||
@@ -350,8 +354,9 @@ async function reloadRecords(): Promise<void> {
|
|||||||
pageResult.total = 0;
|
pageResult.total = 0;
|
||||||
pageResult.list = [];
|
pageResult.list = [];
|
||||||
pageResult.numberValueTotal = null;
|
pageResult.numberValueTotal = null;
|
||||||
|
optionStatList.value = [];
|
||||||
isNoMore.value = false;
|
isNoMore.value = false;
|
||||||
await loadRecords();
|
await Promise.all([loadRecords(), loadOptionStats()]);
|
||||||
} finally {
|
} finally {
|
||||||
isRefreshing.value = false;
|
isRefreshing.value = false;
|
||||||
}
|
}
|
||||||
@@ -387,21 +392,14 @@ async function loadRecords(): Promise<void> {
|
|||||||
|
|
||||||
// ---------- 操作 ----------
|
// ---------- 操作 ----------
|
||||||
|
|
||||||
const isDeleting = ref(false);
|
|
||||||
const deletingId = ref("");
|
|
||||||
const isVerifying = ref(false);
|
|
||||||
const verifyingId = ref("");
|
|
||||||
const isLoadingVerifyRecord = ref(false);
|
|
||||||
const verifyLoadingId = ref("");
|
|
||||||
const verifyDialogVisible = ref(false);
|
|
||||||
const verifyTarget = ref<EventRecord>();
|
|
||||||
|
|
||||||
async function loadActions(): Promise<void> {
|
async function loadActions(): Promise<void> {
|
||||||
eventList.value = await EventAPI.listValid();
|
eventList.value = await EventAPI.listValid();
|
||||||
const eventIdList = parseEventIdList(route.query.eventIds);
|
const validEventIdList = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
|
||||||
actionFilter.value = eventIdList.length
|
const validEventIdSet = new Set(validEventIdList);
|
||||||
? eventIdList
|
const eventIdList = parseEventIdList(route.query.eventIds).filter(eventId => validEventIdSet.has(eventId));
|
||||||
: eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
|
actionFilter.value = eventIdList.length ? eventIdList : validEventIdList;
|
||||||
|
const optionIdSet = new Set(optionFilterOptions.value.map(item => item.value));
|
||||||
|
optionFilter.value = parseEventIdList(route.query.optionIds).filter(optionId => optionIdSet.has(optionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectDateFilter(type: DropdownValue): Promise<void> {
|
async function selectDateFilter(type: DropdownValue): Promise<void> {
|
||||||
@@ -479,68 +477,23 @@ async function exportExcel(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmVerifyRecord(record: EventRecordListItemView): Promise<void> {
|
async function loadOptionStats(): Promise<void> {
|
||||||
if (!record.id || record.verificationStatus !== "PENDING" || isVerifying.value || isLoadingVerifyRecord.value) {
|
if (!hasSelectedOption.value) {
|
||||||
|
optionStatList.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordId = record.id;
|
|
||||||
isLoadingVerifyRecord.value = true;
|
|
||||||
verifyLoadingId.value = recordId;
|
|
||||||
try {
|
try {
|
||||||
verifyTarget.value = await EventRecordAPI.detail(recordId);
|
optionStatList.value = await EventRecordAPI.optionStat({
|
||||||
verifyDialogVisible.value = true;
|
index: 0,
|
||||||
} catch (error) {
|
size: 1,
|
||||||
Message.error(getErrorMessage(error, "登记记录加载失败"));
|
...buildRangeReq()
|
||||||
} finally {
|
|
||||||
isLoadingVerifyRecord.value = false;
|
|
||||||
verifyLoadingId.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitVerifyRecord(record: EventRecord, attachmentList: Attachment[]): Promise<void> {
|
|
||||||
const id = record.id;
|
|
||||||
if (!id) {
|
|
||||||
throw new Error("登记事件记录不存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
isVerifying.value = true;
|
|
||||||
verifyingId.value = id;
|
|
||||||
try {
|
|
||||||
if (canUpdateRecord.value) {
|
|
||||||
await EventRecordAPI.update({
|
|
||||||
id,
|
|
||||||
remark: record.remark || "",
|
|
||||||
attachmentList
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
await EventRecordAPI.verify(id);
|
|
||||||
} finally {
|
|
||||||
isVerifying.value = false;
|
|
||||||
verifyingId.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleVerifySuccess(): Promise<void> {
|
|
||||||
verifyDialogVisible.value = false;
|
|
||||||
verifyTarget.value = undefined;
|
|
||||||
try {
|
|
||||||
await reloadRecords();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Message.error(getErrorMessage(error, "登记记录刷新失败"));
|
optionStatList.value = [];
|
||||||
|
console.warn("登记选项统计加载失败", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toDetail(id: string): Promise<void> {
|
|
||||||
if (!id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await router.push({
|
|
||||||
path: `/management/registration-event-records/${id}`,
|
|
||||||
query: buildFilterQuery(String(getCurrentRouteDepth() + 1))
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toCreate(): Promise<void> {
|
async function toCreate(): Promise<void> {
|
||||||
await router.push({
|
await router.push({
|
||||||
path: "/management/registration-event-records/create",
|
path: "/management/registration-event-records/create",
|
||||||
@@ -550,85 +503,6 @@ async function toCreate(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRemoveRecord(record: EventRecordListItemView): Promise<void> {
|
|
||||||
if (!record.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordId = record.id;
|
|
||||||
const dialog = DialogPlugin.confirm({
|
|
||||||
title: "删除登记记录",
|
|
||||||
content: `确定删除 ${getRecordCustomerName(record)} 的 ${getRecordEventName(record)} 记录吗?删除后无法恢复。`,
|
|
||||||
cancelBtn: "取消",
|
|
||||||
confirmBtn: "删除",
|
|
||||||
onCancel() {
|
|
||||||
dialog.destroy();
|
|
||||||
},
|
|
||||||
async onConfirm() {
|
|
||||||
dialog.destroy();
|
|
||||||
await removeRecord(recordId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeRecord(id: string): Promise<void> {
|
|
||||||
if (isDeleting.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isDeleting.value = true;
|
|
||||||
deletingId.value = id;
|
|
||||||
try {
|
|
||||||
await EventRecordAPI.remove(id);
|
|
||||||
await reloadRecords();
|
|
||||||
Message.success("登记记录已删除");
|
|
||||||
} catch (error) {
|
|
||||||
const message = getErrorMessage(error, "登记记录删除失败");
|
|
||||||
if (message.includes("积分余额不足") || message.includes("余额不足")) {
|
|
||||||
Message.warning("积分余额不足,请先人工调整积分后再删除登记记录");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Message.error(message);
|
|
||||||
} finally {
|
|
||||||
isDeleting.value = false;
|
|
||||||
deletingId.value = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------- 格式化 ----------
|
|
||||||
|
|
||||||
function getRecordEventName(record: EventRecordListItemView): string {
|
|
||||||
return record.event?.name || "未知事件";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecordCustomerName(record: EventRecordListItemView): string {
|
|
||||||
return record.customer?.name || "未知客户";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecordPhotoURL(record: EventRecordListItemView): string {
|
|
||||||
return getCustomerPhotoURL(record.customer, 64);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecordCustomerInitial(record: EventRecordListItemView): string {
|
|
||||||
return getRecordCustomerName(record).slice(0, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVerificationTag(record: EventRecordListItemView): { text: string; theme: "warning" | "success" } {
|
|
||||||
return record.verificationStatus === "VERIFIED"
|
|
||||||
? { text: "已核销", theme: "success" }
|
|
||||||
: { text: "待核销", theme: "warning" };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecordRegisteredAt(record: EventRecordListItemView): string {
|
|
||||||
if (!record.registeredAt) {
|
|
||||||
return "-";
|
|
||||||
}
|
|
||||||
if (["today", "yesterday", "beforeYesterday"].includes(dateFilterType.value)) {
|
|
||||||
return Time.toTime(record.registeredAt);
|
|
||||||
}
|
|
||||||
return Time.toDateTime(record.registeredAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
|
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
|
||||||
const [beginAt, endAt] = buildUnixRange();
|
const [beginAt, endAt] = buildUnixRange();
|
||||||
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === eventList.value.length;
|
const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === eventList.value.length;
|
||||||
@@ -638,6 +512,7 @@ function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
|
|||||||
customerId: customerId.value || undefined,
|
customerId: customerId.value || undefined,
|
||||||
keyword: keyword.value.trim() || undefined,
|
keyword: keyword.value.trim() || undefined,
|
||||||
eventIdList: actionFilter.value.length === 1 ? actionFilter.value : selectedAllEvent ? undefined : actionFilter.value,
|
eventIdList: actionFilter.value.length === 1 ? actionFilter.value : selectedAllEvent ? undefined : actionFilter.value,
|
||||||
|
optionIdList: optionFilter.value.length ? optionFilter.value : undefined,
|
||||||
verificationStatus: verificationFilter.value === "ALL" ? undefined : verificationFilter.value
|
verificationStatus: verificationFilter.value === "ALL" ? undefined : verificationFilter.value
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -681,6 +556,7 @@ function buildFilterQuery(transitionDepth = getQueryText("transitionDepth")): Re
|
|||||||
customerId: customerId.value,
|
customerId: customerId.value,
|
||||||
customerName: getQueryText("customerName"),
|
customerName: getQueryText("customerName"),
|
||||||
eventIds: actionFilter.value.join(","),
|
eventIds: actionFilter.value.join(","),
|
||||||
|
optionIds: optionFilter.value.join(","),
|
||||||
verificationStatus: verificationFilter.value === "ALL" ? "" : verificationFilter.value
|
verificationStatus: verificationFilter.value === "ALL" ? "" : verificationFilter.value
|
||||||
};
|
};
|
||||||
if (isValidTransitionDepth(transitionDepth)) {
|
if (isValidTransitionDepth(transitionDepth)) {
|
||||||
@@ -733,11 +609,21 @@ function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, str
|
|||||||
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
|
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
|
||||||
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
|
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
|
||||||
}
|
}
|
||||||
|
if (type === "thisMonth") {
|
||||||
|
const current = new Date();
|
||||||
|
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
|
||||||
|
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
|
||||||
|
}
|
||||||
|
if (type === "lastMonth") {
|
||||||
|
const current = new Date();
|
||||||
|
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
|
||||||
|
const end = new Date(current.getFullYear(), current.getMonth(), 0);
|
||||||
|
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
|
||||||
|
}
|
||||||
|
|
||||||
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek">, number> = {
|
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
|
||||||
today: 0,
|
today: 0,
|
||||||
yesterday: 1,
|
yesterday: 1
|
||||||
beforeYesterday: 2
|
|
||||||
};
|
};
|
||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setHours(0, 0, 0, 0);
|
date.setHours(0, 0, 0, 0);
|
||||||
@@ -860,58 +746,10 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.record-list {
|
.record-list {
|
||||||
|
|
||||||
.item {
|
:deep(.item:last-child .cell:after) {
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
|
|
||||||
:deep(.t-cell:after) {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.cell {
|
|
||||||
padding: .5rem;
|
|
||||||
|
|
||||||
:deep(.t-cell__title) {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.t-cell__title-text) {
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
:deep(.t-cell__note) {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 3.5rem;
|
|
||||||
height: 3.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description-line {
|
|
||||||
gap: .5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action {
|
|
||||||
height: 100%;
|
|
||||||
padding: 0 1rem;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,422 @@
|
|||||||
|
<template>
|
||||||
|
<t-cell-group class="event-option-editor" theme="card">
|
||||||
|
<t-cell
|
||||||
|
title="登记选项"
|
||||||
|
:note="optionGroupSummary"
|
||||||
|
arrow
|
||||||
|
@click="openEditor"
|
||||||
|
/>
|
||||||
|
</t-cell-group>
|
||||||
|
<bottom-popup
|
||||||
|
v-model="editorVisible"
|
||||||
|
title="登记选项组"
|
||||||
|
height="min(88vh, 48rem)"
|
||||||
|
@left-button-click="closeEditor"
|
||||||
|
@right-button-click="confirmEditor"
|
||||||
|
>
|
||||||
|
<div class="popup" :style="{ background: pageBackground }">
|
||||||
|
<div class="header bg-white">
|
||||||
|
<span v-text="`${draftGroupList.length || 0} 个选项组`"></span>
|
||||||
|
<t-button
|
||||||
|
class="add"
|
||||||
|
size="small"
|
||||||
|
theme="primary"
|
||||||
|
@click="addGroup"
|
||||||
|
>新增选项组</t-button>
|
||||||
|
</div>
|
||||||
|
<t-collapse
|
||||||
|
v-if="draftGroupList.length"
|
||||||
|
v-model="expandedGroupKeyList"
|
||||||
|
class="collapse"
|
||||||
|
theme="card"
|
||||||
|
>
|
||||||
|
<vue-draggable-next
|
||||||
|
v-model="draftGroupList"
|
||||||
|
class="group"
|
||||||
|
item-key="clientKey"
|
||||||
|
handle=".group-handle"
|
||||||
|
ghost-class="ghost"
|
||||||
|
chosen-class="chosen"
|
||||||
|
drag-class="dragging"
|
||||||
|
:animation="180"
|
||||||
|
>
|
||||||
|
<t-collapse-panel
|
||||||
|
class="panel"
|
||||||
|
v-for="(group, groupIndex) in draftGroupList"
|
||||||
|
:key="getGroupKey(group, groupIndex)"
|
||||||
|
:value="getGroupKey(group, groupIndex)"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<div class="group-header">
|
||||||
|
<t-icon
|
||||||
|
class="handle group-handle"
|
||||||
|
role="button"
|
||||||
|
name="move"
|
||||||
|
aria-label="拖动选项组排序"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
<t-input
|
||||||
|
v-model.trim="group.name"
|
||||||
|
class="name"
|
||||||
|
size="small"
|
||||||
|
borderless
|
||||||
|
placeholder="选项组标题"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #header-right-content>
|
||||||
|
<t-button
|
||||||
|
theme="danger"
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click.stop="removeGroup(groupIndex)"
|
||||||
|
>移除</t-button>
|
||||||
|
</template>
|
||||||
|
<t-form class="form" label-align="left" label-width="4rem">
|
||||||
|
<vue-draggable-next
|
||||||
|
v-model="group.optionList"
|
||||||
|
class="options"
|
||||||
|
item-key="clientKey"
|
||||||
|
handle=".option-handle"
|
||||||
|
ghost-class="ghost"
|
||||||
|
chosen-class="chosen"
|
||||||
|
drag-class="dragging"
|
||||||
|
:animation="180"
|
||||||
|
>
|
||||||
|
<t-input
|
||||||
|
v-for="(option, optionIndex) in group.optionList"
|
||||||
|
:key="option.id || option.clientKey || optionIndex"
|
||||||
|
class="item"
|
||||||
|
:placeholder="`选项 ${optionIndex + 1}`"
|
||||||
|
v-model.trim="option.label"
|
||||||
|
borderless
|
||||||
|
>
|
||||||
|
<template #prefix-icon>
|
||||||
|
<t-icon
|
||||||
|
class="handle option-handle"
|
||||||
|
role="button"
|
||||||
|
name="move"
|
||||||
|
aria-label="拖动选项排序"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #suffix-icon>
|
||||||
|
<t-button
|
||||||
|
variant="text"
|
||||||
|
theme="danger"
|
||||||
|
size="small"
|
||||||
|
shape="circle"
|
||||||
|
@click="removeOption(group, optionIndex)"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<t-icon name="close" />
|
||||||
|
</template>
|
||||||
|
</t-button>
|
||||||
|
</template>
|
||||||
|
</t-input>
|
||||||
|
</vue-draggable-next>
|
||||||
|
<div class="require-add-wrapper">
|
||||||
|
<div class="require-wrapper">
|
||||||
|
<span>必选</span>
|
||||||
|
<t-switch v-model="group.required" size="small" />
|
||||||
|
</div>
|
||||||
|
<t-button
|
||||||
|
class="add"
|
||||||
|
theme="primary"
|
||||||
|
variant="outline"
|
||||||
|
size="small"
|
||||||
|
@click="addOption(group)"
|
||||||
|
>+ 新增选项</t-button>
|
||||||
|
</div>
|
||||||
|
<t-form-item label="选择方式" content-align="right">
|
||||||
|
<t-radio-group v-model="group.selectionMode" variant="default-filled">
|
||||||
|
<t-radio :block="false" borderless value="SINGLE">单选</t-radio>
|
||||||
|
<t-radio :block="false" borderless value="MULTIPLE">多选</t-radio>
|
||||||
|
</t-radio-group>
|
||||||
|
</t-form-item>
|
||||||
|
</t-form>
|
||||||
|
</t-collapse-panel>
|
||||||
|
</vue-draggable-next>
|
||||||
|
</t-collapse>
|
||||||
|
<div v-else class="load-state">暂未配置登记选项,点击上方按钮新增。</div>
|
||||||
|
</div>
|
||||||
|
</bottom-popup>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||||
|
import { BottomPopup } from "timi-tdesign-mobile";
|
||||||
|
import { VueDraggableNext } from "vue-draggable-next";
|
||||||
|
import type { EventOption, EventOptionGroup } from "@/types/EventItem";
|
||||||
|
import { createEventOptionId } from "@/utils/eventOption";
|
||||||
|
import { useGlobalUIStore } from "@/store/globalUI.ts";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "EventOptionEditor"
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalUIStore = useGlobalUIStore();
|
||||||
|
|
||||||
|
const modelValue = defineModel<EventOptionGroup[]>({ required: true });
|
||||||
|
const editorVisible = ref(false);
|
||||||
|
const draftGroupList = ref<EventOptionGroup[]>([]);
|
||||||
|
const expandedGroupKeyList = ref<Array<string | number>>([]);
|
||||||
|
|
||||||
|
const optionGroupSummary = computed(() => {
|
||||||
|
if (!modelValue.value.length) {
|
||||||
|
return "未配置";
|
||||||
|
}
|
||||||
|
return `${modelValue.value.length} 个选项组`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function openEditor(): void {
|
||||||
|
draftGroupList.value = cloneGroupList(modelValue.value);
|
||||||
|
expandedGroupKeyList.value = [];
|
||||||
|
editorVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditor(): void {
|
||||||
|
editorVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmEditor(): void {
|
||||||
|
modelValue.value = cloneGroupList(draftGroupList.value);
|
||||||
|
editorVisible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addGroup(): void {
|
||||||
|
const group: EventOptionGroup = {
|
||||||
|
clientKey: createEventOptionId(),
|
||||||
|
name: "",
|
||||||
|
selectionMode: "SINGLE",
|
||||||
|
required: true,
|
||||||
|
optionList: [createOption()]
|
||||||
|
};
|
||||||
|
draftGroupList.value.push(group);
|
||||||
|
expandedGroupKeyList.value = [...expandedGroupKeyList.value, getGroupKey(group, draftGroupList.value.length - 1)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGroup(index: number): void {
|
||||||
|
const group = draftGroupList.value[index];
|
||||||
|
if (!group) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "移除选项组",
|
||||||
|
content: `确定移除${group.name ? `“${group.name}”` : "该"}选项组吗?组内选项也会一并移除。`,
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "移除",
|
||||||
|
zIndex: 12000,
|
||||||
|
overlayProps: { zIndex: 12000 },
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
const groupIndex = draftGroupList.value.indexOf(group);
|
||||||
|
if (groupIndex < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupKey = getGroupKey(group, groupIndex);
|
||||||
|
draftGroupList.value.splice(groupIndex, 1);
|
||||||
|
expandedGroupKeyList.value = expandedGroupKeyList.value.filter(key => key !== groupKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOption(group: EventOptionGroup): void {
|
||||||
|
group.optionList.push(createOption());
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeOption(group: EventOptionGroup, index: number): void {
|
||||||
|
group.optionList.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroupKey(group: EventOptionGroup, index: number): string {
|
||||||
|
return group.id || group.clientKey || `group-${index}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneGroupList(groupList: EventOptionGroup[]): EventOptionGroup[] {
|
||||||
|
return groupList.map(group => ({
|
||||||
|
...group,
|
||||||
|
clientKey: group.clientKey || group.id || createEventOptionId(),
|
||||||
|
optionList: group.optionList.map(option => ({
|
||||||
|
...option,
|
||||||
|
clientKey: option.clientKey || option.id || createEventOptionId()
|
||||||
|
}))
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOption(): EventOption {
|
||||||
|
return {
|
||||||
|
clientKey: createEventOptionId(),
|
||||||
|
label: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 样式 ----------
|
||||||
|
const pageBackground = computed(() => globalUIStore.bodyBackground);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.event-option-editor {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
min-height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.header {
|
||||||
|
--td-button-border-radius: 1rem;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
z-index: 10;
|
||||||
|
padding: .5rem 1rem;
|
||||||
|
position: sticky;
|
||||||
|
box-shadow: 0 1rem 1rem rgba(0, 0, 0, .025);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group {
|
||||||
|
min-height: 0;
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen,
|
||||||
|
.dragging {
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .16);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
|
||||||
|
&:last-child:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
--td-input-vertical-padding: 0;
|
||||||
|
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
gap: .75rem;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
background: transparent;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
:deep(.t-form__item) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.options {
|
||||||
|
gap: .75rem;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
opacity: .4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen,
|
||||||
|
.dragging {
|
||||||
|
z-index: 1;
|
||||||
|
box-shadow: 0 .5rem 1rem rgba(15, 23, 42, .16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
padding: .25rem .5rem .25rem 1rem;
|
||||||
|
min-width: 0;
|
||||||
|
background: #F2F2F2;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.require-add-wrapper {
|
||||||
|
--td-button-border-radius: 1rem;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.require-wrapper {
|
||||||
|
gap: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
color: var(--td-text-color-secondary);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-line {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item :deep(.t-form__controls-content) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-line :deep(.t-input) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-option {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html.theme-dark) {
|
||||||
|
.popup {
|
||||||
|
.empty {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handle {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<template>
|
||||||
|
<div class="event-record-cell bg-white">
|
||||||
|
<t-swipe-cell class="item">
|
||||||
|
<div class="content">
|
||||||
|
<t-cell
|
||||||
|
class="cell"
|
||||||
|
:title="getRecordEventName(record)"
|
||||||
|
:note="registeredAtText"
|
||||||
|
arrow
|
||||||
|
@click="toDetail"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<span v-text="getRecordCustomerName(record)" />
|
||||||
|
</template>
|
||||||
|
<template #leftIcon>
|
||||||
|
<t-image
|
||||||
|
v-if="getRecordPhotoURL(record)"
|
||||||
|
:src="getRecordPhotoURL(record)"
|
||||||
|
:alt="getRecordEventName(record)"
|
||||||
|
fit="cover"
|
||||||
|
class="avatar"
|
||||||
|
/>
|
||||||
|
<div v-else class="avatar fallback" v-text="getRecordCustomerInitial(record)" />
|
||||||
|
</template>
|
||||||
|
</t-cell>
|
||||||
|
<div v-if="tagList.length" class="tags">
|
||||||
|
<t-tag
|
||||||
|
v-for="tag in tagList"
|
||||||
|
:key="tag.key"
|
||||||
|
:theme="tag.theme"
|
||||||
|
variant="light-outline"
|
||||||
|
:content="tag.label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template v-if="canVerifyRecord || canDeleteRecord" #right>
|
||||||
|
<div class="actions">
|
||||||
|
<t-button
|
||||||
|
v-if="canVerifyRecord && record.verificationStatus === 'PENDING'"
|
||||||
|
class="action verify bg-orange white"
|
||||||
|
variant="text"
|
||||||
|
:loading="isVerifyLoading"
|
||||||
|
@click.stop="confirmVerifyRecord"
|
||||||
|
>
|
||||||
|
核销
|
||||||
|
</t-button>
|
||||||
|
<t-button
|
||||||
|
v-if="canDeleteRecord"
|
||||||
|
class="action delete"
|
||||||
|
theme="danger"
|
||||||
|
:loading="isDeleting"
|
||||||
|
@click.stop="confirmRemoveRecord"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</t-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</t-swipe-cell>
|
||||||
|
<event-record-verify-dialog
|
||||||
|
v-model:visible="verifyDialogVisible"
|
||||||
|
:record="verifyTarget"
|
||||||
|
:can-update="canUpdateRecord"
|
||||||
|
:submit="submitVerifyRecord"
|
||||||
|
@verified="handleVerifySuccess"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { DialogPlugin } from "tdesign-mobile-vue";
|
||||||
|
import { Time } from "timi-web";
|
||||||
|
import { useUserStore, type Attachment } from "timi-web-api";
|
||||||
|
import EventRecordAPI from "@/api/EventRecordAPI";
|
||||||
|
import EventRecordVerifyDialog from "@/components/EventRecordVerifyDialog.vue";
|
||||||
|
import type { EventRecord, EventRecordListItemView } from "@/types/EventRecord";
|
||||||
|
import { getCustomerPhotoURL } from "@/utils/customer";
|
||||||
|
import { getErrorMessage } from "@/utils/error";
|
||||||
|
import { permissionCodes } from "@/utils/permission";
|
||||||
|
import { Message } from "@/utils/message";
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: "EventRecordCell"
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
record: EventRecordListItemView;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordTag = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
theme: "primary" | "warning" | "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: [];
|
||||||
|
}>();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const canUpdateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.update));
|
||||||
|
const canVerifyRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.verify));
|
||||||
|
const canDeleteRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.delete));
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
const isVerifying = ref(false);
|
||||||
|
const isLoadingVerifyRecord = ref(false);
|
||||||
|
const verifyDialogVisible = ref(false);
|
||||||
|
const verifyTarget = ref<EventRecord>();
|
||||||
|
|
||||||
|
const registeredAtText = computed(() => {
|
||||||
|
if (!props.record.registeredAt) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
const dateFilterType = getQueryText("dateType");
|
||||||
|
return !dateFilterType || ["today", "yesterday"].includes(dateFilterType)
|
||||||
|
? Time.toTime(props.record.registeredAt)
|
||||||
|
: Time.toDateTime(props.record.registeredAt);
|
||||||
|
});
|
||||||
|
const tagList = computed<RecordTag[]>(() => {
|
||||||
|
const list: RecordTag[] = [];
|
||||||
|
if (props.record.verificationStatus && props.record.verificationStatus !== "NOT_REQUIRED") {
|
||||||
|
list.push({
|
||||||
|
key: "verification",
|
||||||
|
label: props.record.verificationStatus === "VERIFIED" ? "已核销" : "待核销",
|
||||||
|
theme: props.record.verificationStatus === "VERIFIED" ? "success" : "warning"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (shouldShowSelectedOptions.value) {
|
||||||
|
list.push(...getRecordSelectedOptions(props.record));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
const isVerifyLoading = computed(() => isVerifying.value || isLoadingVerifyRecord.value);
|
||||||
|
const shouldShowSelectedOptions = computed(() => {
|
||||||
|
const selectedEventIdList = parseEventIdList(route.query.eventIds);
|
||||||
|
const recordEventId = props.record.eventId || props.record.event?.id;
|
||||||
|
return selectedEventIdList.length === 1
|
||||||
|
&& selectedEventIdList[0] === recordEventId
|
||||||
|
&& !getQueryText("optionIds").trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function toDetail(): Promise<void> {
|
||||||
|
const id = props.record.id;
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await router.push({
|
||||||
|
path: `/management/registration-event-records/${id}`,
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
transitionDepth: String(getCurrentRouteDepth() + 1)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmVerifyRecord(): Promise<void> {
|
||||||
|
if (!props.record.id || props.record.verificationStatus !== "PENDING" || isVerifyLoading.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingVerifyRecord.value = true;
|
||||||
|
try {
|
||||||
|
verifyTarget.value = await EventRecordAPI.detail(props.record.id);
|
||||||
|
verifyDialogVisible.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
Message.error(getErrorMessage(error, "登记记录加载失败"));
|
||||||
|
} finally {
|
||||||
|
isLoadingVerifyRecord.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitVerifyRecord(record: EventRecord, attachmentList: Attachment[]): Promise<void> {
|
||||||
|
const id = record.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error("登记事件记录不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
isVerifying.value = true;
|
||||||
|
try {
|
||||||
|
if (canUpdateRecord.value) {
|
||||||
|
await EventRecordAPI.update({
|
||||||
|
id,
|
||||||
|
remark: record.remark || "",
|
||||||
|
attachmentList
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await EventRecordAPI.verify(id);
|
||||||
|
} finally {
|
||||||
|
isVerifying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVerifySuccess(): void {
|
||||||
|
verifyDialogVisible.value = false;
|
||||||
|
verifyTarget.value = undefined;
|
||||||
|
emit("updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRemoveRecord(): Promise<void> {
|
||||||
|
if (!props.record.id || isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recordId = props.record.id;
|
||||||
|
const dialog = DialogPlugin.confirm({
|
||||||
|
title: "删除登记记录",
|
||||||
|
content: `确定删除 ${getRecordCustomerName(props.record)} 的 ${getRecordEventName(props.record)} 记录吗?删除后无法恢复。`,
|
||||||
|
cancelBtn: "取消",
|
||||||
|
confirmBtn: "删除",
|
||||||
|
onCancel() {
|
||||||
|
dialog.destroy();
|
||||||
|
},
|
||||||
|
async onConfirm() {
|
||||||
|
dialog.destroy();
|
||||||
|
await removeRecord(recordId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeRecord(id: string): Promise<void> {
|
||||||
|
if (isDeleting.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isDeleting.value = true;
|
||||||
|
try {
|
||||||
|
await EventRecordAPI.remove(id);
|
||||||
|
emit("updated");
|
||||||
|
Message.success("登记记录已删除");
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error, "登记记录删除失败");
|
||||||
|
if (message.includes("积分余额不足") || message.includes("余额不足")) {
|
||||||
|
Message.warning("积分余额不足,请先人工调整积分后再删除登记记录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Message.error(message);
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordEventName(record: EventRecordListItemView): string {
|
||||||
|
return record.event?.name || "未知事件";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordCustomerName(record: EventRecordListItemView): string {
|
||||||
|
return record.customer?.name || "未知客户";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordPhotoURL(record: EventRecordListItemView): string {
|
||||||
|
return getCustomerPhotoURL(record.customer, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordCustomerInitial(record: EventRecordListItemView): string {
|
||||||
|
return getRecordCustomerName(record).slice(0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRecordSelectedOptions(record: EventRecordListItemView): RecordTag[] {
|
||||||
|
return (record.selectedOptionGroupList || [])
|
||||||
|
.filter(group => group.selectedOptionList?.length)
|
||||||
|
.map(group => ({
|
||||||
|
key: `option-${group.groupId}`,
|
||||||
|
label: `${group.groupName || "选项组"}:${group.selectedOptionList.map(option => option.label).join("、")}`,
|
||||||
|
theme: "primary"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQueryText(key: string): string {
|
||||||
|
const value = route.query[key];
|
||||||
|
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEventIdList(value: unknown): string[] {
|
||||||
|
const text = Array.isArray(value) ? String(value[0] || "") : String(value || "");
|
||||||
|
return text.split(",").map(item => item.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCurrentRouteDepth(): number {
|
||||||
|
const transitionDepth = getQueryText("transitionDepth").trim();
|
||||||
|
const depth = Number(transitionDepth);
|
||||||
|
if (transitionDepth !== "" && Number.isInteger(depth) && 0 <= depth && depth <= 20) {
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(route.meta.depth ?? 0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="less">
|
||||||
|
.event-record-cell {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&:not(:last-child) .item .content:after {
|
||||||
|
content: "";
|
||||||
|
left: var(--td-border-left-space, var(--td-cell-horizontal-padding, 16px));
|
||||||
|
right: var(--td-cell-border-right-space, 0);
|
||||||
|
bottom: 0;
|
||||||
|
position: absolute;
|
||||||
|
border-bottom: 1px solid var(--td-cell-border-color, var(--td-component-stroke, var(--td-gray-color-3, #e7e7e7)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
|
||||||
|
:deep(.t-swipe-cell__content) {
|
||||||
|
display: block;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
&:after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__title) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__title-text) {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.t-cell__note) {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 3.5rem;
|
||||||
|
height: 3.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags {
|
||||||
|
--td-tag-square-border-radius: .65rem;
|
||||||
|
|
||||||
|
gap: .5rem;
|
||||||
|
display: flex;
|
||||||
|
padding: 0 .5rem .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.action {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -71,6 +71,11 @@
|
|||||||
:event-list="eventList"
|
:event-list="eventList"
|
||||||
/>
|
/>
|
||||||
</t-form-item>
|
</t-form-item>
|
||||||
|
<event-option-selector
|
||||||
|
v-model="selectedOptionSelectionMap"
|
||||||
|
:event-list="eventList"
|
||||||
|
:selected-event-id-list="selectedEventIdList"
|
||||||
|
/>
|
||||||
<div v-if="selectedNumberEventList.length" class="event-value-list">
|
<div v-if="selectedNumberEventList.length" class="event-value-list">
|
||||||
<t-form-item
|
<t-form-item
|
||||||
v-for="item in selectedNumberEventList"
|
v-for="item in selectedNumberEventList"
|
||||||
@@ -152,14 +157,16 @@ import CustomerAPI from "@/api/CustomerAPI";
|
|||||||
import EventAPI from "@/api/EventAPI";
|
import EventAPI from "@/api/EventAPI";
|
||||||
import CodeScanner from "@/components/CodeScanner.vue";
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
import CustomerSelector from "@/components/CustomerSelector.vue";
|
import CustomerSelector from "@/components/CustomerSelector.vue";
|
||||||
|
import EventOptionSelector from "@/components/EventOptionSelector.vue";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
import type { Customer } from "@/types/Customer";
|
import type { Customer } from "@/types/Customer";
|
||||||
import type { EventItem } from "@/types/EventItem";
|
import type { EventItem, EventOptionSelectionMap } from "@/types/EventItem";
|
||||||
import type { PointAction } from "@/types/Point";
|
import type { PointAction } from "@/types/Point";
|
||||||
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
|
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
|
||||||
import { getAttachmentReadURL } from "@/utils/attachment";
|
import { getAttachmentReadURL } from "@/utils/attachment";
|
||||||
import { hasPhotoAttachment } from "@/utils/eventRecord";
|
import { hasPhotoAttachment } from "@/utils/eventRecord";
|
||||||
|
import { resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/utils/eventOption";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
import { loadSourceValueConfig, type SourceValueConfig } from "@/utils/point";
|
import { loadSourceValueConfig, type SourceValueConfig } from "@/utils/point";
|
||||||
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
|
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
|
||||||
@@ -180,6 +187,7 @@ const hasCustomerDeletePermission = computed(() => userStore.hasPermissions(perm
|
|||||||
const eventList = ref<EventItem[]>([]);
|
const eventList = ref<EventItem[]>([]);
|
||||||
|
|
||||||
const selectedEventIdList = ref<string[]>([]);
|
const selectedEventIdList = ref<string[]>([]);
|
||||||
|
const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({});
|
||||||
const numberValueTextMap = reactive<Record<string, string>>({});
|
const numberValueTextMap = reactive<Record<string, string>>({});
|
||||||
const selectedNumberEventList = computed(() => selectedEventIdList.value
|
const selectedNumberEventList = computed(() => selectedEventIdList.value
|
||||||
.map(eventId => eventList.value.find(item => item.id === eventId))
|
.map(eventId => eventList.value.find(item => item.id === eventId))
|
||||||
@@ -669,6 +677,7 @@ async function saveRecord(): Promise<void> {
|
|||||||
name: customerName.value.trim(),
|
name: customerName.value.trim(),
|
||||||
eventRecordList: selectedEventIdList.value.map(eventId => ({
|
eventRecordList: selectedEventIdList.value.map(eventId => ({
|
||||||
eventId,
|
eventId,
|
||||||
|
selectedOptionGroupList: resolveRecordOptionGroupList(eventId),
|
||||||
numberValue: resolveNumberValue(eventId),
|
numberValue: resolveNumberValue(eventId),
|
||||||
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
|
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
|
||||||
remark: note.value || "快速补录登记"
|
remark: note.value || "快速补录登记"
|
||||||
@@ -720,6 +729,9 @@ function resetFormAfterSubmit(): void {
|
|||||||
for (const eventId of Object.keys(numberValueTextMap)) {
|
for (const eventId of Object.keys(numberValueTextMap)) {
|
||||||
delete numberValueTextMap[eventId];
|
delete numberValueTextMap[eventId];
|
||||||
}
|
}
|
||||||
|
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
|
||||||
|
delete selectedOptionSelectionMap[eventId];
|
||||||
|
}
|
||||||
refreshInputTips();
|
refreshInputTips();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +769,9 @@ function validateForm(): string {
|
|||||||
if (!validateRequiredPhotoList()) {
|
if (!validateRequiredPhotoList()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
if (!validateEventOptionSelectionList()) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
if (!validateNumberValueList()) {
|
if (!validateNumberValueList()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -780,6 +795,26 @@ function validateRequiredPhotoList(): boolean {
|
|||||||
return true;
|
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 () => {
|
onMounted(async () => {
|
||||||
|
|||||||
@@ -16,6 +16,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="tips red">事件可多选,请注意勾选的登记事件</div>
|
<div class="tips red">事件可多选,请注意勾选的登记事件</div>
|
||||||
</div>
|
</div>
|
||||||
|
<event-option-selector
|
||||||
|
v-model="selectedOptionSelectionMap"
|
||||||
|
:event-list="eventList"
|
||||||
|
:selected-event-id-list="selectedEventIdList"
|
||||||
|
/>
|
||||||
<code-scanner
|
<code-scanner
|
||||||
visible
|
visible
|
||||||
:session-key="scannerSessionKey"
|
:session-key="scannerSessionKey"
|
||||||
@@ -133,14 +138,16 @@ import EventAPI from "@/api/EventAPI";
|
|||||||
import EventRecordAPI from "@/api/EventRecordAPI";
|
import EventRecordAPI from "@/api/EventRecordAPI";
|
||||||
import CodeScanner from "@/components/CodeScanner.vue";
|
import CodeScanner from "@/components/CodeScanner.vue";
|
||||||
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
|
||||||
|
import EventOptionSelector from "@/components/EventOptionSelector.vue";
|
||||||
import PhotoScroller from "@/components/PhotoScroller.vue";
|
import PhotoScroller from "@/components/PhotoScroller.vue";
|
||||||
import { useSettingStore } from "@/store/settingStore";
|
import { useSettingStore } from "@/store/settingStore";
|
||||||
import type { Customer } from "@/types/Customer";
|
import type { Customer } from "@/types/Customer";
|
||||||
import type { EventItem } from "@/types/EventItem";
|
import type { EventItem, EventOptionSelectionMap } from "@/types/EventItem";
|
||||||
import type { EventRecord } from "@/types/EventRecord";
|
import type { EventRecord } from "@/types/EventRecord";
|
||||||
import type { PhotoItem } from "@/types/Photo";
|
import type { PhotoItem } from "@/types/Photo";
|
||||||
import type { PointAction } from "@/types/Point";
|
import type { PointAction } from "@/types/Point";
|
||||||
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
|
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
|
||||||
|
import { resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/utils/eventOption";
|
||||||
import { permissionCodes } from "@/utils/permission";
|
import { permissionCodes } from "@/utils/permission";
|
||||||
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
|
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
|
||||||
import { compressUploadImageList } from "@/utils/imageCompression";
|
import { compressUploadImageList } from "@/utils/imageCompression";
|
||||||
@@ -163,6 +170,7 @@ const settingStore = useSettingStore();
|
|||||||
const EventItemStorageKey = "quick-scan-event-id";
|
const EventItemStorageKey = "quick-scan-event-id";
|
||||||
const selectedEventIdList = ref<string[]>([]);
|
const selectedEventIdList = ref<string[]>([]);
|
||||||
const eventList = ref<EventItem[]>([]);
|
const eventList = ref<EventItem[]>([]);
|
||||||
|
const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({});
|
||||||
const isEventLoading = ref(false);
|
const isEventLoading = ref(false);
|
||||||
const eventLoadFailed = ref(false);
|
const eventLoadFailed = ref(false);
|
||||||
const selectedEventList = computed(() => selectedEventIdList.value
|
const selectedEventList = computed(() => selectedEventIdList.value
|
||||||
@@ -225,6 +233,26 @@ async function handleEventSelectionChange(eventIdList: string[]): Promise<void>
|
|||||||
await loadSourceValueRule();
|
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 {
|
function syncStoredEventId(): void {
|
||||||
const firstEventId = selectedEventIdList.value[0];
|
const firstEventId = selectedEventIdList.value[0];
|
||||||
if (firstEventId) {
|
if (firstEventId) {
|
||||||
@@ -449,6 +477,9 @@ async function doSubmit(): Promise<void> {
|
|||||||
if (!validateRequiredPhotoList()) {
|
if (!validateRequiredPhotoList()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!validateEventOptionSelectionList()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validateNumberValueList()) {
|
if (!validateNumberValueList()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -463,6 +494,7 @@ async function doSubmit(): Promise<void> {
|
|||||||
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
|
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
|
||||||
eventId,
|
eventId,
|
||||||
customerId: customer.id,
|
customerId: customer.id,
|
||||||
|
selectedOptionGroupList: resolveRecordOptionGroupList(eventId),
|
||||||
pointValue: showSourceValueInput.value ? pointValue : undefined,
|
pointValue: showSourceValueInput.value ? pointValue : undefined,
|
||||||
numberValue: resolveNumberValue(eventId),
|
numberValue: resolveNumberValue(eventId),
|
||||||
remark: "客户快速扫码登记",
|
remark: "客户快速扫码登记",
|
||||||
@@ -476,6 +508,9 @@ async function doSubmit(): Promise<void> {
|
|||||||
for (const eventId of Object.keys(numberValueTextMap)) {
|
for (const eventId of Object.keys(numberValueTextMap)) {
|
||||||
delete numberValueTextMap[eventId];
|
delete numberValueTextMap[eventId];
|
||||||
}
|
}
|
||||||
|
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
|
||||||
|
delete selectedOptionSelectionMap[eventId];
|
||||||
|
}
|
||||||
recentRecordList.value = [];
|
recentRecordList.value = [];
|
||||||
latestDecodedText = "";
|
latestDecodedText = "";
|
||||||
scannerSessionKey.value += 1;
|
scannerSessionKey.value += 1;
|
||||||
|
|||||||
@@ -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 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<string, Record<string, string[]>>;
|
||||||
|
|
||||||
export type EventItem = {
|
export type EventItem = {
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,4 +46,5 @@ export type EventItem = {
|
|||||||
endAt?: number;
|
endAt?: number;
|
||||||
sort?: number;
|
sort?: number;
|
||||||
roleCodeList?: string[];
|
roleCodeList?: string[];
|
||||||
|
optionGroupList?: EventOptionGroup[];
|
||||||
} & Model;
|
} & Model;
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Attachment, BasePage, Model, PageResult, User } from "timi-web-api";
|
import type { Attachment, BasePage, Model, PageResult, User } from "timi-web-api";
|
||||||
import type { Customer } from "@/types/Customer";
|
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";
|
import type { PointLedgerChangeType } from "@/types/Point";
|
||||||
|
|
||||||
export type EventRecord = {
|
export type EventRecord = {
|
||||||
@@ -20,6 +26,7 @@ export type EventRecord = {
|
|||||||
registeredAt?: number;
|
registeredAt?: number;
|
||||||
registeredDateValue?: number;
|
registeredDateValue?: number;
|
||||||
businessDayNo?: number;
|
businessDayNo?: number;
|
||||||
|
selectedOptionGroupList?: EventRecordOptionGroup[];
|
||||||
|
|
||||||
customer?: Customer;
|
customer?: Customer;
|
||||||
event?: EventItem;
|
event?: EventItem;
|
||||||
@@ -36,6 +43,7 @@ export type EventRecordListItemView = {
|
|||||||
customer?: Customer;
|
customer?: Customer;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
event?: EventItem;
|
event?: EventItem;
|
||||||
|
selectedOptionGroupList?: EventRecordOptionGroup[];
|
||||||
registeredAt?: number;
|
registeredAt?: number;
|
||||||
verificationStatus?: VerificationStatus;
|
verificationStatus?: VerificationStatus;
|
||||||
verifierUserId?: string;
|
verifierUserId?: string;
|
||||||
@@ -54,8 +62,17 @@ export type EventRecordPage = {
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
beginAt?: number;
|
beginAt?: number;
|
||||||
endAt?: number;
|
endAt?: number;
|
||||||
|
optionIdList?: string[];
|
||||||
} & BasePage;
|
} & BasePage;
|
||||||
|
|
||||||
|
export type EventOptionStat = {
|
||||||
|
groupId?: string;
|
||||||
|
groupName?: string;
|
||||||
|
optionId?: string;
|
||||||
|
optionLabel?: string;
|
||||||
|
recordCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type EventRecordRankPage = {
|
export type EventRecordRankPage = {
|
||||||
storeId?: string;
|
storeId?: string;
|
||||||
customerId?: string;
|
customerId?: string;
|
||||||
|
|||||||
@@ -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<string>();
|
||||||
|
const groupIdSet = new Set<string>();
|
||||||
|
const optionIdSet = new Set<string>();
|
||||||
|
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<string>();
|
||||||
|
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<string, string[]> = {};
|
||||||
|
for (const group of selectedGroupList || []) {
|
||||||
|
if (group.groupId) {
|
||||||
|
groupMap[group.groupId] = (group.selectedOptionList || [])
|
||||||
|
.map(option => normalizeOptionId(option))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eventId ? {[eventId]: groupMap} : {};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user