From 2d94abfb32c17696859f18b768d5fda63043c875 Mon Sep 17 00:00:00 2001 From: Timi Date: Tue, 1 Sep 2026 01:14:54 +0800 Subject: [PATCH] add package --- patches/tdesign-mobile-vue@1.16.0.patch | 45 ++ pnpm-lock.yaml | 31 +- src/api/EventAPI.ts | 4 +- src/api/PackageAPI.ts | 190 ++++++++ src/components/CustomerSelector.vue | 13 +- src/components/PackagePhotoField.vue | 89 ++++ src/components/navbar/index.vue | 23 +- src/pages/event/EventDetail.vue | 33 +- src/pages/event/EventEdit.vue | 155 ++++++- src/pages/event/EventList.vue | 5 +- src/pages/event/EventRecordDetail.vue | 23 +- src/pages/event/EventRecordEdit.vue | 37 +- src/pages/event/EventRecordList.vue | 13 +- src/pages/event/component/EventRecordCell.vue | 8 +- src/pages/home/QuickSignInPage.vue | 3 +- src/pages/package/CustomerPackageCancel.vue | 94 ++++ src/pages/package/CustomerPackageDelivery.vue | 226 ++++++++++ src/pages/package/CustomerPackageDetail.vue | 278 ++++++++++++ src/pages/package/CustomerPackageList.vue | 210 +++++++++ src/pages/package/CustomerPackagePayment.vue | 151 +++++++ src/pages/package/CustomerPackagePurchase.vue | 405 ++++++++++++++++++ src/pages/package/CustomerPackageRefund.vue | 173 ++++++++ src/pages/package/FundRecordDetail.vue | 190 ++++++++ src/pages/package/FundRecordList.vue | 168 ++++++++ src/pages/package/FundRecordRevoke.vue | 152 +++++++ src/pages/package/PackageConfigDetail.vue | 176 ++++++++ src/pages/package/PackageConfigEdit.vue | 358 ++++++++++++++++ src/pages/package/PackageConfigList.vue | 233 ++++++++++ src/pages/package/PackageDeliveryDetail.vue | 203 +++++++++ src/pages/package/PackageDeliveryList.vue | 157 +++++++ src/pages/package/PackageDeliveryRevoke.vue | 152 +++++++ src/pages/tabs/SettingTab.vue | 29 ++ src/router/index.ts | 2 + src/router/routes/packageRoutes.ts | 51 +++ src/types/EventItem.ts | 5 + src/types/Package.ts | 212 +++++++++ src/utils/event.ts | 11 + src/utils/package.ts | 130 ++++++ src/utils/permission.ts | 46 ++ 39 files changed, 4425 insertions(+), 59 deletions(-) create mode 100644 src/api/PackageAPI.ts create mode 100644 src/components/PackagePhotoField.vue create mode 100644 src/pages/package/CustomerPackageCancel.vue create mode 100644 src/pages/package/CustomerPackageDelivery.vue create mode 100644 src/pages/package/CustomerPackageDetail.vue create mode 100644 src/pages/package/CustomerPackageList.vue create mode 100644 src/pages/package/CustomerPackagePayment.vue create mode 100644 src/pages/package/CustomerPackagePurchase.vue create mode 100644 src/pages/package/CustomerPackageRefund.vue create mode 100644 src/pages/package/FundRecordDetail.vue create mode 100644 src/pages/package/FundRecordList.vue create mode 100644 src/pages/package/FundRecordRevoke.vue create mode 100644 src/pages/package/PackageConfigDetail.vue create mode 100644 src/pages/package/PackageConfigEdit.vue create mode 100644 src/pages/package/PackageConfigList.vue create mode 100644 src/pages/package/PackageDeliveryDetail.vue create mode 100644 src/pages/package/PackageDeliveryList.vue create mode 100644 src/pages/package/PackageDeliveryRevoke.vue create mode 100644 src/router/routes/packageRoutes.ts create mode 100644 src/types/Package.ts create mode 100644 src/utils/event.ts create mode 100644 src/utils/package.ts diff --git a/patches/tdesign-mobile-vue@1.16.0.patch b/patches/tdesign-mobile-vue@1.16.0.patch index d78b689..682a5dd 100644 --- a/patches/tdesign-mobile-vue@1.16.0.patch +++ b/patches/tdesign-mobile-vue@1.16.0.patch @@ -82,3 +82,48 @@ diff --git a/cjs/swiper/type.d.ts b/cjs/swiper/type.d.ts + navigation?: boolean | SwiperNavigation | TNode; nextMargin?: string | number; previousMargin?: string | number; +diff --git a/es/shared/hover.mjs b/es/shared/hover.mjs +--- a/es/shared/hover.mjs ++++ b/es/shared/hover.mjs +@@ -50,29 +50,33 @@ + add: function add() { +- if (ctx.current.disabledHover || !ctx.current.className) return; +- if (!el.classList.contains(ctx.current.className)) { +- el.classList.add(ctx.current.className); ++ var current = ctx.current; ++ if (!current || current.disabledHover || !current.className) return; ++ if (!el.classList.contains(current.className)) { ++ el.classList.add(current.className); + } + }, + remove: function remove() { +- if (ctx.current.className) el.classList.remove(ctx.current.className); ++ var current = ctx.current; ++ if (current && current.className) el.classList.remove(current.className); + }, + clearTimers: function clearTimers() { + ctx.timers.forEach(function (t) { + return window.clearTimeout(t); + }); + ctx.timers = []; + }, + downHandler: function downHandler() { +- if (ctx.current.disabledHover) return; ++ var current = ctx.current; ++ if (!current || current.disabledHover) return; + ctx.clearTimers(); + ctx.timers.push(window.setTimeout(function () { + ctx.add(); +- }, ctx.current.startTime)); ++ }, current.startTime)); + }, + upHandler: function upHandler() { +- if (ctx.current.disabledHover) return; ++ var current = ctx.current; ++ if (!current || current.disabledHover) return; + ctx.clearTimers(); + ctx.timers.push(window.setTimeout(function () { + ctx.remove(); +- }, ctx.current.stayTime)); ++ }, current.stayTime)); + }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8548d22..6e77b6c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: patchedDependencies: tdesign-mobile-vue@1.16.0: - hash: 2xgxuymu3bzalw6z7rwbgszafy + hash: zwjpdqqohmbknbgri7osfh6nzy path: patches/tdesign-mobile-vue@1.16.0.patch importers: @@ -33,10 +33,10 @@ importers: version: 3.0.4(typescript@5.8.3)(vue@3.5.40(typescript@5.8.3)) tdesign-mobile-vue: specifier: ^1.16.0 - version: 1.16.0(patch_hash=2xgxuymu3bzalw6z7rwbgszafy)(vue@3.5.40(typescript@5.8.3)) + version: 1.16.0(patch_hash=zwjpdqqohmbknbgri7osfh6nzy)(vue@3.5.40(typescript@5.8.3)) timi-tdesign-mobile: - specifier: 0.0.10 - version: 0.0.10(typescript@5.8.3) + specifier: link:E:/WebProject/timi-tdesign-mobile + version: link:../timi-tdesign-mobile timi-web: specifier: link:E:/WebProject/timi-web version: link:../timi-web @@ -1735,10 +1735,6 @@ packages: engines: {node: '>=10'} hasBin: true - timi-tdesign-mobile@0.0.10: - resolution: {integrity: sha512-fv83++YkMkeKTkReR/2+RoaSacj6fv5JHJfJ1P3C4p/KmLn9XTe+3jHYlFEWjQdxbehz9BifX1L8rVHmgScP5g==} - engines: {node: '>=16.0.0'} - tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} @@ -1960,11 +1956,6 @@ packages: peerDependencies: vue: ^3.0.0 - vue-router@4.5.0: - resolution: {integrity: sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==} - peerDependencies: - vue: ^3.2.0 - vue-router@4.5.1: resolution: {integrity: sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==} peerDependencies: @@ -3658,7 +3649,7 @@ snapshots: '@babel/runtime': 7.29.7 vue: 3.5.40(typescript@5.8.3) - tdesign-mobile-vue@1.16.0(patch_hash=2xgxuymu3bzalw6z7rwbgszafy)(vue@3.5.40(typescript@5.8.3)): + tdesign-mobile-vue@1.16.0(patch_hash=zwjpdqqohmbknbgri7osfh6nzy)(vue@3.5.40(typescript@5.8.3)): dependencies: '@babel/runtime': 7.29.7 '@use-gesture/vanilla': 10.3.1 @@ -3680,13 +3671,6 @@ snapshots: source-map-support: 0.5.21 optional: true - timi-tdesign-mobile@0.0.10(typescript@5.8.3): - dependencies: - vue: 3.5.40(typescript@5.8.3) - vue-router: 4.5.0(vue@3.5.40(typescript@5.8.3)) - transitivePeerDependencies: - - typescript - tinycolor2@1.6.0: {} tinyglobby@0.2.17: @@ -3890,11 +3874,6 @@ snapshots: dependencies: vue: 3.5.40(typescript@5.8.3) - vue-router@4.5.0(vue@3.5.40(typescript@5.8.3)): - dependencies: - '@vue/devtools-api': 6.6.4 - vue: 3.5.40(typescript@5.8.3) - vue-router@4.5.1(vue@3.5.40(typescript@5.8.3)): dependencies: '@vue/devtools-api': 6.6.4 diff --git a/src/api/EventAPI.ts b/src/api/EventAPI.ts index 720a92e..f2b0862 100644 --- a/src/api/EventAPI.ts +++ b/src/api/EventAPI.ts @@ -62,9 +62,9 @@ async function list(req: Page, options?: ListOptions): Promise { +async function listValid(storeId?: string): Promise { const list = await axios.post(`${BASE_URI}/list/valid`, undefined, { - params: { storeId: getCurrentStoreId() } + params: { storeId: storeId || getCurrentStoreId() } }) as unknown as EventItem[]; return sortEventList(list); } diff --git a/src/api/PackageAPI.ts b/src/api/PackageAPI.ts new file mode 100644 index 0000000..c052973 --- /dev/null +++ b/src/api/PackageAPI.ts @@ -0,0 +1,190 @@ +import { axios } from "timi-web-api"; +import { getCurrentStoreId } from "@/utils/store"; +import type { + CustomerPackagePage, + DeliveryPage, + FundRecordPage, + GaoCustomerPackage, + GaoFundRecord, + GaoPackageConfig, + GaoPackageDelivery, + PackageConfigPage, + PackagePageResult, + PackagePaymentReceiveRequest, + PackagePurchaseRequest, + PackageDeliveryCreateRequest, + PackageAttachment +} from "@/types/Package"; + +const CONFIG_URI = "/gao/package/config"; +const CUSTOMER_PACKAGE_URI = "/gao/customer/package"; +const DELIVERY_URI = "/gao/package/delivery"; +const FUND_URI = "/gao/fund/record"; + +function withStore(request: T): T { + const storeId = request.storeId || getCurrentStoreId(); + return storeId ? { ...request, storeId } : request; +} + +async function configList(page: PackageConfigPage): Promise> { + return await axios.post(`${CONFIG_URI}/list`, withStore(page)); +} + +async function configListValid(storeId?: string): Promise { + return await axios.post(`${CONFIG_URI}/list/valid`, undefined, { + params: { storeId: storeId || getCurrentStoreId() } + }); +} + +async function configDetail(id: string): Promise { + return await axios.post(`${CONFIG_URI}/detail`, undefined, { params: { id } }); +} + +async function configCreate(config: GaoPackageConfig): Promise { + return await axios.post(`${CONFIG_URI}/create`, withStore(config)); +} + +async function configUpdate(config: GaoPackageConfig): Promise { + await axios.post(`${CONFIG_URI}/update`, withStore(config)); +} + +async function configRemove(id: string): Promise { + await axios.post(`${CONFIG_URI}/delete`, undefined, { params: { id } }); +} + +async function customerPackageList(page: CustomerPackagePage): Promise> { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/list`, withStore(page)); +} + +async function customerPackageDetail(id: string): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/detail`, undefined, { params: { id } }); +} + +async function purchase(request: PackagePurchaseRequest): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/purchase`, request); +} + +async function cancel(customerPackageId: string, reason: string): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/cancel`, { customerPackageId, reason }); +} + +async function receivePayment(request: PackagePaymentReceiveRequest): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/receive`, request); +} + +async function verifyPayment(fundRecordId: string): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/verify`, undefined, { params: { id: fundRecordId } }); +} + +async function rejectPayment(fundRecordId: string, reason: string): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/reject`, { fundRecordId, reason }); +} + +async function cancelPayment(fundRecordId: string, reason: string): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/cancel`, { fundRecordId, reason }); +} + +async function refund(request: { + requestNo: string; + customerPackageId: string; + amount: number; + occurredAt?: number; + reason: string; + attachmentList: PackageAttachment[]; + remark?: string | null; +}): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/refund`, request); +} + +async function confirmNoRefund(request: { + requestNo: string; + customerPackageId: string; + occurredAt?: number; + reason: string; + remark?: string | null; +}): Promise { + return await axios.post(`${CUSTOMER_PACKAGE_URI}/refund/confirm-none`, request); +} + +async function deliveryList(page: DeliveryPage): Promise> { + return await axios.post(`${DELIVERY_URI}/list`, withStore(page)); +} + +async function deliveryDetail(id: string): Promise { + return await axios.post(`${DELIVERY_URI}/detail`, undefined, { params: { id } }); +} + +async function createDelivery(request: PackageDeliveryCreateRequest): Promise { + return await axios.post(`${DELIVERY_URI}/create`, request); +} + +async function verifyDelivery(id: string): Promise { + return await axios.post(`${DELIVERY_URI}/verify`, undefined, { params: { id } }); +} + +async function rejectDelivery(deliveryId: string, reason: string): Promise { + return await axios.post(`${DELIVERY_URI}/reject`, { deliveryId, reason }); +} + +async function cancelDelivery(deliveryId: string, reason: string): Promise { + return await axios.post(`${DELIVERY_URI}/cancel`, { deliveryId, reason }); +} + +async function revokeDelivery(request: { + requestNo: string; + deliveryId: string; + deliveredAt?: number; + reason: string; + attachmentList: PackageAttachment[]; + remark?: string | null; +}): Promise { + return await axios.post(`${DELIVERY_URI}/revoke`, request); +} + +async function fundRecordList(page: FundRecordPage): Promise> { + return await axios.post(`${FUND_URI}/list`, withStore(page)); +} + +async function fundRecordDetail(id: string): Promise { + return await axios.post(`${FUND_URI}/detail`, undefined, { params: { id } }); +} + +async function revokeFundRecord(request: { + requestNo: string; + fundRecordId: string; + occurredAt?: number; + reason: string; + attachmentList: PackageAttachment[]; + remark?: string | null; +}): Promise { + return await axios.post(`${FUND_URI}/revoke`, request); +} + +export default { + configList, + configListValid, + configDetail, + configCreate, + configUpdate, + configRemove, + customerPackageList, + customerPackageDetail, + purchase, + cancel, + receivePayment, + verifyPayment, + rejectPayment, + cancelPayment, + refund, + confirmNoRefund, + deliveryList, + deliveryDetail, + createDelivery, + verifyDelivery, + rejectDelivery, + cancelDelivery, + revokeDelivery, + fundRecordList, + fundRecordDetail, + revokeFundRecord +}; diff --git a/src/components/CustomerSelector.vue b/src/components/CustomerSelector.vue index 95e4de8..5b2c5ff 100644 --- a/src/components/CustomerSelector.vue +++ b/src/components/CustomerSelector.vue @@ -43,10 +43,12 @@ interface Props { modelValue: string | string[]; visible: boolean; selectionMode?: SelectionMode; + storeId?: string; } const props = withDefaults(defineProps(), { - selectionMode: "single" + selectionMode: "single", + storeId: "" }); const emit = defineEmits<{ @@ -100,6 +102,7 @@ async function loadCustomers(): Promise { const result = await CustomerAPI.list({ index: pageIndex.value, size: pageSize, + storeId: props.storeId || undefined, likesExample: { code: keyword.value || undefined, name: keyword.value || undefined, @@ -195,6 +198,14 @@ watch(() => props.visible, async (value) => { await bindScrollListener(); }); +watch(() => props.storeId, async () => { + if (!props.visible) { + return; + } + await reloadCustomers(); + await bindScrollListener(); +}); + watch(keyword, async () => { if (!props.visible) { return; diff --git a/src/components/PackagePhotoField.vue b/src/components/PackagePhotoField.vue new file mode 100644 index 0000000..d0b37f5 --- /dev/null +++ b/src/components/PackagePhotoField.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/src/components/navbar/index.vue b/src/components/navbar/index.vue index adf9c5d..9dbd614 100644 --- a/src/components/navbar/index.vue +++ b/src/components/navbar/index.vue @@ -126,14 +126,27 @@ function getUserAgentMajorVersion(userAgent: string, pattern: RegExp): number { // ---------- 导航返回 ---------- -function doBack(): void { - if (navBarStore.backTo) { - router.push(navBarStore.backTo); - navBarStore.setBackTo(undefined); +let isBacking = false; + +async function doBack(): Promise { + if (isBacking) { return; } - router.back(); + isBacking = true; + const backTo = navBarStore.backTo; + navBarStore.setBackTo(undefined); + navBarStore.clearRight(); + try { + if (backTo) { + await router.push(backTo); + return; + } + + await router.back(); + } finally { + isBacking = false; + } } // ---------- 右侧操作 ---------- diff --git a/src/pages/event/EventDetail.vue b/src/pages/event/EventDetail.vue index c4865fe..d67c1e3 100644 --- a/src/pages/event/EventDetail.vue +++ b/src/pages/event/EventDetail.vue @@ -15,6 +15,15 @@ + + + + + @@ -116,6 +130,8 @@ import EventRemoveDialog from "@/pages/event/component/EventRemoveDialog.vue"; import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useNavBarStore } from "@/store/navBarStore"; import type { EventItem, EventLimitType, EventOptionSelectionMap, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem"; +import { getEventBizTypeText, isPackagePurchaseEvent } from "@/utils/event"; +import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { Message } from "@/utils/message"; @@ -309,7 +325,7 @@ async function removeEvent(eventItem: EventItem): Promise { async function toEventRecordList(): Promise { const eventId = event.value?.id || String(route.params.id || ""); - if (!eventId || !canReadEventRecord.value) { + if (!eventId || !canReadEventRecord.value || isPackagePurchaseEvent(event.value)) { return; } await router.push({ @@ -417,7 +433,8 @@ onUnmounted(() => { } .color-note, - .tag-list { + .tag-list, + .package-note { gap: .4rem; display: flex; align-items: center; @@ -425,6 +442,16 @@ onUnmounted(() => { flex-wrap: wrap; } + .package-note { + flex-direction: column; + align-items: flex-end; + + span { + color: var(--td-text-color-secondary); + font-size: .8125rem; + } + } + .color-dot { width: 1rem; height: 1rem; diff --git a/src/pages/event/EventEdit.vue b/src/pages/event/EventEdit.vue index fb6d749..617539b 100644 --- a/src/pages/event/EventEdit.vue +++ b/src/pages/event/EventEdit.vue @@ -25,6 +25,15 @@
+ +
+ +
+ 套餐购买事件必须绑定一个套餐配置。实际购买时只能使用此处绑定的套餐,不能临时更换。 +
+ +
+ - +
@@ -157,6 +166,24 @@ @confirm="confirmStorePicker" /> + + + + + + ([]); const formModel = reactive({ id: "", storeId: "", + bizType: "GENERAL" as EventBizType, + packageConfigId: "", name: "", description: "", color: "#0f766e", @@ -279,6 +312,93 @@ const formModel = reactive({ roleCodeList: [] as string[] }); +// ---------- 业务类型 ---------- + +const bizTypeOptions: Array<{ label: string; value: EventBizType }> = [ + { label: "普通登记", value: "GENERAL" }, + { label: "套餐购买", value: "PACKAGE_PURCHASE" } +]; +const bizTypePickerVisible = ref(false); +const bizTypePickerDraft = ref([formModel.bizType]); +const bizTypePickerColumns = [bizTypeOptions]; + +function getBizTypeText(value: EventBizType): string { + return getEventBizTypeText(value); +} + +function openBizTypePicker(): void { + if (!isCreateMode.value) { + return; + } + bizTypePickerDraft.value = [formModel.bizType]; + bizTypePickerVisible.value = true; +} + +async function confirmBizTypePicker(value: Array): Promise { + const bizType = value[0]; + if (bizType !== "GENERAL" && bizType !== "PACKAGE_PURCHASE") { + return; + } + formModel.bizType = bizType; + if (bizType === "PACKAGE_PURCHASE") { + formModel.requireVerification = false; + await loadPackageConfigList(); + } else { + formModel.packageConfigId = ""; + } + bizTypePickerVisible.value = false; +} + +// ---------- 套餐绑定 ---------- + +const packageConfigList = ref([]); +const packageConfigPickerVisible = ref(false); +const packageConfigPickerDraft = ref([]); +const packageConfigPickerColumns = computed(() => [[...packageConfigList.value].map(item => ({ + label: `${item.name || "未命名套餐"}(${getPackageConfigStatusText(item.status)})`, + value: item.id || "" +}))]); +const selectedPackageConfig = computed(() => packageConfigList.value.find(item => item.id === formModel.packageConfigId)); + +async function loadPackageConfigList(): Promise { + if (formModel.bizType !== "PACKAGE_PURCHASE" || !formModel.storeId) { + packageConfigList.value = []; + return; + } + try { + const result = await PackageAPI.configList({ + storeId: formModel.storeId, + index: 0, + size: 1000 + }); + packageConfigList.value = result.list || []; + } catch (error) { + packageConfigList.value = []; + Message.error(getErrorMessage(error, "套餐配置加载失败")); + } +} + +function openPackageConfigPicker(): void { + if (formModel.bizType !== "PACKAGE_PURCHASE") { + return; + } + if (!packageConfigList.value.length) { + Message.warning("当前门店没有可绑定的套餐配置"); + return; + } + packageConfigPickerDraft.value = [formModel.packageConfigId]; + packageConfigPickerVisible.value = true; +} + +function confirmPackageConfigPicker(value: Array): void { + const packageConfigId = String(value[0] || ""); + if (!packageConfigList.value.some(item => item.id === packageConfigId)) { + return; + } + formModel.packageConfigId = packageConfigId; + packageConfigPickerVisible.value = false; +} + // ---------- 门店选择 ---------- const storePickerVisible = ref(false); @@ -309,13 +429,16 @@ function openStorePicker(): void { storePickerVisible.value = true; } -function confirmStorePicker(): void { +async function confirmStorePicker(): Promise { const storeId = String(storePickerDraft.value[0] || ""); if (!storeId) { return; } formModel.storeId = storeId; + if (formModel.bizType === "PACKAGE_PURCHASE") { + await loadPackageConfigList(); + } storePickerVisible.value = false; } @@ -573,6 +696,10 @@ function validateForm(): boolean { Message.warning("请至少选择一个可登记角色"); return false; } + if (formModel.bizType === "PACKAGE_PURCHASE" && (!formModel.packageConfigId || !selectedPackageConfig.value)) { + Message.warning("请选择绑定套餐配置"); + return false; + } const optionError = validateEventOptionGroupList(optionGroupList.value); if (optionError) { Message.warning(optionError); @@ -582,6 +709,10 @@ function validateForm(): boolean { Message.warning("请输入有效的约束数值"); return false; } + if (formModel.bizType === "PACKAGE_PURCHASE" && formModel.requireVerification) { + Message.warning("套餐购买事件不能要求核销"); + return false; + } const beginAt = resolveTimestamp(formModel.beginAtText); const endAt = resolveTimestamp(formModel.endAtText); if (beginAt !== undefined && endAt !== undefined && endAt < beginAt) { @@ -636,6 +767,8 @@ async function saveEventItem(): Promise { const payload: EventItem = { id: formModel.id || undefined, storeId: formModel.storeId, + bizType: formModel.bizType, + packageConfigId: formModel.bizType === "PACKAGE_PURCHASE" ? formModel.packageConfigId : undefined, name: formModel.name.trim(), description: formModel.description, color: formModel.color, @@ -698,6 +831,8 @@ async function fillForm(): Promise { Object.assign(formModel, { id: current.id || "", storeId: current.storeId || formModel.storeId, + bizType: current.bizType || "GENERAL", + packageConfigId: current.packageConfigId || "", name: current.name || "", description: current.description || "", color: current.color || "#2f88ff", @@ -712,6 +847,12 @@ async function fillForm(): Promise { endAtText: current.endAt ? Time.toDateTime(current.endAt) : "", roleCodeList: [...(current.roleCodeList || [])] }); + if (formModel.bizType === "PACKAGE_PURCHASE") { + await loadPackageConfigList(); + if (current.packageConfig && !packageConfigList.value.some(item => item.id === current.packageConfig?.id)) { + packageConfigList.value.unshift(current.packageConfig); + } + } optionGroupList.value = normalizeEventOptionGroupList(current.optionGroupList); formModel.limitValueText = resolveLimitValueText(current.limitValue); colorDraft.value = formModel.color; @@ -762,6 +903,14 @@ onUnmounted(() => navBarStore.clearRight()); font-size: .875rem; flex-direction: column; } + + .biz-type-tip { + padding: .75rem 1rem; + color: var(--td-text-color-secondary); + font-size: .8125rem; + line-height: 1.5; + background: var(--td-bg-color-secondarycontainer); + } } .picker-text { diff --git a/src/pages/event/EventList.vue b/src/pages/event/EventList.vue index d0f05da..ea5cc84 100644 --- a/src/pages/event/EventList.vue +++ b/src/pages/event/EventList.vue @@ -34,6 +34,7 @@ /> 需拍照 需核销 + 套餐购买
@@ -89,6 +91,7 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useNavBarStore } from "@/store/navBarStore"; import type { EventItem, EventLimitType, EventStatus } from "@/types/EventItem"; import type { NavBarAction } from "@/types/NavBarAction"; +import { getEventBizTypeText } from "@/utils/event"; import { getEventValueUnitText } from "@/utils/eventValue"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; diff --git a/src/pages/event/EventRecordDetail.vue b/src/pages/event/EventRecordDetail.vue index bb6dd53..651a84e 100644 --- a/src/pages/event/EventRecordDetail.vue +++ b/src/pages/event/EventRecordDetail.vue @@ -15,6 +15,7 @@ :hover="canReadEvent && !!(record.event?.id || record.eventId)" @click="toEventDetail" /> + @@ -111,6 +112,7 @@ import type { PhotoItem } from "@/types/Photo"; import type { PointLedgerChangeType } from "@/types/Point"; import { getAttachmentReadURL } from "@/utils/attachment"; import { buildEventOptionSelectionMap } from "@/utils/eventOption"; +import { getEventBizTypeText, isPackagePurchaseEvent } from "@/utils/event"; import { getErrorMessage } from "@/utils/error"; import { formatEventNumberValue } from "@/utils/eventValue"; import { Message } from "@/utils/message"; @@ -133,9 +135,10 @@ const isVerifying = ref(false); const isDeleting = ref(false); const verifyDialogVisible = ref(false); -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 isPackagePurchaseRecord = computed(() => isPackagePurchaseEvent(record.value?.event)); +const canUpdateRecord = computed(() => !!record.value && !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.update)); +const canVerifyRecord = computed(() => !!record.value && !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.verify)); +const canDeleteRecord = computed(() => !!record.value && !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.delete)); const canReadEvent = computed(() => userStore.hasPermissions(permissionCodes.event.read)); const recordPhotoItemList = computed(() => (record.value?.attachmentList || []) .filter(item => !!item.id) @@ -149,7 +152,7 @@ const recordPhotoList = computed(() => recordPhotoItemList.value.map(item => ite async function submitPhotoItems(photoItems: PhotoItem[]): Promise { const currentRecord = record.value; - if (!currentRecord?.id) { + if (!currentRecord?.id || isPackagePurchaseRecord.value) { throw new Error("登记记录不存在"); } @@ -260,7 +263,7 @@ async function confirmVerifyRecord(): Promise { async function submitVerifyRecord(record: EventRecord, attachmentList: Attachment[]): Promise { const id = record.id; - if (!id) { + if (!id || isPackagePurchaseRecord.value) { throw new Error("登记事件记录不存在"); } @@ -313,7 +316,7 @@ function confirmRemoveRecord(): void { } async function removeRecord(id: string): Promise { - if (isDeleting.value) { + if (isDeleting.value || isPackagePurchaseRecord.value) { return; } @@ -338,7 +341,7 @@ async function removeRecord(id: string): Promise { async function toEdit(): Promise { const recordId = String(route.params.id || ""); - if (!recordId) { + if (!recordId || !canUpdateRecord.value) { return; } await router.push(`/management/registration-event-records/${recordId}/edit`); @@ -380,12 +383,14 @@ watch(() => route.params.id, async () => { await loadRecord(); }, { immediate: true }); -onMounted(() => { +function updateNavBarAction(): void { navBarStore.clearRight(); if (canUpdateRecord.value) { navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit); } -}); +} + +watch(canUpdateRecord, updateNavBarAction, { immediate: true }); onUnmounted(() => { navBarStore.clearRight(); diff --git a/src/pages/event/EventRecordEdit.vue b/src/pages/event/EventRecordEdit.vue index 8ba7dde..81e1996 100644 --- a/src/pages/event/EventRecordEdit.vue +++ b/src/pages/event/EventRecordEdit.vue @@ -46,11 +46,12 @@ />
涉及积分变动的数据不能修改,已产生积分流水的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建,删除会自动回流积分。
{ return map; }); +async function handleEventSelectionChange(eventIdList: string[]): Promise { + const packageEvent = eventIdList + .map(eventId => eventList.value.find(item => item.id === eventId)) + .find(item => isPackagePurchaseEvent(item)); + if (!isEditMode.value && packageEvent?.id) { + if (!userStore.hasPermissions(permissionCodes.customerPackage.create)) { + Message.warning("当前账号没有套餐购买权限"); + return; + } + await router.push({ + name: "CustomerPackagePurchase", + query: { + eventId: packageEvent.id, + ...(selectedCustomer.value?.id ? { customerId: selectedCustomer.value.id } : {}) + } + }); + return; + } + selectedEventIdList.value = eventIdList; +} + function appendRecordEvent(record: EventRecord): void { if (!record.event?.id || eventList.value.some(item => item.id === record.event?.id)) { return; @@ -683,6 +706,10 @@ function validateForm(): boolean { Message.warning("请至少选择一个登记事件"); return false; } + if (selectedEventIdList.value.some(eventId => isPackagePurchaseEvent(eventList.value.find(item => item.id === eventId)))) { + Message.warning("套餐购买事件必须通过客户套餐购买页面登记"); + return false; + } for (const eventId of selectedEventIdList.value) { const event = eventList.value.find(item => item.id === eventId); if (!event) { @@ -769,11 +796,17 @@ async function loadPageData(): Promise { return; } - selectedEventIdList.value = eventList.value.slice(0, 1).map(item => item.id).filter((item): item is string => Boolean(item)); + const defaultEvent = eventList.value.find(item => !isPackagePurchaseEvent(item)); + selectedEventIdList.value = defaultEvent?.id ? [defaultEvent.id] : []; } async function loadRecordForEdit(): Promise { const record = await EventRecordAPI.detail(routeId.value); + if (isPackagePurchaseEvent(record.event)) { + Message.warning("套餐购买记录不能通过普通登记页编辑,请在客户套餐详情中查看"); + await router.replace({ name: "EventRecordDetail", params: { id: routeId.value } }); + return; + } editingRecord.value = record; appendRecordEvent(record); selectedCustomerList.value = record.customer ? [record.customer] : []; diff --git a/src/pages/event/EventRecordList.vue b/src/pages/event/EventRecordList.vue index b308bc7..1cf4af2 100644 --- a/src/pages/event/EventRecordList.vue +++ b/src/pages/event/EventRecordList.vue @@ -128,6 +128,7 @@ import { useNavBarStore } from "@/store/navBarStore"; import type { EventItem, EventStatus } from "@/types/EventItem"; import type { EventOptionStat, EventRecordPage, EventRecordPageResult, VerificationStatus } from "@/types/EventRecord"; import type { NavBarAction } from "@/types/NavBarAction"; +import { isPackagePurchaseEvent } from "@/utils/event"; import { formatEventNumberValue } from "@/utils/eventValue"; import { permissionCodes } from "@/utils/permission"; import { Message } from "@/utils/message.ts"; @@ -367,6 +368,10 @@ async function loadRecords(): Promise { if (isLoading.value || isNoMore.value) { return; } + if (!eventList.value.length) { + isNoMore.value = true; + return; + } const token = loadToken + 1; loadToken = token; isLoading.value = true; @@ -398,7 +403,7 @@ async function loadActions(): Promise { const result = canCreateEvent.value ? await EventAPI.listHistory() : await EventAPI.list({ index: 0, size: 1000 }); - eventList.value = result.list || []; + eventList.value = (result.list || []).filter(item => !isPackagePurchaseEvent(item)); const eventIdList = eventList.value.map(item => item.id).filter((item): item is string => Boolean(item)); const eventIdSet = new Set(eventIdList); const selectedEventIdList = parseEventIdList(route.query.eventIds).filter(eventId => eventIdSet.has(eventId)); @@ -529,13 +534,15 @@ async function toCreate(): Promise { function buildRangeReq(): Omit { const [beginAt, endAt] = buildUnixRange(); - const selectedAllEvent = !actionFilter.value.length || actionFilter.value.length === eventList.value.length; + const eventIdList = actionFilter.value.length + ? actionFilter.value + : eventList.value.map(item => item.id).filter((item): item is string => Boolean(item)); return { beginAt, endAt, customerId: customerId.value || undefined, keyword: keyword.value.trim() || undefined, - eventIdList: actionFilter.value.length === 1 ? actionFilter.value : selectedAllEvent ? undefined : actionFilter.value, + eventIdList, optionIdList: optionFilter.value.length ? optionFilter.value : undefined, verificationStatus: verificationFilter.value === "ALL" ? undefined : verificationFilter.value }; diff --git a/src/pages/event/component/EventRecordCell.vue b/src/pages/event/component/EventRecordCell.vue index f8df31c..2588e98 100644 --- a/src/pages/event/component/EventRecordCell.vue +++ b/src/pages/event/component/EventRecordCell.vue @@ -74,6 +74,7 @@ import EventRecordAPI from "@/api/EventRecordAPI"; import EventRecordVerifyDialog from "@/components/EventRecordVerifyDialog.vue"; import type { EventRecord, EventRecordListItemView } from "@/types/EventRecord"; import { getCustomerPhotoURL } from "@/utils/customer"; +import { isPackagePurchaseEvent } from "@/utils/event"; import { getErrorMessage } from "@/utils/error"; import { permissionCodes } from "@/utils/permission"; import { Message } from "@/utils/message"; @@ -100,9 +101,10 @@ 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 isPackagePurchaseRecord = computed(() => isPackagePurchaseEvent(props.record.event)); +const canUpdateRecord = computed(() => !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.update)); +const canVerifyRecord = computed(() => !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.verify)); +const canDeleteRecord = computed(() => !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.delete)); const isDeleting = ref(false); const isVerifying = ref(false); const isLoadingVerifyRecord = ref(false); diff --git a/src/pages/home/QuickSignInPage.vue b/src/pages/home/QuickSignInPage.vue index 9e84a1a..549858d 100644 --- a/src/pages/home/QuickSignInPage.vue +++ b/src/pages/home/QuickSignInPage.vue @@ -147,6 +147,7 @@ import type { EventRecord } from "@/types/EventRecord"; import type { PhotoItem } from "@/types/Photo"; import type { PointAction } from "@/types/Point"; import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue"; +import { isPackagePurchaseEvent } from "@/utils/event"; import { resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/utils/eventOption"; import { permissionCodes } from "@/utils/permission"; import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord"; @@ -194,7 +195,7 @@ async function loadActions(): Promise { isEventLoading.value = true; eventLoadFailed.value = false; try { - eventList.value = await EventAPI.listValid(); + eventList.value = (await EventAPI.listValid()).filter(item => !isPackagePurchaseEvent(item)); selectedEventIdList.value = buildInitialSelectedEventIdList(); syncStoredEventId(); } catch (error) { diff --git a/src/pages/package/CustomerPackageCancel.vue b/src/pages/package/CustomerPackageCancel.vue new file mode 100644 index 0000000..cf6a136 --- /dev/null +++ b/src/pages/package/CustomerPackageCancel.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/src/pages/package/CustomerPackageDelivery.vue b/src/pages/package/CustomerPackageDelivery.vue new file mode 100644 index 0000000..767c763 --- /dev/null +++ b/src/pages/package/CustomerPackageDelivery.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/src/pages/package/CustomerPackageDetail.vue b/src/pages/package/CustomerPackageDetail.vue new file mode 100644 index 0000000..816dc38 --- /dev/null +++ b/src/pages/package/CustomerPackageDetail.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/src/pages/package/CustomerPackageList.vue b/src/pages/package/CustomerPackageList.vue new file mode 100644 index 0000000..c8ac0a9 --- /dev/null +++ b/src/pages/package/CustomerPackageList.vue @@ -0,0 +1,210 @@ + + + + + diff --git a/src/pages/package/CustomerPackagePayment.vue b/src/pages/package/CustomerPackagePayment.vue new file mode 100644 index 0000000..305b48e --- /dev/null +++ b/src/pages/package/CustomerPackagePayment.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/pages/package/CustomerPackagePurchase.vue b/src/pages/package/CustomerPackagePurchase.vue new file mode 100644 index 0000000..c9b9b49 --- /dev/null +++ b/src/pages/package/CustomerPackagePurchase.vue @@ -0,0 +1,405 @@ + + + + + diff --git a/src/pages/package/CustomerPackageRefund.vue b/src/pages/package/CustomerPackageRefund.vue new file mode 100644 index 0000000..b7e8b5d --- /dev/null +++ b/src/pages/package/CustomerPackageRefund.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/src/pages/package/FundRecordDetail.vue b/src/pages/package/FundRecordDetail.vue new file mode 100644 index 0000000..ebeeb1c --- /dev/null +++ b/src/pages/package/FundRecordDetail.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/pages/package/FundRecordList.vue b/src/pages/package/FundRecordList.vue new file mode 100644 index 0000000..aa56c66 --- /dev/null +++ b/src/pages/package/FundRecordList.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/pages/package/FundRecordRevoke.vue b/src/pages/package/FundRecordRevoke.vue new file mode 100644 index 0000000..7449a3e --- /dev/null +++ b/src/pages/package/FundRecordRevoke.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/pages/package/PackageConfigDetail.vue b/src/pages/package/PackageConfigDetail.vue new file mode 100644 index 0000000..d497b5c --- /dev/null +++ b/src/pages/package/PackageConfigDetail.vue @@ -0,0 +1,176 @@ + + + + + diff --git a/src/pages/package/PackageConfigEdit.vue b/src/pages/package/PackageConfigEdit.vue new file mode 100644 index 0000000..ecb2ee6 --- /dev/null +++ b/src/pages/package/PackageConfigEdit.vue @@ -0,0 +1,358 @@ + + + + + diff --git a/src/pages/package/PackageConfigList.vue b/src/pages/package/PackageConfigList.vue new file mode 100644 index 0000000..b74eb97 --- /dev/null +++ b/src/pages/package/PackageConfigList.vue @@ -0,0 +1,233 @@ + + + + + diff --git a/src/pages/package/PackageDeliveryDetail.vue b/src/pages/package/PackageDeliveryDetail.vue new file mode 100644 index 0000000..13732e4 --- /dev/null +++ b/src/pages/package/PackageDeliveryDetail.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/src/pages/package/PackageDeliveryList.vue b/src/pages/package/PackageDeliveryList.vue new file mode 100644 index 0000000..fda1aae --- /dev/null +++ b/src/pages/package/PackageDeliveryList.vue @@ -0,0 +1,157 @@ + + + + + diff --git a/src/pages/package/PackageDeliveryRevoke.vue b/src/pages/package/PackageDeliveryRevoke.vue new file mode 100644 index 0000000..175a247 --- /dev/null +++ b/src/pages/package/PackageDeliveryRevoke.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/pages/tabs/SettingTab.vue b/src/pages/tabs/SettingTab.vue index a3b8a36..3cc02da 100644 --- a/src/pages/tabs/SettingTab.vue +++ b/src/pages/tabs/SettingTab.vue @@ -196,6 +196,35 @@ const menuGroupList = computed(() => [ } ] }, + { + title: "套餐", + items: [ + { + icon: "layers", + path: "/management/package-configs", + title: "套餐配置", + visible: userStore.hasPermissions(permissionCodes.packageConfig.read) + }, + { + icon: "order-list", + path: "/management/customer-packages", + title: "客户套餐", + visible: userStore.hasPermissions(permissionCodes.customerPackage.read) + }, + { + icon: "upload", + path: "/management/package-deliveries", + title: "发放记录", + visible: userStore.hasPermissions(permissionCodes.packageDelivery.read) + }, + { + icon: "money", + path: "/management/fund-records", + title: "资金流水", + visible: userStore.hasPermissions(permissionCodes.fundRecord.read) + } + ] + }, { title: "系统", items: [ diff --git a/src/router/index.ts b/src/router/index.ts index b169a13..c16bf21 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -8,6 +8,7 @@ import customerRoutes from "@/router/routes/customerRoutes"; import { managementAccountRoutes, managementSettingRoutes, storeRoutes } from "@/router/routes/managementRoutes"; import permissionRoutes from "@/router/routes/permissionRoutes"; import pointRoutes from "@/router/routes/pointRoutes"; +import packageRoutes from "@/router/routes/packageRoutes"; import registrationRoutes from "@/router/routes/registrationRoutes"; import tabRoutes from "@/router/routes/tabRoutes"; import userRoutes from "@/router/routes/userRoutes"; @@ -25,6 +26,7 @@ const detailRoutes = [ ...storeRoutes, ...permissionRoutes, ...pointRoutes, + ...packageRoutes, ...chartRoutes, ...managementAccountRoutes, ...notifyRoutes diff --git a/src/router/routes/packageRoutes.ts b/src/router/routes/packageRoutes.ts new file mode 100644 index 0000000..75233af --- /dev/null +++ b/src/router/routes/packageRoutes.ts @@ -0,0 +1,51 @@ +import type { RouteRecordRaw } from "vue-router"; +import PackageConfigList from "@/pages/package/PackageConfigList.vue"; +import PackageConfigEdit from "@/pages/package/PackageConfigEdit.vue"; +import PackageConfigDetail from "@/pages/package/PackageConfigDetail.vue"; +import CustomerPackageList from "@/pages/package/CustomerPackageList.vue"; +import CustomerPackagePurchase from "@/pages/package/CustomerPackagePurchase.vue"; +import CustomerPackageDetail from "@/pages/package/CustomerPackageDetail.vue"; +import CustomerPackageCancel from "@/pages/package/CustomerPackageCancel.vue"; +import CustomerPackagePayment from "@/pages/package/CustomerPackagePayment.vue"; +import CustomerPackageDelivery from "@/pages/package/CustomerPackageDelivery.vue"; +import CustomerPackageRefund from "@/pages/package/CustomerPackageRefund.vue"; +import PackageDeliveryList from "@/pages/package/PackageDeliveryList.vue"; +import PackageDeliveryDetail from "@/pages/package/PackageDeliveryDetail.vue"; +import PackageDeliveryRevoke from "@/pages/package/PackageDeliveryRevoke.vue"; +import FundRecordList from "@/pages/package/FundRecordList.vue"; +import FundRecordDetail from "@/pages/package/FundRecordDetail.vue"; +import FundRecordRevoke from "@/pages/package/FundRecordRevoke.vue"; + +function pageMeta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw["meta"] { + return { + depth, + navBarVisible: true, + navBarCanBack: true, + navBarTitle: title, + tabBarVisible: false, + bodyBackground: background, + darkBodyBackground: "#10161f" + }; +} + +const packageRoutes: RouteRecordRaw[] = [ + { path: "/management/package-configs", name: "PackageConfigList", component: PackageConfigList, meta: pageMeta("套餐配置") }, + { path: "/management/package-configs/create", name: "PackageConfigCreate", component: PackageConfigEdit, meta: pageMeta("新增套餐配置", 4) }, + { path: "/management/package-configs/:id", name: "PackageConfigDetail", component: PackageConfigDetail, meta: pageMeta("套餐配置详情", 4, "#FFF") }, + { path: "/management/package-configs/:id/edit", name: "PackageConfigEdit", component: PackageConfigEdit, meta: pageMeta("编辑套餐配置", 5) }, + { path: "/management/customer-packages", name: "CustomerPackageList", component: CustomerPackageList, meta: pageMeta("客户套餐") }, + { path: "/management/customer-packages/purchase", name: "CustomerPackagePurchase", component: CustomerPackagePurchase, meta: pageMeta("购买套餐", 4) }, + { path: "/management/customer-packages/:id", name: "CustomerPackageDetail", component: CustomerPackageDetail, meta: pageMeta("客户套餐详情", 4, "#FFF") }, + { path: "/management/customer-packages/:id/cancel", name: "CustomerPackageCancel", component: CustomerPackageCancel, meta: pageMeta("取消客户套餐", 5) }, + { path: "/management/customer-packages/:id/payment", name: "CustomerPackagePayment", component: CustomerPackagePayment, meta: pageMeta("登记收款", 5) }, + { path: "/management/customer-packages/:id/delivery/create", name: "CustomerPackageDelivery", component: CustomerPackageDelivery, meta: pageMeta("创建发放单", 5) }, + { path: "/management/customer-packages/:id/refund", name: "CustomerPackageRefund", component: CustomerPackageRefund, meta: pageMeta("处理退款", 5) }, + { path: "/management/package-deliveries", name: "PackageDeliveryList", component: PackageDeliveryList, meta: pageMeta("发放记录") }, + { path: "/management/package-deliveries/:id", name: "PackageDeliveryDetail", component: PackageDeliveryDetail, meta: pageMeta("发放单详情", 4, "#FFF") }, + { path: "/management/package-deliveries/:id/revoke", name: "PackageDeliveryRevoke", component: PackageDeliveryRevoke, meta: pageMeta("发放冲正", 5) }, + { path: "/management/fund-records", name: "FundRecordList", component: FundRecordList, meta: pageMeta("资金流水") }, + { path: "/management/fund-records/:id", name: "FundRecordDetail", component: FundRecordDetail, meta: pageMeta("资金记录详情", 4, "#FFF") }, + { path: "/management/fund-records/:id/revoke", name: "FundRecordRevoke", component: FundRecordRevoke, meta: pageMeta("资金冲正", 5) } +]; + +export default packageRoutes; diff --git a/src/types/EventItem.ts b/src/types/EventItem.ts index f9de807..9af72ee 100644 --- a/src/types/EventItem.ts +++ b/src/types/EventItem.ts @@ -1,6 +1,8 @@ import type { Model } from "timi-web-api"; +import type { GaoPackageConfig } from "@/types/Package"; export type EventStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED"; +export type EventBizType = "GENERAL" | "PACKAGE_PURCHASE"; export type EventValueType = "NONE" | "NUMBER"; export type EventValueUnit = "CENT" | "COUNT"; @@ -31,6 +33,9 @@ export type EventOptionSelectionMap = Record>; export type EventItem = { storeId?: string; + bizType?: EventBizType; + packageConfigId?: string; + packageConfig?: GaoPackageConfig; name: string; description?: string; color?: string; diff --git a/src/types/Package.ts b/src/types/Package.ts new file mode 100644 index 0000000..847dc2a --- /dev/null +++ b/src/types/Package.ts @@ -0,0 +1,212 @@ +import type { Attachment, Model, PageResult, User } from "timi-web-api"; +import type { Customer } from "@/types/Customer"; +import type { EventRecord } from "@/types/EventRecord"; + +export type PackageConfigStatus = "DRAFT" | "ACTIVE" | "INACTIVE"; +export type CustomerPackageStatus = "ACTIVE" | "FULFILLED" | "CANCELLED"; +export type PaymentStatus = "UNPAID" | "PARTIALLY_PAID" | "PAID"; +export type RefundStatus = "NOT_REQUIRED" | "PENDING" | "REFUNDED"; +export type DeliveryStatus = "PENDING" | "VERIFIED" | "REJECTED" | "CANCELLED" | "REVOKED"; +export type DeliveryOperationType = "DELIVER" | "REVOKE"; +export type FundOperationType = "RECEIVE" | "REFUND" | "CONFIRM_NO_REFUND" | "REVOKE"; +export type FundRecordStatus = "PENDING" | "VERIFIED" | "REJECTED" | "CANCELLED"; + +export type PackageConfigItem = { + productName: string; + unit: string; + defaultQuantity: number | string; + sort?: number; + remark?: string | null; +} & Model; + +export type GaoPackageConfig = { + storeId?: string; + name: string; + description?: string; + standardAmount: number; + status?: PackageConfigStatus; + beginAt?: number | null; + endAt?: number | null; + sort?: number; + itemList?: PackageConfigItem[]; + updater?: Partial; +} & Model; + +export type PackageConfigPage = { + storeId?: string; + keyword?: string; + status?: PackageConfigStatus; + index: number; + size: number; +}; + +export type CustomerPackageItem = { + productNameSnapshot: string; + unitSnapshot: string; + totalQuantity: number | string; + reservedQuantity: number | string; + deliveredQuantity: number | string; + pendingQuantity: number | string; + availableQuantity: number | string; + remainingQuantity: number | string; +} & Model; + +export type GaoCustomerPackage = { + storeId?: string; + customerId?: string; + packageConfigId?: string; + packageNameSnapshot?: string; + standardAmountSnapshot?: number; + saleAmount?: number; + pendingPaidAmount?: number; + paidAmount?: number; + refundedAmount?: number; + status?: CustomerPackageStatus; + paymentStatus?: PaymentStatus; + refundStatus?: RefundStatus; + purchasedAt?: number; + cancelledAt?: number | null; + customer?: Customer; + purchaseEventRecord?: EventRecord; + itemList?: CustomerPackageItem[]; + deliveryList?: GaoPackageDelivery[]; + fundRecordList?: GaoFundRecord[]; +} & Model; + +export type CustomerPackagePage = { + storeId?: string; + customerId?: string; + keyword?: string; + status?: CustomerPackageStatus; + paymentStatus?: PaymentStatus; + refundStatus?: RefundStatus; + beginAt?: number; + endAt?: number; + index: number; + size: number; +}; + +export type PackagePurchaseItem = { + productName: string; + unit: string; + totalQuantity: string; + sort?: number; + remark?: string | null; +}; + +export type PackageAttachment = Partial & { + tempFileId?: string; + attachType?: string; + name?: string; +}; + +export type PackagePurchaseRequest = { + requestNo: string; + eventId: string; + customerId: string; + saleAmount?: number; + purchasedAt?: number; + itemList?: PackagePurchaseItem[]; + attachmentList?: PackageAttachment[]; + remark?: string | null; +}; + +export type PackagePaymentReceiveRequest = { + requestNo: string; + customerPackageId: string; + amount: number; + occurredAt?: number; + attachmentList: PackageAttachment[]; + remark?: string | null; +}; + +export type PackageDeliveryItem = { + customerPackageItemId: string; + quantity: string; +}; + +export type PackageDeliveryCreateRequest = { + requestNo: string; + customerPackageId: string; + deliveredAt?: number; + itemList: PackageDeliveryItem[]; + attachmentList: PackageAttachment[]; + remark?: string | null; +}; + +export type GaoPackageDeliveryItem = { + customerPackageItemId?: string; + productNameSnapshot?: string; + unitSnapshot?: string; + quantity?: number | string; + reservedBefore?: number | string; + reservedAfter?: number | string; + deliveredBefore?: number | string; + deliveredAfter?: number | string; +}; + +export type GaoPackageDelivery = { + customerPackageId?: string; + customerId?: string; + operationType?: DeliveryOperationType; + status?: DeliveryStatus; + requestNo?: string; + operatorUserId?: string; + operator?: Partial; + verifierUserId?: string; + verifier?: Partial; + deliveredAt?: number; + sourceDeliveryId?: string; + reason?: string | null; + itemList?: GaoPackageDeliveryItem[]; + attachmentList?: PackageAttachment[]; +} & Model; + +export type DeliveryPage = { + storeId?: string; + customerPackageId?: string; + customerId?: string; + status?: DeliveryStatus; + operationType?: DeliveryOperationType; + beginAt?: number; + endAt?: number; + index: number; + size: number; +}; + +export type GaoFundRecord = { + bizId?: string; + customerPackageId?: string; + customerId?: string; + customer?: Customer; + customerPackage?: GaoCustomerPackage; + operationType?: FundOperationType; + status?: FundRecordStatus; + amount?: number; + sourceRecordId?: string; + requestNo?: string; + occurredAt?: number; + reason?: string | null; + remark?: string | null; + operatorUserId?: string; + operator?: Partial; + verifierUserId?: string; + verifier?: Partial; + attachmentList?: PackageAttachment[]; +} & Model; + +export type FundRecordPage = { + storeId?: string; + customerPackageId?: string; + customerId?: string; + operationType?: FundOperationType; + status?: FundRecordStatus; + beginAt?: number; + endAt?: number; + index: number; + size: number; +}; + +export type PackagePageResult = PageResult & { + pages?: number; +}; diff --git a/src/utils/event.ts b/src/utils/event.ts new file mode 100644 index 0000000..f1110d5 --- /dev/null +++ b/src/utils/event.ts @@ -0,0 +1,11 @@ +import type { EventBizType, EventItem } from "@/types/EventItem"; + +/** 返回事件业务类型的前端文案。 */ +export function getEventBizTypeText(bizType?: EventBizType): string { + return bizType === "PACKAGE_PURCHASE" ? "套餐购买" : "普通登记"; +} + +/** 判断事件是否为套餐购买事件。 */ +export function isPackagePurchaseEvent(event?: Pick): boolean { + return event?.bizType === "PACKAGE_PURCHASE"; +} diff --git a/src/utils/package.ts b/src/utils/package.ts new file mode 100644 index 0000000..23b2749 --- /dev/null +++ b/src/utils/package.ts @@ -0,0 +1,130 @@ +import type { + CustomerPackageItem, + CustomerPackageStatus, + DeliveryOperationType, + DeliveryStatus, + FundOperationType, + FundRecordStatus, + PackageConfigStatus, + PaymentStatus, + RefundStatus +} from "@/types/Package"; + +/** 生成套餐业务幂等请求号。每次新操作必须重新生成。 */ +export function createPackageRequestNo(): string { + return window.crypto.randomUUID(); +} + +/** 将分转换为金额展示文本,不用浮点数做金额格式化。 */ +export function formatPackageAmount(cents?: number | null): string { + if (cents === undefined || cents === null) { + return "未设置"; + } + const negative = cents < 0; + const absolute = Math.abs(Math.trunc(cents)); + const yuan = Math.floor(absolute / 100); + const fen = String(absolute % 100).padStart(2, "0"); + return `${negative ? "-" : ""}${yuan}.${fen} 元`; +} + +/** 将分转换为金额输入框文本。 */ +export function formatPackageAmountInput(cents?: number | null): string { + if (cents === undefined || cents === null) { + return ""; + } + const negative = cents < 0; + const absolute = Math.abs(Math.trunc(cents)); + return `${negative ? "-" : ""}${Math.floor(absolute / 100)}.${String(absolute % 100).padStart(2, "0")}`; +} + +/** 将用户输入的元金额精确转换为后端要求的分。 */ +export function parsePackageAmount(value: string): number | undefined { + const text = value.trim(); + if (!/^\d+(?:\.\d{1,2})?$/.test(text)) { + return undefined; + } + const [yuanText, fenText = ""] = text.split("."); + const cents = BigInt(yuanText) * 100n + BigInt(fenText.padEnd(2, "0") || "0"); + const result = Number(cents); + return Number.isSafeInteger(result) ? result : undefined; +} + +/** 规范化最多三位小数的非负定点数量。 */ +export function normalizePackageQuantity(value: number | string): string | undefined { + const text = String(value).trim(); + if (!/^\d+(?:\.\d{1,3})?$/.test(text)) { + return undefined; + } + const [integerText, fractionText = ""] = text.split("."); + const integer = integerText.replace(/^0+(?=\d)/, "") || "0"; + const fraction = fractionText.replace(/0+$/, ""); + return fraction ? `${integer}.${fraction}` : integer; +} + +/** 使用 BigInt 比较定点数量,避免 JavaScript 二进制浮点误差。 */ +export function comparePackageQuantity(left: number | string, right: number | string): number { + const leftParts = toQuantityParts(left); + const rightParts = toQuantityParts(right); + if (!leftParts || !rightParts) { + return 0; + } + const scale = Math.max(leftParts.scale, rightParts.scale); + const leftValue = leftParts.value * 10n ** BigInt(scale - leftParts.scale); + const rightValue = rightParts.value * 10n ** BigInt(scale - rightParts.scale); + return leftValue < rightValue ? -1 : rightValue < leftValue ? 1 : 0; +} + +export function getPackageQuantityText(value?: number | string | null): string { + if (value === undefined || value === null || value === "") { + return "0"; + } + return normalizePackageQuantity(value) || "0"; +} + +export function getPackageConfigStatusText(status?: PackageConfigStatus): string { + return ({ DRAFT: "草稿", ACTIVE: "启用", INACTIVE: "停用" } as Record)[status || ""] || "未知"; +} + +export function getCustomerPackageStatusText(status?: CustomerPackageStatus): string { + return ({ ACTIVE: "进行中", FULFILLED: "已履约", CANCELLED: "已取消" } as Record)[status || ""] || "未知"; +} + +export function getPaymentStatusText(status?: PaymentStatus): string { + return ({ UNPAID: "未收款", PARTIALLY_PAID: "部分收款", PAID: "已收款" } as Record)[status || ""] || "未知"; +} + +export function getRefundStatusText(status?: RefundStatus): string { + return ({ NOT_REQUIRED: "无需退款", PENDING: "待退款", REFUNDED: "已退款" } as Record)[status || ""] || "未知"; +} + +export function getDeliveryStatusText(status?: DeliveryStatus): string { + return ({ PENDING: "待核销", VERIFIED: "已核销", REJECTED: "已拒绝", CANCELLED: "已撤销", REVOKED: "已冲正" } as Record)[status || ""] || "未知"; +} + +export function getDeliveryOperationText(type?: DeliveryOperationType): string { + return type === "REVOKE" ? "发放冲正" : "正常发放"; +} + +export function getFundOperationText(type?: FundOperationType): string { + return ({ RECEIVE: "线下收款", REFUND: "退款", CONFIRM_NO_REFUND: "确认无需退款", REVOKE: "资金冲正" } as Record)[type || ""] || "未知"; +} + +export function getFundStatusText(status?: FundRecordStatus): string { + return ({ PENDING: "待核销", VERIFIED: "已核销", REJECTED: "已拒绝", CANCELLED: "已撤销" } as Record)[status || ""] || "未知"; +} + +export function getPackageItemAvailableQuantity(item: CustomerPackageItem): string { + return getPackageQuantityText(item.availableQuantity); +} + +function toQuantityParts(value: number | string): { value: bigint; scale: number } | undefined { + const normalized = normalizePackageQuantity(value); + if (!normalized) { + return undefined; + } + const [integerText, fractionText = ""] = normalized.split("."); + return { + value: BigInt(`${integerText}${fractionText}`), + scale: fractionText.length + }; +} diff --git a/src/utils/permission.ts b/src/utils/permission.ts index 305b396..bcc9abd 100644 --- a/src/utils/permission.ts +++ b/src/utils/permission.ts @@ -45,6 +45,35 @@ export const permissionCodes = { update: "GAO:POINT_RULE:UPDATE", delete: "GAO:POINT_RULE:DELETE" }, + packageConfig: { + create: "GAO:PACKAGE_CONFIG:CREATE", + read: "GAO:PACKAGE_CONFIG:READ", + update: "GAO:PACKAGE_CONFIG:UPDATE", + delete: "GAO:PACKAGE_CONFIG:DELETE" + }, + customerPackage: { + create: "GAO:CUSTOMER_PACKAGE:CREATE", + read: "GAO:CUSTOMER_PACKAGE:READ", + cancel: "GAO:CUSTOMER_PACKAGE:CANCEL", + priceOverride: "GAO:CUSTOMER_PACKAGE:PRICE_OVERRIDE" + }, + packageDelivery: { + create: "GAO:PACKAGE_DELIVERY:CREATE", + read: "GAO:PACKAGE_DELIVERY:READ", + verify: "GAO:PACKAGE_DELIVERY:VERIFY", + revoke: "GAO:PACKAGE_DELIVERY:REVOKE" + }, + fundReceive: { + create: "GAO:FUND_RECEIVE:CREATE", + verify: "GAO:FUND_RECEIVE:VERIFY" + }, + fundRecord: { + read: "GAO:FUND_RECORD:READ" + }, + fund: { + refund: "GAO:FUND:REFUND", + revoke: "GAO:FUND:REVOKE" + }, stat: { read: "GAO:STAT:READ" }, @@ -140,6 +169,23 @@ export const gaoStoreScopePermissionCodes = [ permissionCodes.pointRule.read, permissionCodes.pointRule.update, permissionCodes.pointRule.delete, + permissionCodes.packageConfig.create, + permissionCodes.packageConfig.read, + permissionCodes.packageConfig.update, + permissionCodes.packageConfig.delete, + permissionCodes.customerPackage.create, + permissionCodes.customerPackage.read, + permissionCodes.customerPackage.cancel, + permissionCodes.customerPackage.priceOverride, + permissionCodes.packageDelivery.create, + permissionCodes.packageDelivery.read, + permissionCodes.packageDelivery.verify, + permissionCodes.packageDelivery.revoke, + permissionCodes.fundReceive.create, + permissionCodes.fundReceive.verify, + permissionCodes.fundRecord.read, + permissionCodes.fund.refund, + permissionCodes.fund.revoke, permissionCodes.stat.read, permissionCodes.user.create, permissionCodes.user.read,