add event option

This commit is contained in:
Timi
2026-08-30 23:36:59 +08:00
parent 82e2359857
commit f65c88920a
17 changed files with 1620 additions and 398 deletions
+111 -2
View File
@@ -46,6 +46,13 @@
<t-cell title="需要拍照" :note="event.requirePhoto ? '是' : '否'" />
<t-cell title="需要核销" :note="event.requireVerification ? '是' : '否'" />
<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 v-entrance-transition="210" v-if="event" title="生效时间" theme="card">
<t-cell title="起始时间" :note="formatDate(event.beginAt)" />
@@ -53,17 +60,48 @@
<t-cell title="创建时间" :note="formatDate(event.createdAt, '未知时间')" />
<t-cell title="更新时间" :note="formatDate(event.updatedAt, '未知时间')" />
</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>
<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>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { BottomPopup } from "timi-tdesign-mobile";
import { Time, Text } from "timi-web";
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
import EventOptionSelector from "@/components/EventOptionSelector.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
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 { Message } from "@/utils/message";
@@ -82,6 +120,7 @@ const defaultColor = "#2f88ff";
// ---------- 权限与门店 ----------
const canUpdate = computed(() => userStore.hasPermissions(permissionCodes.event.update));
const canDeleteEvent = computed(() => userStore.hasPermissions(permissionCodes.event.delete));
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const storeName = computed(() => {
const storeId = event.value?.storeId;
@@ -98,6 +137,15 @@ const storeName = computed(() => {
const event = ref<EventItem>();
const roleOptions = ref<Role[]>([]);
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> {
if (isGlobalManager.value) {
@@ -193,6 +241,58 @@ function formatDate(timestamp?: number, emptyText = "不限"): string {
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> {
@@ -286,5 +386,14 @@ onUnmounted(() => {
height: 1rem;
border-radius: 50%;
}
.delete-button {
width: calc(100% - 2rem);
margin: 0 1rem;
}
}
.option-popup {
padding: 1rem;
}
</style>
+19 -71
View File
@@ -1,5 +1,5 @@
<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-item label="颜色">
<t-button
@@ -86,6 +86,7 @@
<div class="picker-text" v-text="getStatusText(formModel.status)" />
</t-form-item>
</t-form>
<event-option-editor v-model="optionGroupList" />
<section class="roles" v-entrance-transition="70">
<div class="title">
<span>可登记角色</span>
@@ -103,19 +104,6 @@
</t-checkbox>
</t-checkbox-group>
</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>
<t-popup v-model="colorPickerVisible" placement="bottom" @opened="handleColorPickerOpened">
<div class="color-popup">
@@ -214,12 +202,13 @@
import { Time } from "timi-web";
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
import type { ColorObject } from "tdesign-mobile-vue";
import { DialogPlugin } from "tdesign-mobile-vue";
import EventAPI from "@/api/EventAPI";
import EventOptionEditor from "@/pages/event/component/EventOptionEditor.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
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 { normalizeEventOptionGroupList, prepareEventOptionGroupList, validateEventOptionGroupList } from "@/utils/eventOption";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message";
@@ -245,8 +234,6 @@ const moduleCode: ModuleCode = "GAO";
const isCreateMode = computed(() => !routeId.value);
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
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(() => ({
value: [permissionCodes.event.read, permissionCodes.user.read, savePermissionCode.value],
mode: "and" as const
@@ -256,7 +243,7 @@ const pageAccess = computed(() => ({
const roleOptions = ref<Role[]>([]);
const isSaving = ref(false);
const isRemoving = ref(false);
const optionGroupList = ref<EventOptionGroup[]>([]);
const formModel = reactive({
id: "",
storeId: "",
@@ -392,7 +379,6 @@ const limitValuePlaceholder = computed(() => {
}
return "请输入总次数上限";
});
// ---------- 颜色 ----------
function openColorPicker(): void {
@@ -571,6 +557,11 @@ function validateForm(): boolean {
Message.warning("请至少选择一个可登记角色");
return false;
}
const optionError = validateEventOptionGroupList(optionGroupList.value);
if (optionError) {
Message.warning(optionError);
return false;
}
if (isValueLimitType(formModel.limitType) && !resolveLimitValue()) {
Message.warning("请输入有效的约束数值");
return false;
@@ -642,12 +633,14 @@ async function saveEventItem(): Promise<void> {
limitValue: resolveLimitValue(),
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
roleCodeList: [...formModel.roleCodeList]
roleCodeList: [...formModel.roleCodeList],
optionGroupList: isCreateMode.value ? prepareEventOptionGroupList(optionGroupList.value) : undefined
};
if (isCreateMode.value) {
await EventAPI.create(payload);
} else {
await EventAPI.update(payload);
await EventAPI.updateOptionGroupList(formModel.id || routeId.value, prepareEventOptionGroupList(optionGroupList.value));
}
Message.success("登记事件已保存");
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> {
@@ -737,6 +691,7 @@ async function fillForm(): Promise<void> {
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
roleCodeList: [...(current.roleCodeList || [])]
});
optionGroupList.value = normalizeEventOptionGroupList(current.optionGroupList);
formModel.limitValueText = resolveLimitValueText(current.limitValue);
colorDraft.value = formModel.color;
}
@@ -757,11 +712,13 @@ onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.registration-event-edit {
.event-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem 0;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
.description {
width: 100%;
@@ -849,15 +806,6 @@ onUnmounted(() => navBarStore.clearRight());
}
}
.bottom-actions {
gap: .75rem;
margin: 1rem;
display: flex;
.delete {
flex: 1;
}
}
}
.picker-popup {
+29
View File
@@ -33,6 +33,19 @@
<t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" />
<t-cell title="备注" :note="Text.display(record.remark)" />
</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
v-if="canVerifyRecord && record?.verificationStatus === 'PENDING'"
class="verify-button cell-button bg-orange white"
@@ -69,7 +82,9 @@
import { PassTimeLabel, Text } from "timi-web";
import { useUserStore, type Attachment } from "timi-web-api";
import EventRecordAPI from "@/api/EventRecordAPI";
import CardContainer from "@/components/CardContainer.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import EventOptionSelector from "@/components/EventOptionSelector.vue";
import EventRecordVerifyDialog from "@/components/EventRecordVerifyDialog.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
@@ -77,6 +92,7 @@ import type { EventRecord } from "@/types/EventRecord";
import type { PhotoItem } from "@/types/Photo";
import type { PointLedgerChangeType } from "@/types/Point";
import { getAttachmentReadURL } from "@/utils/attachment";
import { buildEventOptionSelectionMap } from "@/utils/eventOption";
import { formatEventNumberValue } from "@/utils/eventValue";
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 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 {
if (value === undefined || value === null) {
@@ -322,6 +347,10 @@ onUnmounted(() => {
width: calc(100% - 2rem);
margin: 0 1rem;
}
.option-selector {
padding-left: 1rem;
}
}
:global(html.theme-dark) {
+46 -6
View File
@@ -44,6 +44,7 @@
:customer="selectedCustomer"
v-entrance-transition
/>
<div v-if="isEditMode" class="edit-point-tip red">涉及积分变动的数据不能修改,已产生积分流水的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建,删除会自动回流积分。</div>
<event-selector
v-model="selectedEventIdList"
class="event-selector"
@@ -51,6 +52,13 @@
:disabled="isEditMode"
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">
<t-form-item
v-for="item in selectedNumberEventList"
@@ -68,7 +76,6 @@
/>
</t-form-item>
</div>
<div v-if="isEditMode" class="edit-point-tip">已产生积分的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建。</div>
<t-form-item v-if="showSourceValueInput" label="积分动作">
<span class="picker-text" v-text="getPointActionText(sourceValueAction)" />
</t-form-item>
@@ -202,15 +209,17 @@ import CodeScanner from "@/components/CodeScanner.vue";
import CustomerList from "@/components/CustomerList.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import EventOptionSelector from "@/components/EventOptionSelector.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import EventSelector from "@/components/EventSelector.vue";
import { useNavBarStore } from "@/store/navBarStore";
import { useSettingStore } from "@/store/settingStore";
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 { PointAction } from "@/types/Point";
import { getAttachmentReadURL } from "@/utils/attachment";
import { buildEventOptionSelectionMap, resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/utils/eventOption";
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
@@ -240,7 +249,7 @@ type EditablePhoto = {
const routeId = computed(() => String(route.params.id || ""));
const isEditMode = computed(() => !!routeId.value);
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 selectedEventIdList = ref<string[]>([]);
const selectedOptionSelectionMap = reactive<EventOptionSelectionMap>({});
const numberValueTextMap = reactive<Record<string, string>>({});
const selectedNumberEventList = computed(() => selectedEventIdList.value
.map(eventId => eventList.value.find(item => item.id === eventId))
@@ -432,6 +442,11 @@ function appendRecordEvent(record: EventRecord): void {
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 {
return getEventNumberValuePlaceholder(event);
}
@@ -629,6 +644,7 @@ async function submitRecord(): Promise<void> {
await EventRecordAPI.createBatch(selectedEventIdList.value.map(eventId => ({
eventId,
customerIdList,
selectedOptionGroupList: resolveRecordOptionGroupList(eventId),
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
@@ -646,6 +662,9 @@ async function submitRecord(): Promise<void> {
for (const eventId of Object.keys(numberValueTextMap)) {
delete numberValueTextMap[eventId];
}
for (const eventId of Object.keys(selectedOptionSelectionMap)) {
delete selectedOptionSelectionMap[eventId];
}
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
Message.success("登记已提交");
@@ -664,6 +683,17 @@ function validateForm(): boolean {
Message.warning("请至少选择一个登记事件");
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()) {
return false;
}
@@ -698,9 +728,10 @@ async function updateRecord(): Promise<void> {
}
await EventRecordAPI.update({
...editingRecord.value,
id: editingRecord.value.id,
customerId: editingRecord.value.customerId,
eventId: editingRecord.value.eventId,
selectedOptionGroupList: resolveRecordOptionGroupList(editingRecord.value.eventId),
remark: note.value,
registeredAt: editingRecord.value.registeredAt,
attachmentList: buildPhotoAttachmentList()
@@ -749,6 +780,12 @@ async function loadRecordForEdit(): Promise<void> {
customerCode.value = record.customer?.code || record.customerId || "";
selectedCustomerIdList.value = record.customer?.id ? [record.customer.id] : [];
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)) : "";
note.value = record.remark || "";
pointValueText.value = "";
@@ -793,7 +830,7 @@ watch(customerCode, async (value) => {
.registration-record-edit {
gap: 1rem;
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;
.form,
@@ -843,6 +880,10 @@ watch(customerCode, async (value) => {
}
}
.option-selector {
margin: 1rem 0 1rem 1rem;
}
.time-actions {
gap: .5rem;
display: flex;
@@ -851,7 +892,6 @@ watch(customerCode, async (value) => {
}
.edit-point-tip {
color: #9a3412;
padding: 0 1rem 1rem;
font-size: .8125rem;
margin-top: .25rem;
+116 -278
View File
@@ -28,6 +28,14 @@
:label="verificationFilterLabel"
:options="verificationOptions"
/>
<t-dropdown-item
v-if="optionFilterOptions.length"
v-model="optionFilter"
:label="optionFilterLabel"
options-columns="2"
:options="optionFilterOptions"
multiple
/>
</t-dropdown-menu>
</div>
</div>
@@ -52,86 +60,36 @@
@click="openEndTime"
/>
<t-cell title="事件" :note="eventFilterLabel" />
<t-cell v-if="optionFilterLabel !== '全部选项'" title="选项" :note="optionFilterLabel" />
<t-cell title="数据量" :note="`共 ${pageResult.total} 条记录`" />
<t-cell v-if="summaryNumberValueText" title="数值合计" :note="summaryNumberValueText" />
</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
v-if="pageResult.list.length"
class="record-list"
theme="card"
>
<t-swipe-cell
v-entrance-transition="index * 30"
v-for="(item, index) in pageResult.list"
:key="item.id"
class="item"
>
<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>
<event-record-cell
v-entrance-transition="index * 30"
v-for="(item, index) in pageResult.list"
:key="item.id"
:record="item"
@updated="reloadRecords"
/>
</t-cell-group>
<div v-else class="load-state" v-text="isLoading ? '正在加载登记事件记录' : '暂无登记事件记录'" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
</div>
</t-pull-down-refresh>
</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" />
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
@@ -160,23 +118,21 @@
</template>
<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 { useUserStore, type Attachment } from "timi-web-api";
import { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
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 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 { getCustomerPhotoURL } from "@/utils/customer";
import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
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;
defineOptions({
@@ -191,8 +147,9 @@ const userStore = useUserStore();
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "前天", value: "beforeYesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "上个月", value: "lastMonth" },
{ label: "全部", value: "all" },
{ label: "自定义范围", value: "custom" }
];
@@ -206,6 +163,7 @@ const dateRange = ref(parseDateRange(dateFilterType.value));
const startDraft = ref("");
const endDraft = ref("");
const actionFilter = ref<string[]>([]);
const optionFilter = ref<string[]>([]);
const verificationOptions: Array<{ label: string; value: VerificationFilter }> = [
{ label: "全部核销状态", value: "ALL" },
{ label: "待核销", value: "PENDING" },
@@ -219,6 +177,7 @@ const endVisible = ref(false);
let keywordTimer: number | undefined;
let initialized = false;
const isExporting = ref(false);
let suppressOptionFilterReload = false;
const canCreateRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.create));
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "今天");
@@ -236,6 +195,34 @@ const eventFilterLabel = computed(() => {
.map(item => item.name || "未命名事件")
.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 selectedNumberEvent = computed(() => {
if (actionFilter.value.length !== 1) {
@@ -244,10 +231,8 @@ const selectedNumberEvent = computed(() => {
const event = eventList.value.find(item => item.id === actionFilter.value[0]);
return event?.valueType === "NUMBER" ? event : undefined;
});
const hasSelectedOption = computed(() => !!selectedOptionEvent.value);
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 actionList = computed<NavBarAction[]>(() => {
@@ -288,6 +273,24 @@ watch(actionFilter, async () => {
if (!initialized) {
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 reloadRecords();
});
@@ -328,6 +331,7 @@ const pageResult = reactive<EventRecordPageResult>({
list: [],
numberValueTotal: null
});
const optionStatList = ref<EventOptionStat[]>([]);
let loadToken = 0;
const summaryNumberValueText = computed(() => {
@@ -350,8 +354,9 @@ async function reloadRecords(): Promise<void> {
pageResult.total = 0;
pageResult.list = [];
pageResult.numberValueTotal = null;
optionStatList.value = [];
isNoMore.value = false;
await loadRecords();
await Promise.all([loadRecords(), loadOptionStats()]);
} finally {
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> {
eventList.value = await EventAPI.listValid();
const eventIdList = parseEventIdList(route.query.eventIds);
actionFilter.value = eventIdList.length
? eventIdList
: eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
const validEventIdList = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item));
const validEventIdSet = new Set(validEventIdList);
const eventIdList = parseEventIdList(route.query.eventIds).filter(eventId => validEventIdSet.has(eventId));
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> {
@@ -479,68 +477,23 @@ async function exportExcel(): Promise<void> {
}
}
async function confirmVerifyRecord(record: EventRecordListItemView): Promise<void> {
if (!record.id || record.verificationStatus !== "PENDING" || isVerifying.value || isLoadingVerifyRecord.value) {
async function loadOptionStats(): Promise<void> {
if (!hasSelectedOption.value) {
optionStatList.value = [];
return;
}
const recordId = record.id;
isLoadingVerifyRecord.value = true;
verifyLoadingId.value = recordId;
try {
verifyTarget.value = await EventRecordAPI.detail(recordId);
verifyDialogVisible.value = true;
optionStatList.value = await EventRecordAPI.optionStat({
index: 0,
size: 1,
...buildRangeReq()
});
} catch (error) {
Message.error(getErrorMessage(error, "登记记录加载失败"));
} finally {
isLoadingVerifyRecord.value = false;
verifyLoadingId.value = "";
optionStatList.value = [];
console.warn("登记选项统计加载失败", error);
}
}
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) {
Message.error(getErrorMessage(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> {
await router.push({
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"> {
const [beginAt, endAt] = buildUnixRange();
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,
keyword: keyword.value.trim() || undefined,
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
};
}
@@ -681,6 +556,7 @@ function buildFilterQuery(transitionDepth = getQueryText("transitionDepth")): Re
customerId: customerId.value,
customerName: getQueryText("customerName"),
eventIds: actionFilter.value.join(","),
optionIds: optionFilter.value.join(","),
verificationStatus: verificationFilter.value === "ALL" ? "" : verificationFilter.value
};
if (isValidTransitionDepth(transitionDepth)) {
@@ -733,11 +609,21 @@ function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, str
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
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,
yesterday: 1,
beforeYesterday: 2
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
@@ -860,56 +746,8 @@ onUnmounted(() => {
.record-list {
.item {
&:last-child {
:deep(.t-cell:after) {
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;
}
:deep(.item:last-child .cell:after) {
display: none;
}
}
}
@@ -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>