add package

This commit is contained in:
Timi
2026-09-01 01:14:54 +08:00
parent 100d4c4c44
commit 2d94abfb32
39 changed files with 4425 additions and 59 deletions
+45
View File
@@ -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));
},
+5 -26
View File
@@ -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
+2 -2
View File
@@ -62,9 +62,9 @@ async function list(req: Page<EventItem>, options?: ListOptions): Promise<PageRe
return await axios.post(`${BASE_URI}/list`, withCurrentStorePage(orderedPage));
}
async function listValid(): Promise<EventItem[]> {
async function listValid(storeId?: string): Promise<EventItem[]> {
const list = await axios.post(`${BASE_URI}/list/valid`, undefined, {
params: { storeId: getCurrentStoreId() }
params: { storeId: storeId || getCurrentStoreId() }
}) as unknown as EventItem[];
return sortEventList(list);
}
+190
View File
@@ -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<T extends { storeId?: string }>(request: T): T {
const storeId = request.storeId || getCurrentStoreId();
return storeId ? { ...request, storeId } : request;
}
async function configList(page: PackageConfigPage): Promise<PackagePageResult<GaoPackageConfig>> {
return await axios.post(`${CONFIG_URI}/list`, withStore(page));
}
async function configListValid(storeId?: string): Promise<GaoPackageConfig[]> {
return await axios.post(`${CONFIG_URI}/list/valid`, undefined, {
params: { storeId: storeId || getCurrentStoreId() }
});
}
async function configDetail(id: string): Promise<GaoPackageConfig> {
return await axios.post(`${CONFIG_URI}/detail`, undefined, { params: { id } });
}
async function configCreate(config: GaoPackageConfig): Promise<string> {
return await axios.post(`${CONFIG_URI}/create`, withStore(config));
}
async function configUpdate(config: GaoPackageConfig): Promise<void> {
await axios.post(`${CONFIG_URI}/update`, withStore(config));
}
async function configRemove(id: string): Promise<void> {
await axios.post(`${CONFIG_URI}/delete`, undefined, { params: { id } });
}
async function customerPackageList(page: CustomerPackagePage): Promise<PackagePageResult<GaoCustomerPackage>> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/list`, withStore(page));
}
async function customerPackageDetail(id: string): Promise<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/detail`, undefined, { params: { id } });
}
async function purchase(request: PackagePurchaseRequest): Promise<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/purchase`, request);
}
async function cancel(customerPackageId: string, reason: string): Promise<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/cancel`, { customerPackageId, reason });
}
async function receivePayment(request: PackagePaymentReceiveRequest): Promise<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/receive`, request);
}
async function verifyPayment(fundRecordId: string): Promise<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/verify`, undefined, { params: { id: fundRecordId } });
}
async function rejectPayment(fundRecordId: string, reason: string): Promise<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/payment/reject`, { fundRecordId, reason });
}
async function cancelPayment(fundRecordId: string, reason: string): Promise<GaoCustomerPackage> {
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<GaoCustomerPackage> {
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<GaoCustomerPackage> {
return await axios.post(`${CUSTOMER_PACKAGE_URI}/refund/confirm-none`, request);
}
async function deliveryList(page: DeliveryPage): Promise<PackagePageResult<GaoPackageDelivery>> {
return await axios.post(`${DELIVERY_URI}/list`, withStore(page));
}
async function deliveryDetail(id: string): Promise<GaoPackageDelivery> {
return await axios.post(`${DELIVERY_URI}/detail`, undefined, { params: { id } });
}
async function createDelivery(request: PackageDeliveryCreateRequest): Promise<GaoPackageDelivery> {
return await axios.post(`${DELIVERY_URI}/create`, request);
}
async function verifyDelivery(id: string): Promise<GaoPackageDelivery> {
return await axios.post(`${DELIVERY_URI}/verify`, undefined, { params: { id } });
}
async function rejectDelivery(deliveryId: string, reason: string): Promise<GaoPackageDelivery> {
return await axios.post(`${DELIVERY_URI}/reject`, { deliveryId, reason });
}
async function cancelDelivery(deliveryId: string, reason: string): Promise<GaoPackageDelivery> {
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<GaoPackageDelivery> {
return await axios.post(`${DELIVERY_URI}/revoke`, request);
}
async function fundRecordList(page: FundRecordPage): Promise<PackagePageResult<GaoFundRecord>> {
return await axios.post(`${FUND_URI}/list`, withStore(page));
}
async function fundRecordDetail(id: string): Promise<GaoFundRecord> {
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<GaoFundRecord> {
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
};
+12 -1
View File
@@ -43,10 +43,12 @@ interface Props {
modelValue: string | string[];
visible: boolean;
selectionMode?: SelectionMode;
storeId?: string;
}
const props = withDefaults(defineProps<Props>(), {
selectionMode: "single"
selectionMode: "single",
storeId: ""
});
const emit = defineEmits<{
@@ -100,6 +102,7 @@ async function loadCustomers(): Promise<void> {
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;
+89
View File
@@ -0,0 +1,89 @@
<template>
<div class="package-photo-field">
<photo-scroller
v-model="photoUrlList"
v-model:photo-items="photoItems"
:title="title"
:required="required"
:loading="isUploading"
:edit-mode="true"
@add="openFilePicker"
/>
<input ref="fileInput" class="file-input" type="file" accept="image/*" multiple @change="handleFileChange" />
</div>
</template>
<script setup lang="ts">
import { AttachmentAPI } from "timi-web-api";
import PhotoScroller from "@/components/PhotoScroller.vue";
import type { PhotoItem } from "@/types/Photo";
import { compressUploadImageList } from "@/utils/imageCompression";
import { Message } from "@/utils/message";
interface Props {
required?: boolean;
title?: string;
}
withDefaults(defineProps<Props>(), {
required: false,
title: "业务照片"
});
defineOptions({
name: "PackagePhotoField"
});
const photoItems = defineModel<PhotoItem[]>({ required: true });
const fileInput = ref<HTMLInputElement>();
const isUploading = ref(false);
const photoUrlList = computed<string[]>({
get: () => photoItems.value.map(item => item.url),
set: value => {
const urlSet = new Set(value);
photoItems.value = photoItems.value.filter(item => urlSet.has(item.url));
}
});
function openFilePicker(): void {
fileInput.value?.click();
}
async function handleFileChange(event: Event): Promise<void> {
const input = event.target as HTMLInputElement;
const fileList = Array.from(input.files || []);
if (!fileList.length) {
return;
}
isUploading.value = true;
try {
const tempFileList = await AttachmentAPI.upload(await compressUploadImageList(fileList));
for (const tempFile of tempFileList) {
if (!tempFile.id) {
continue;
}
photoItems.value.push({
tempFileId: tempFile.id,
attachType: "PHOTO",
url: AttachmentAPI.getTempReadURL(tempFile.id)
});
}
} catch {
Message.error("照片上传失败");
} finally {
isUploading.value = false;
input.value = "";
}
}
</script>
<style scoped lang="less">
.package-photo-field {
width: 100%;
.file-input {
display: none;
}
}
</style>
+18 -5
View File
@@ -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<void> {
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;
}
}
// ---------- 右侧操作 ----------
+30 -3
View File
@@ -15,6 +15,15 @@
</div>
<t-cell-group v-entrance-transition="70" v-if="event" title="基本信息" theme="card">
<t-cell title="所属门店" :note="storeName" />
<t-cell title="业务类型" :note="getEventBizTypeText(event.bizType)" />
<t-cell v-if="isPackagePurchaseEvent(event)" title="绑定套餐">
<template #note>
<div class="package-note">
<strong v-text="event.packageConfig?.name || '未返回套餐配置'" />
<span v-if="event.packageConfig" v-text="`${formatPackageAmount(event.packageConfig.standardAmount)}|${getPackageConfigStatusText(event.packageConfig.status)}`" />
</div>
</template>
</t-cell>
<t-cell title="事件颜色">
<template #note>
<div class="color-note">
@@ -39,13 +48,18 @@
</template>
</t-cell>
<t-cell
v-if="canReadEventRecord"
v-if="canReadEventRecord && event.bizType !== 'PACKAGE_PURCHASE'"
title="登记记录"
note="查看该事件登记记录"
arrow
:hover="true"
@click="toEventRecordList"
/>
<t-cell
v-else-if="event.bizType === 'PACKAGE_PURCHASE'"
title="购买记录"
note="请在客户套餐详情中查看"
/>
</t-cell-group>
<t-cell-group v-entrance-transition="140" v-if="event" title="登记配置" theme="card">
<t-cell title="数值类型" :note="getValueTypeText(event.valueType)" />
@@ -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<void> {
async function toEventRecordList(): Promise<void> {
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;
+152 -3
View File
@@ -25,6 +25,15 @@
<t-form-item v-if="isGlobalManager" label="门店" :arrow="isCreateMode" @click="openStorePicker">
<div class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="业务类型" :arrow="isCreateMode" @click="isCreateMode && openBizTypePicker()">
<div class="picker-text" v-text="getBizTypeText(formModel.bizType)" />
</t-form-item>
<div v-if="formModel.bizType === 'PACKAGE_PURCHASE'" class="biz-type-tip">
套餐购买事件必须绑定一个套餐配置。实际购买时只能使用此处绑定的套餐,不能临时更换。
</div>
<t-form-item v-if="formModel.bizType === 'PACKAGE_PURCHASE'" label="绑定套餐" required-mark arrow @click="openPackageConfigPicker">
<div class="picker-text" v-text="selectedPackageConfig?.name || '请选择套餐配置'" />
</t-form-item>
<t-form-item label="说明">
<t-textarea
class="description"
@@ -80,7 +89,7 @@
<t-switch v-model="formModel.requirePhoto" />
</t-form-item>
<t-form-item label="需要核销" content-align="right">
<t-switch v-model="formModel.requireVerification" />
<t-switch v-model="formModel.requireVerification" :disabled="formModel.bizType === 'PACKAGE_PURCHASE'" />
</t-form-item>
<t-form-item label="状态" arrow @click="openStatusPicker">
<div class="picker-text" v-text="getStatusText(formModel.status)" />
@@ -157,6 +166,24 @@
@confirm="confirmStorePicker"
/>
</t-popup>
<t-popup v-model="bizTypePickerVisible" placement="bottom">
<t-picker
v-model="bizTypePickerDraft"
:columns="bizTypePickerColumns"
title="业务类型"
@cancel="bizTypePickerVisible = false"
@confirm="confirmBizTypePicker"
/>
</t-popup>
<t-popup v-model="packageConfigPickerVisible" placement="bottom">
<t-picker
v-model="packageConfigPickerDraft"
:columns="packageConfigPickerColumns"
title="绑定套餐"
@cancel="packageConfigPickerVisible = false"
@confirm="confirmPackageConfigPicker"
/>
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker
v-model="statusPickerDraft"
@@ -219,12 +246,16 @@ import { Time } from "timi-web";
import { type ModuleCode, type Role, RoleAPI, useUserStore } from "timi-web-api";
import type { ColorObject } from "tdesign-mobile-vue";
import EventAPI from "@/api/EventAPI";
import PackageAPI from "@/api/PackageAPI";
import EventOptionEditor from "@/pages/event/component/EventOptionEditor.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem, EventLimitType, EventOptionGroup, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem";
import type { EventBizType, EventItem, EventLimitType, EventOptionGroup, EventStatus, EventValueType, EventValueUnit } from "@/types/EventItem";
import type { GaoPackageConfig } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { normalizeEventOptionGroupList, prepareEventOptionGroupList, validateEventOptionGroupList } from "@/utils/eventOption";
import { getEventBizTypeText } from "@/utils/event";
import { getPackageConfigStatusText } from "@/utils/package";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { Message } from "@/utils/message";
@@ -263,6 +294,8 @@ const optionGroupList = ref<EventOptionGroup[]>([]);
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<PickerValue[]>([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<string | number>): Promise<void> {
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<GaoPackageConfig[]>([]);
const packageConfigPickerVisible = ref(false);
const packageConfigPickerDraft = ref<PickerValue[]>([]);
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<void> {
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<string | number>): 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<void> {
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<void> {
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<void> {
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<void> {
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 {
+4 -1
View File
@@ -34,6 +34,7 @@
/>
<t-tag v-if="item.requirePhoto" theme="warning" variant="outline">需拍照</t-tag>
<t-tag v-if="item.requireVerification" theme="warning" variant="outline">需核销</t-tag>
<t-tag v-if="item.bizType === 'PACKAGE_PURCHASE'" theme="primary" variant="outline">套餐购买</t-tag>
</div>
</template>
<template #description>
@@ -50,7 +51,8 @@
<span v-if="!(item.roleCodeList || []).length" class="muted" v-text="'未配置'" />
</div>
<div class="line" v-text="getLimitText(item)" />
<div class="line" v-text="getValueDefinitionText(item)" />
<div class="line" v-text="`${getEventBizTypeText(item.bizType)}|${getValueDefinitionText(item)}`" />
<div v-if="item.bizType === 'PACKAGE_PURCHASE'" class="line" v-text="`绑定套餐:${item.packageConfig?.name || '未配置'}`" />
</div>
</template>
</t-cell>
@@ -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";
+14 -9
View File
@@ -15,6 +15,7 @@
:hover="canReadEvent && !!(record.event?.id || record.eventId)"
@click="toEventDetail"
/>
<t-cell title="业务类型" :note="getEventBizTypeText(record.event?.bizType)" />
<t-cell v-if="record.numberValue !== undefined && record.numberValue !== null" title="事件数值" :note="getNumberValueText(record)" />
<t-cell v-if="record.pointValue !== undefined && record.pointValue !== null" title="提交积分值" :note="getPointValueText(record.pointValue, pointLedgerChangeType)" />
<t-cell v-if="record.earnedPointValue !== undefined && record.earnedPointValue !== null" title="本次积分变化" :note="getPointValueText(record.earnedPointValue, pointLedgerChangeType)" />
@@ -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<void> {
const currentRecord = record.value;
if (!currentRecord?.id) {
if (!currentRecord?.id || isPackagePurchaseRecord.value) {
throw new Error("登记记录不存在");
}
@@ -260,7 +263,7 @@ async function confirmVerifyRecord(): Promise<void> {
async function submitVerifyRecord(record: EventRecord, attachmentList: Attachment[]): Promise<void> {
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<void> {
if (isDeleting.value) {
if (isDeleting.value || isPackagePurchaseRecord.value) {
return;
}
@@ -338,7 +341,7 @@ async function removeRecord(id: string): Promise<void> {
async function toEdit(): Promise<void> {
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();
+35 -2
View File
@@ -46,11 +46,12 @@
/>
<div v-if="isEditMode" class="edit-point-tip red">涉及积分变动的数据不能修改,已产生积分流水的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建,删除会自动回流积分。</div>
<event-selector
v-model="selectedEventIdList"
:model-value="selectedEventIdList"
class="event-selector"
:event-list="eventList"
:disabled="isEditMode"
required-mark
@update:model-value="handleEventSelectionChange"
/>
<event-option-selector
class="option-selector"
@@ -219,6 +220,7 @@ 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 { isPackagePurchaseEvent } from "@/utils/event";
import { buildEventOptionSelectionMap, resolveSelectedOptionGroupList, validateEventOptionSelection } from "@/utils/eventOption";
import { getEventRecordPhotoURL, hasPhotoAttachment } from "@/utils/eventRecord";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
@@ -434,6 +436,27 @@ const eventNameMap = computed(() => {
return map;
});
async function handleEventSelectionChange(eventIdList: string[]): Promise<void> {
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<void> {
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<void> {
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] : [];
+10 -3
View File
@@ -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<void> {
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<void> {
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<void> {
function buildRangeReq(): Omit<EventRecordPage, "index" | "size"> {
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
};
@@ -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);
+2 -1
View File
@@ -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<void> {
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) {
@@ -0,0 +1,94 @@
<template>
<div v-can="permissionCodes.customerPackage.cancel" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="待取消套餐" theme="card" v-entrance-transition>
<t-cell title="客户" :note="customerPackage.customer?.name || customerPackage.customerId || '未知客户'" />
<t-cell title="套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="当前状态" :note="getCustomerPackageStatusText(customerPackage.status)" />
</t-cell-group>
<t-form class="form" label-align="left" v-entrance-transition="70">
<t-form-item label="取消原因" required-mark>
<t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入取消原因" />
</t-form-item>
</t-form>
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage } from "@/types/Package";
import { getCustomerPackageStatusText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageCancel" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const reason = ref("");
const isLoading = ref(false);
const isSaving = ref(false);
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
if (!customerPackage.value?.id || !reason.value.trim()) {
Message.warning("请输入取消原因");
return;
}
isSaving.value = true;
try {
await PackageAPI.cancel(customerPackage.value.id, reason.value.trim());
Message.success("客户套餐已取消");
await router.replace(`/management/customer-packages/${customerPackage.value.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "套餐取消失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.customerPackage.cancel)) {
navBarStore.setRightText("确认取消", "var(--td-danger-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
}
</style>
@@ -0,0 +1,226 @@
<template>
<div v-can="permissionCodes.packageDelivery.create" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="发放前状态(以提交时后端校验为准)" theme="card" v-entrance-transition>
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="收款状态" :note="getPaymentStatusText(customerPackage.paymentStatus)" />
</t-cell-group>
<section v-if="customerPackage" class="items" v-entrance-transition="70">
<div class="section-title"><strong>本次发放权益</strong><span>只能提交不超过可发数量的产品</span></div>
<div v-for="item in customerPackage.itemList || []" :key="item.id" class="item-card">
<div class="item-title"><strong v-text="`${item.productNameSnapshot}(${item.unitSnapshot})`" /><span v-text="`可发 ${getPackageQuantityText(item.availableQuantity)}`" /></div>
<t-input v-model.trim="quantityMap[item.id || '']" type="number" borderless placeholder="本次发放数量,不填表示不发放" />
</div>
</section>
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="140">
<t-form-item label="发放时间" arrow @click="deliveredPickerVisible = true">
<span class="picker-text" v-text="deliveredAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="发放备注" />
</t-form-item>
</t-form>
<package-photo-field v-if="customerPackage" v-model="photoItems" required title="发放照片" />
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
<t-popup v-model="deliveredPickerVisible" placement="bottom">
<t-date-time-picker v-model="deliveredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发放时间" @cancel="deliveredPickerVisible = false" @confirm="confirmDeliveredAt" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { comparePackageQuantity, createPackageRequestNo, getPackageQuantityText, getPaymentStatusText, normalizePackageQuantity } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageDelivery" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const quantityMap = reactive<Record<string, string>>({});
const photoItems = ref<PhotoItem[]>([]);
const remark = ref("");
const deliveredAtText = ref("");
const deliveredDraft = ref("");
const deliveredPickerVisible = ref(false);
const isLoading = ref(false);
const isSaving = ref(false);
function confirmDeliveredAt(value: string | number): void {
deliveredAtText.value = String(value);
deliveredPickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
for (const item of customerPackage.value.itemList || []) {
if (item.id) {
quantityMap[item.id] = "";
}
}
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
function validateItems(): Array<{ customerPackageItemId: string; quantity: string }> | undefined {
const result: Array<{ customerPackageItemId: string; quantity: string }> = [];
for (const item of customerPackage.value?.itemList || []) {
if (!item.id) {
continue;
}
const text = quantityMap[item.id] || "";
if (!text.trim()) {
continue;
}
const quantity = normalizePackageQuantity(text);
if (!quantity || quantity === "0") {
Message.warning(`${item.productNameSnapshot} 的数量必须是正数,最多三位小数`);
return undefined;
}
if (comparePackageQuantity(quantity, item.availableQuantity) > 0) {
Message.warning(`${item.productNameSnapshot} 的发放数量超过当前可发数量,请刷新后重新确认`);
return undefined;
}
result.push({ customerPackageItemId: item.id, quantity });
}
if (!result.length) {
Message.warning("请至少填写一项本次发放数量");
return undefined;
}
return result;
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
const customerPackageId = customerPackage.value?.id;
const itemList = validateItems();
if (!customerPackageId || !itemList) {
return;
}
if (customerPackage.value?.paymentStatus !== "PAID") {
Message.warning("套餐未付清,不能提交发放");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("发放必须上传至少一张照片");
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.createDelivery({
requestNo: createPackageRequestNo(),
customerPackageId,
deliveredAt: resolveTimestamp(deliveredAtText.value),
itemList,
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("发放单已提交,等待核销");
await PackageAPI.customerPackageDetail(customerPackageId);
if (result.id) {
await router.replace(`/management/package-deliveries/${result.id}`);
return;
}
await router.replace(`/management/customer-packages/${customerPackageId}`);
} catch (error) {
Message.error(getErrorMessage(error, "发放单提交失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.packageDelivery.create)) {
navBarStore.setRightText("提交发放", "var(--td-brand-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.items,
.package-photo-field {
padding: 0 1rem;
}
.section-title {
color: var(--td-text-color-secondary);
margin-bottom: .5rem;
font-size: .8125rem;
}
.section-title span {
margin-left: .5rem;
}
.item-card {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
display: grid;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
.item-title {
display: flex;
align-items: center;
justify-content: space-between;
}
.item-title span {
color: var(--td-brand-color);
font-size: .75rem;
}
}
</style>
+278
View File
@@ -0,0 +1,278 @@
<template>
<div v-can="permissionCodes.customerPackage.read" class="customer-package-detail">
<template v-if="customerPackage">
<customer-profile-card class="customer" :customer="customerPackage.customer" v-entrance-transition />
<section class="hero" v-entrance-transition="40">
<div class="title-row">
<h2 v-text="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-tag :content="getCustomerPackageStatusText(customerPackage.status)" variant="light-outline" :theme="getStatusTheme(customerPackage.status)" />
</div>
<div class="meta" v-text="customerPackage.purchasedAt ? `购买于 ${Time.toDateTime(customerPackage.purchasedAt)}` : '购买时间未知'" />
</section>
<t-cell-group title="金额与状态" theme="card" v-entrance-transition="70">
<t-cell title="标准价快照" :note="formatPackageAmount(customerPackage.standardAmountSnapshot)" />
<t-cell title="成交价" :note="formatPackageAmount(customerPackage.saleAmount)" />
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
<t-cell title="待核销收款" :note="formatPackageAmount(customerPackage.pendingPaidAmount)" />
<t-cell title="有效退款" :note="formatPackageAmount(customerPackage.refundedAmount)" />
<t-cell title="收款状态" :note="getPaymentStatusText(customerPackage.paymentStatus)" />
<t-cell title="退款状态" :note="getRefundStatusText(customerPackage.refundStatus)" />
</t-cell-group>
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140">
<t-cell v-for="item in customerPackage.itemList || []" :key="item.id" :title="item.productNameSnapshot" :description="`${getPackageQuantityText(item.deliveredQuantity)} ${item.unitSnapshot} 已核销`">
<template #note><div class="quantity" v-text="`可发 ${getPackageQuantityText(item.availableQuantity)}|待核销 ${getPackageQuantityText(item.pendingQuantity)}`" /></template>
</t-cell>
<div v-if="!customerPackage.itemList?.length" class="empty" v-text="'暂无产品权益'" />
</t-cell-group>
<t-cell-group v-if="customerPackage.purchaseEventRecord" title="购买记录照片" theme="card" v-entrance-transition="210">
<t-cell
v-if="canReadEventRecord && customerPackage.purchaseEventRecord.id"
title="购买登记记录"
note="查看关联的事件登记记录"
arrow
hover
@click="toPurchaseEventRecord"
/>
<photo-scroller :model-value="purchasePhotoURLList" :photo-items="purchasePhotoItems" title="购买照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
</t-cell-group>
<section class="actions" v-entrance-transition="240">
<t-button v-if="canReceivePayment" theme="primary" variant="outline" @click="toPayment">登记收款</t-button>
<t-button v-if="canCreateDelivery" theme="primary" variant="outline" @click="toDelivery">创建发放单</t-button>
<t-button v-if="canCancelPackage" theme="default" variant="outline" @click="toCancel">取消套餐</t-button>
<t-button v-if="canRefund" theme="danger" variant="outline" @click="toRefund">处理退款</t-button>
</section>
<t-cell-group title="业务记录" theme="card" v-entrance-transition="280">
<t-cell v-if="canReadDelivery" title="发放历史" :note="`${customerPackage.deliveryList?.length || 0} 条`" arrow hover @click="toDeliveryList" />
<t-cell v-if="canReadFund" title="资金历史" :note="`${customerPackage.fundRecordList?.length || 0} 条`" arrow hover @click="toFundList" />
</t-cell-group>
<t-cell-group v-if="customerPackage.deliveryList?.length" title="最近发放记录" theme="card" v-entrance-transition="320">
<t-cell
v-for="item in customerPackage.deliveryList"
:key="item.id"
:title="`${getDeliveryOperationText(item.operationType)} · ${getDeliveryStatusText(item.status)}`"
:description="item.deliveredAt ? Time.toDateTime(item.deliveredAt) : '时间未知'"
arrow
hover
@click="toDeliveryDetail(item)"
>
<template #note><span v-text="getDeliveryItemSummary(item)" /></template>
</t-cell>
</t-cell-group>
<t-cell-group v-if="customerPackage.fundRecordList?.length" title="最近资金记录" theme="card" v-entrance-transition="360">
<t-cell
v-for="item in customerPackage.fundRecordList"
:key="item.id"
:title="`${getFundOperationText(item.operationType)} · ${getFundStatusText(item.status)}`"
:description="item.occurredAt ? Time.toDateTime(item.occurredAt) : '时间未知'"
arrow
hover
@click="toFundDetail(item)"
>
<template #note><span v-text="formatPackageAmount(item.amount)" /></template>
</t-cell>
</t-cell-group>
</template>
<t-empty v-else :description="isLoading ? '正在加载客户套餐' : '客户套餐不存在'" />
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { GaoCustomerPackage, GaoFundRecord, GaoPackageDelivery, CustomerPackageStatus } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import {
formatPackageAmount,
getCustomerPackageStatusText,
getDeliveryOperationText,
getDeliveryStatusText,
getFundOperationText,
getFundStatusText,
getPackageQuantityText,
getPaymentStatusText,
getRefundStatusText
} from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageDetail" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const customerPackage = ref<GaoCustomerPackage>();
const isLoading = ref(false);
const canReceivePayment = computed(() => customerPackage.value?.status === "ACTIVE" && customerPackage.value.paymentStatus !== "PAID" && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canCreateDelivery = computed(() => customerPackage.value?.status === "ACTIVE" && customerPackage.value.paymentStatus === "PAID" && userStore.hasPermissions(permissionCodes.packageDelivery.create));
const canCancelPackage = computed(() => customerPackage.value?.status === "ACTIVE" && userStore.hasPermissions(permissionCodes.customerPackage.cancel));
const canRefund = computed(() => customerPackage.value?.status === "CANCELLED" && customerPackage.value.refundStatus === "PENDING" && userStore.hasPermissions(permissionCodes.fund.refund));
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
const canReadDelivery = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.read));
const canReadFund = computed(() => userStore.hasPermissions(permissionCodes.fundRecord.read));
const purchasePhotoItems = computed<PhotoItem[]>(() => (customerPackage.value?.purchaseEventRecord?.attachmentList || []).filter(item => !!item.id).map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
previewUrl: getAttachmentReadURL(item.id || ""),
url: getAttachmentReadURL(item.id || "")
})));
const purchasePhotoURLList = computed(() => purchasePhotoItems.value.map(item => item.url));
function getStatusTheme(status?: CustomerPackageStatus): "success" | "default" {
return status === "ACTIVE" ? "success" : "default";
}
function getDeliveryItemSummary(item: GaoPackageDelivery): string {
return (item.itemList || []).map(detail => `${detail.productNameSnapshot || "产品"} ${getPackageQuantityText(detail.quantity)} ${detail.unitSnapshot || ""}`).join("、") || "无明细";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
customerPackage.value = undefined;
Message.error(getErrorMessage(error, "客户套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function toPayment(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/payment`);
}
}
async function toPurchaseEventRecord(): Promise<void> {
const id = customerPackage.value?.purchaseEventRecord?.id;
if (id && canReadEventRecord.value) {
await router.push(`/management/registration-event-records/${id}`);
}
}
async function toDelivery(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/delivery/create`);
}
}
async function toCancel(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/cancel`);
}
}
async function toRefund(): Promise<void> {
if (customerPackage.value?.id) {
await router.push(`/management/customer-packages/${customerPackage.value.id}/refund`);
}
}
async function toDeliveryList(): Promise<void> {
if (customerPackage.value?.id) {
await router.push({ path: "/management/package-deliveries", query: { customerPackageId: customerPackage.value.id } });
}
}
async function toFundList(): Promise<void> {
if (customerPackage.value?.id) {
await router.push({ path: "/management/fund-records", query: { customerPackageId: customerPackage.value.id } });
}
}
async function toDeliveryDetail(item: GaoPackageDelivery): Promise<void> {
if (item.id) {
await router.push(`/management/package-deliveries/${item.id}`);
}
}
async function toFundDetail(item: GaoFundRecord): Promise<void> {
if (item.id) {
await router.push(`/management/fund-records/${item.id}`);
}
}
onMounted(loadData);
watch(() => route.params.id, loadData);
</script>
<style scoped lang="less">
.customer-package-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.customer {
margin: 0 1rem;
}
.hero {
margin: 0 1rem;
padding: 1rem;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
.title-row {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
margin: 0;
font-size: 1.125rem;
}
.meta {
color: var(--td-text-color-secondary);
margin-top: .5rem;
font-size: .8125rem;
}
}
.quantity {
color: var(--td-brand-color);
font-size: .75rem;
}
.actions {
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.t-button {
flex: 1 1 8rem;
}
}
:deep(.photo-scroller) {
padding: 1rem;
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div v-can="permissionCodes.customerPackage.read" class="customer-package-list">
<div class="filters">
<t-input v-model.trim="keyword" class="keyword" borderless clearable placeholder="搜索客户、编码或套餐名称" @enter="reload" />
<t-button theme="primary" size="small" @click="reload">搜索</t-button>
</div>
<t-dropdown-menu>
<t-dropdown-item v-model="status" label="套餐状态" :options="statusOptions" @change="selectStatus" />
<t-dropdown-item v-model="paymentStatus" label="收款状态" :options="paymentOptions" @change="selectPaymentStatus" />
</t-dropdown-menu>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="packageList.length" theme="card" class="list">
<t-cell v-for="(item, index) in packageList" :key="item.id" class="item" :arrow="!!item.id" :hover="!!item.id" v-entrance-transition="index * 30" @click="toDetail(item)">
<template #title>
<div class="title-row">
<strong v-text="item.packageNameSnapshot || '未命名套餐'" />
<t-tag :content="getCustomerPackageStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #description>
<div class="description">
<span v-text="item.customer?.name || item.customer?.code || item.customerId || '未知客户'" />
<span v-text="formatPackageAmount(item.saleAmount)" />
<span v-text="getPaymentStatusText(item.paymentStatus)" />
<span v-text="item.purchasedAt ? Time.toPassedDateTime(item.purchasedAt) : '未知时间'" />
</div>
</template>
<template #note>
<div class="paid" v-text="`已收 ${formatPackageAmount(item.paidAmount)}`" />
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载客户套餐' : '暂无客户套餐'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { CustomerPackageStatus, GaoCustomerPackage, PaymentStatus } from "@/types/Package";
import type { NavBarAction } from "@/types/NavBarAction";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getCustomerPackageStatusText, getPaymentStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageList" });
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.create));
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
key: "purchase",
text: "购买套餐",
icon: "add",
theme: "primary",
action: toPurchase
}] : []);
const keyword = ref("");
const status = ref("ALL");
const paymentStatus = ref("ALL");
const packageList = ref<GaoCustomerPackage[]>([]);
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "进行中", value: "ACTIVE" },
{ label: "已履约", value: "FULFILLED" },
{ label: "已取消", value: "CANCELLED" }
];
const paymentOptions = [
{ label: "全部收款", value: "ALL" },
{ label: "未收款", value: "UNPAID" },
{ label: "部分收款", value: "PARTIALLY_PAID" },
{ label: "已收款", value: "PAID" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.customerPackageList({
index: pageIndex.value,
size: 15,
keyword: keyword.value || undefined,
status: status.value === "ALL" ? undefined : status.value as CustomerPackageStatus,
paymentStatus: paymentStatus.value === "ALL" ? undefined : paymentStatus.value as PaymentStatus
});
const list = result.list || [];
packageList.value = packageList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= packageList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
packageList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
status.value = String(value);
await reload();
}
async function selectPaymentStatus(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
paymentStatus.value = String(value);
await reload();
}
async function toPurchase(): Promise<void> {
await router.push("/management/customer-packages/purchase");
}
async function toDetail(item: GaoCustomerPackage): Promise<void> {
if (item.id) {
await router.push(`/management/customer-packages/${item.id}`);
}
}
function getStatusTheme(statusValue?: CustomerPackageStatus): "success" | "warning" | "default" {
return statusValue === "ACTIVE" ? "success" : statusValue === "CANCELLED" ? "warning" : "default";
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canCreate.value);
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "客户套餐加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.customer-package-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.filters {
gap: .5rem;
margin: 0 1rem .75rem;
display: flex;
.keyword {
flex: 1;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
}
}
.item {
.title-row,
.description {
gap: .5rem;
display: flex;
align-items: center;
}
.description {
color: var(--td-text-color-secondary);
margin-top: .35rem;
font-size: .8125rem;
flex-wrap: wrap;
}
.paid {
color: var(--td-brand-color);
font-size: .75rem;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
@@ -0,0 +1,151 @@
<template>
<div v-can="permissionCodes.fundReceive.create" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="收款信息" theme="card" v-entrance-transition>
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="成交价" :note="formatPackageAmount(customerPackage.saleAmount)" />
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
<t-cell title="待核销收款" :note="formatPackageAmount(customerPackage.pendingPaidAmount)" />
</t-cell-group>
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="本次收款(元)" required-mark>
<t-input v-model.trim="amountText" type="number" align="right" borderless placeholder="请输入本次实际收款金额" />
</t-form-item>
<t-form-item label="发生时间" arrow @click="occurredPickerVisible = true">
<span class="picker-text" v-text="occurredAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="收款备注" />
</t-form-item>
</t-form>
<package-photo-field v-if="customerPackage" v-model="photoItems" required title="收款凭证照片" />
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
<t-popup v-model="occurredPickerVisible" placement="bottom">
<t-date-time-picker v-model="occurredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发生时间" @cancel="occurredPickerVisible = false" @confirm="confirmOccurredAt" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, parsePackageAmount } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackagePayment" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const isLoading = ref(false);
const isSaving = ref(false);
const amountText = ref("");
const occurredAtText = ref("");
const occurredDraft = ref("");
const occurredPickerVisible = ref(false);
const remark = ref("");
const photoItems = ref<PhotoItem[]>([]);
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function confirmOccurredAt(value: string | number): void {
occurredAtText.value = String(value);
occurredPickerVisible.value = false;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
const customerPackageId = customerPackage.value?.id;
const amount = parsePackageAmount(amountText.value);
if (!customerPackageId || amount === undefined || 0 >= amount) {
Message.warning("请输入大于 0 的合法收款金额");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("收款必须上传至少一张凭证照片");
return;
}
isSaving.value = true;
try {
await PackageAPI.receivePayment({
requestNo: createPackageRequestNo(),
customerPackageId,
amount,
occurredAt: resolveTimestamp(occurredAtText.value),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("收款已提交,等待核销");
await router.replace(`/management/customer-packages/${customerPackageId}`);
} catch (error) {
Message.error(getErrorMessage(error, "收款提交失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.fundReceive.create)) {
navBarStore.setRightText("提交收款", "var(--td-brand-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
@@ -0,0 +1,405 @@
<template>
<div v-can="permissionCodes.customerPackage.create" class="customer-package-purchase">
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="客户" required-mark arrow @click="customerSelectorVisible = true">
<span class="picker-text" v-text="selectedCustomer?.name || selectedCustomer?.code || '请选择客户'" />
</t-form-item>
<customer-profile-card v-if="selectedCustomer" class="customer-profile" :customer="selectedCustomer" />
<t-form-item label="购买事件" required-mark arrow @click="openEventPicker">
<span class="picker-text" v-text="selectedEvent?.name || '请选择 PACKAGE_PURCHASE 事件'" />
</t-form-item>
<div v-if="selectedConfig" class="config-summary">
<strong v-text="selectedConfig.name || '未命名套餐'" />
<span v-text="`标准价:${formatPackageAmount(selectedConfig.standardAmount)}|${getPackageConfigStatusText(selectedConfig.status)}`" />
</div>
<div v-if="!eventList.length" class="option-tip">
<div v-if="!eventList.length">当前门店没有可用的 PACKAGE_PURCHASE 购买事件,请先新增并启用购买事件。</div>
</div>
<div v-if="selectedEvent && !selectedConfig" class="option-tip">当前购买事件未返回绑定套餐,无法购买,请联系管理员检查事件配置。</div>
<div v-if="selectedConfig && !isSelectedConfigSaleable" class="option-tip">事件绑定的套餐当前不可购买,请检查套餐状态和有效期。</div>
<t-form-item label="成交价(元)" required-mark>
<t-input v-model.trim="saleAmountText" type="number" align="right" borderless :readonly="!canPriceOverride" placeholder="请输入成交价" />
</t-form-item>
<t-form-item label="购买时间" arrow @click="openPurchaseTime">
<span class="picker-text" v-text="purchasedAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="购买备注" />
</t-form-item>
</t-form>
<section class="items" v-entrance-transition="70">
<div class="section-title"><strong>购买权益明细</strong><span v-if="!canPriceOverride">当前账号无定制权限,明细不可修改</span></div>
<div v-for="(item, index) in purchaseItems" :key="item.clientKey" class="item-card">
<strong v-text="`权益 ${index + 1}`" />
<t-input v-model.trim="item.productName" borderless readonly placeholder="产品名称" />
<t-input v-model.trim="item.unit" borderless readonly placeholder="单位" />
<t-input v-model.trim="item.totalQuantity" type="number" borderless :readonly="!canPriceOverride" placeholder="总权益数量" />
<span v-if="item.remark" class="item-remark" v-text="`备注:${item.remark}`" />
</div>
<div v-if="!purchaseItems.length" class="empty" v-text="'选择购买事件后加载绑定套餐权益明细'" />
</section>
<package-photo-field v-model="photoItems" :required="selectedEvent?.requirePhoto === true" title="购买照片" />
<customer-selector v-model="selectedCustomerId" v-model:visible="customerSelectorVisible" :store-id="formStoreId" @change="handleCustomerChange" />
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker v-model="storeDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
</t-popup>
<t-popup v-model="eventPickerVisible" placement="bottom">
<t-picker v-model="eventDraft" :columns="eventPickerColumns" title="选择购买事件" @cancel="eventPickerVisible = false" @confirm="confirmEvent" />
</t-popup>
<t-popup v-model="purchasePickerVisible" placement="bottom">
<t-date-time-picker v-model="purchaseDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="购买时间" @cancel="purchasePickerVisible = false" @confirm="confirmPurchaseTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import PackageAPI from "@/api/PackageAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { GaoPackageConfig, PackageAttachment, PackagePurchaseItem } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
type PurchaseFormItem = PackagePurchaseItem & { clientKey: string };
defineOptions({ name: "CustomerPackagePurchase" });
const router = useRouter();
const route = useRoute();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const currentStoreStore = useCurrentStoreStore();
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const canPriceOverride = computed(() => userStore.hasPermissions(permissionCodes.customerPackage.priceOverride));
const formStoreId = ref("");
const selectedCustomerId = ref("");
const selectedCustomer = ref<Customer>();
const selectedEventId = ref("");
const eventList = ref<EventItem[]>([]);
const purchaseItems = ref<PurchaseFormItem[]>([]);
const selectedEvent = computed(() => eventList.value.find(item => item.id === selectedEventId.value));
const selectedConfig = computed<GaoPackageConfig | undefined>(() => selectedEvent.value?.packageConfig);
const isSelectedConfigSaleable = computed(() => {
const config = selectedConfig.value;
if (!config || config.status !== "ACTIVE") {
return false;
}
const now = Date.now();
const beginAt = config.beginAt;
const endAt = config.endAt;
return (beginAt === undefined || beginAt === null || beginAt <= now)
&& (endAt === undefined || endAt === null || now <= endAt);
});
const saleAmountText = ref("");
const purchasedAtText = ref("");
const purchaseDraft = ref("");
const remark = ref("");
const photoItems = ref<PhotoItem[]>([]);
const isSaving = ref(false);
const customerSelectorVisible = ref(false);
const storePickerVisible = ref(false);
const eventPickerVisible = ref(false);
const purchasePickerVisible = ref(false);
const storeDraft = ref<PickerValue[]>([]);
const eventDraft = ref<PickerValue[]>([]);
const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
const eventPickerColumns = computed(() => [[...eventList.value].map(item => ({ label: item.name || "未命名事件", value: item.id || "" }))]);
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === formStoreId.value)?.name || formStoreId.value || "请选择门店");
async function loadOptions(): Promise<void> {
eventList.value = formStoreId.value
? (await EventAPI.listValid(formStoreId.value)).filter(item => item.bizType === "PACKAGE_PURCHASE" && !!item.packageConfigId)
: [];
if (!eventList.value.some(item => item.id === selectedEventId.value)) {
selectedEventId.value = "";
resetPurchaseConfig();
}
}
async function handleCustomerChange(id: string): Promise<void> {
selectedCustomerId.value = id;
selectedCustomer.value = id ? await CustomerAPI.detail(id) : undefined;
}
function openStorePicker(): void {
storeDraft.value = [formStoreId.value];
storePickerVisible.value = true;
}
function openEventPicker(): void {
if (!eventList.value.length) {
Message.warning("当前门店没有 PACKAGE_PURCHASE 类型购买事件,请先启用购买事件");
return;
}
eventDraft.value = [selectedEventId.value];
eventPickerVisible.value = true;
}
async function confirmStore(value: Array<string | number>): Promise<void> {
const storeId = String(value[0] || "");
if (storeId && storeId !== formStoreId.value) {
formStoreId.value = storeId;
selectedCustomerId.value = "";
selectedCustomer.value = undefined;
await loadOptions();
}
storePickerVisible.value = false;
}
function resetPurchaseConfig(): void {
saleAmountText.value = "";
purchaseItems.value = [];
}
function applySelectedEvent(): void {
const config = selectedConfig.value;
if (!config) {
resetPurchaseConfig();
return;
}
saleAmountText.value = formatPackageAmountInput(config.standardAmount);
purchaseItems.value = (config.itemList || []).map((item, index) => ({
clientKey: window.crypto.randomUUID(),
productName: item.productName,
unit: item.unit,
totalQuantity: normalizePackageQuantity(item.defaultQuantity) || "",
sort: item.sort ?? index,
remark: item.remark ?? null
}));
}
function confirmEvent(value: Array<string | number>): void {
selectedEventId.value = String(value[0] || "");
applySelectedEvent();
eventPickerVisible.value = false;
}
function openPurchaseTime(): void {
purchaseDraft.value = purchasedAtText.value || Time.formatDateTime(new Date());
purchasePickerVisible.value = true;
}
function confirmPurchaseTime(value: string | number): void {
purchasedAtText.value = String(value);
purchasePickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function validateForm(): boolean {
if (!selectedCustomerId.value) {
Message.warning("请选择客户");
return false;
}
if (!selectedEventId.value || !selectedEvent.value) {
Message.warning("请选择 PACKAGE_PURCHASE 类型购买事件");
return false;
}
if (!selectedConfig.value) {
Message.warning("当前购买事件未绑定有效套餐配置");
return false;
}
if (!isSelectedConfigSaleable.value) {
Message.warning("事件绑定的套餐当前不可购买,请检查套餐状态和有效期");
return false;
}
if (parsePackageAmount(saleAmountText.value) === undefined) {
Message.warning("成交价必须是合法的元金额,最多两位小数");
return false;
}
if (!purchaseItems.value.length) {
Message.warning("购买权益至少需要一项");
return false;
}
for (const item of purchaseItems.value) {
if (!item.productName.trim() || !item.unit.trim() || !normalizePackageQuantity(item.totalQuantity) || normalizePackageQuantity(item.totalQuantity) === "0") {
Message.warning("产品名称、单位和正数量不能为空");
return false;
}
}
if (selectedEvent.value.requirePhoto && !photoItems.value.some(item => item.tempFileId)) {
Message.warning("当前购买事件要求上传照片");
return false;
}
return true;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function save(): Promise<void> {
if (isSaving.value || !validateForm()) {
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.purchase({
requestNo: createPackageRequestNo(),
eventId: selectedEventId.value,
customerId: selectedCustomerId.value,
saleAmount: parsePackageAmount(saleAmountText.value)!,
purchasedAt: resolveTimestamp(purchasedAtText.value),
itemList: purchaseItems.value.map((item, index) => ({
productName: item.productName.trim(),
unit: item.unit.trim(),
totalQuantity: normalizePackageQuantity(item.totalQuantity)!,
sort: item.sort ?? index,
remark: item.remark ?? null
})),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("套餐购买已提交");
if (result.id) {
await router.replace(`/management/customer-packages/${result.id}`);
return;
}
await router.replace("/management/customer-packages");
} catch (error) {
Message.error(getErrorMessage(error, "套餐购买失败,请刷新后重试"));
} finally {
isSaving.value = false;
}
}
async function loadData(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
formStoreId.value = currentStoreStore.currentStoreId;
if (!formStoreId.value) {
const store = await currentStoreStore.loadLoginStore();
formStoreId.value = store?.id || "";
}
await loadOptions();
const queryEventId = String(route.query.eventId || "");
if (queryEventId && eventList.value.some(item => item.id === queryEventId)) {
selectedEventId.value = queryEventId;
applySelectedEvent();
}
const queryCustomerId = String(route.query.customerId || "");
if (queryCustomerId) {
await handleCustomerChange(queryCustomerId);
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.customerPackage.create)) {
navBarStore.setRightText("提交购买", "var(--td-brand-color)", save);
}
try {
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "购买页面数据加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.customer-package-purchase {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.customer-profile {
margin: 0 1rem;
}
.items,
.package-photo-field {
padding: 0 1rem;
}
.section-title {
color: var(--td-text-color-secondary);
margin-bottom: .5rem;
font-size: .8125rem;
}
.section-title span {
margin-left: .5rem;
}
.item-card {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
display: grid;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
.config-summary {
gap: .25rem;
padding: .75rem 1rem;
display: flex;
color: var(--td-text-color-secondary);
font-size: .8125rem;
flex-direction: column;
background: var(--td-bg-color-secondarycontainer);
strong {
color: var(--td-text-color-primary);
font-size: .9375rem;
}
}
.item-remark {
color: var(--td-text-color-secondary);
font-size: .8125rem;
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
.option-tip {
padding: .75rem 1rem;
color: var(--td-text-color-secondary);
font-size: .8125rem;
line-height: 1.5;
background: var(--td-bg-color-secondarycontainer);
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div v-can="permissionCodes.fund.refund" class="package-operation-edit">
<t-cell-group v-if="customerPackage" title="退款信息" theme="card" v-entrance-transition>
<t-cell title="客户套餐" :note="customerPackage.packageNameSnapshot || '未命名套餐'" />
<t-cell title="已核销收款" :note="formatPackageAmount(customerPackage.paidAmount)" />
<t-cell title="当前退款状态" :note="getRefundStatusText(customerPackage.refundStatus)" />
</t-cell-group>
<t-form v-if="customerPackage" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="确认无需退款" content-align="right">
<t-switch v-model="confirmNone" />
</t-form-item>
<t-form-item v-if="!confirmNone" label="退款金额(元)" required-mark>
<t-input v-model.trim="amountText" type="number" align="right" borderless placeholder="请输入实际退款金额,不自动填充" />
</t-form-item>
<t-form-item label="退款原因" required-mark>
<t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入原因" />
</t-form-item>
<t-form-item label="发生时间" arrow @click="occurredPickerVisible = true">
<span class="picker-text" v-text="occurredAtText || '使用当前时间'" />
</t-form-item>
<t-form-item label="备注">
<t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" />
</t-form-item>
</t-form>
<package-photo-field v-if="customerPackage && !confirmNone" v-model="photoItems" required title="退款凭证照片" />
<t-empty v-if="!customerPackage" :description="isLoading ? '正在加载套餐' : '套餐不存在'" />
<t-popup v-model="occurredPickerVisible" placement="bottom">
<t-date-time-picker v-model="occurredDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="发生时间" @cancel="occurredPickerVisible = false" @confirm="confirmOccurredAt" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoCustomerPackage, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, parsePackageAmount, getRefundStatusText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "CustomerPackageRefund" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const customerPackage = ref<GaoCustomerPackage>();
const confirmNone = ref(false);
const amountText = ref("");
const reason = ref("");
const remark = ref("");
const occurredAtText = ref("");
const occurredDraft = ref("");
const occurredPickerVisible = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
function confirmOccurredAt(value: string | number): void {
occurredAtText.value = String(value);
occurredPickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO", name: item.attachment?.name }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
customerPackage.value = await PackageAPI.customerPackageDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
const customerPackageId = customerPackage.value?.id;
if (!customerPackageId || !reason.value.trim()) {
Message.warning("请输入退款原因");
return;
}
const amount = confirmNone.value ? undefined : parsePackageAmount(amountText.value);
if (!confirmNone.value && (amount === undefined || 0 >= amount)) {
Message.warning("请输入大于 0 的合法退款金额");
return;
}
if (!confirmNone.value && !photoItems.value.some(item => item.tempFileId)) {
Message.warning("退款必须上传至少一张凭证照片");
return;
}
isSaving.value = true;
try {
if (confirmNone.value) {
await PackageAPI.confirmNoRefund({
requestNo: createPackageRequestNo(),
customerPackageId,
occurredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
remark: remark.value.trim() || null
});
} else {
await PackageAPI.refund({
requestNo: createPackageRequestNo(),
customerPackageId,
amount: amount!,
occurredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
}
Message.success(confirmNone.value ? "已确认无需退款" : "退款已登记");
await router.replace(`/management/customer-packages/${customerPackageId}`);
} catch (error) {
Message.error(getErrorMessage(error, "退款操作失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.fund.refund)) {
navBarStore.setRightText("提交处理", "var(--td-brand-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
+190
View File
@@ -0,0 +1,190 @@
<template>
<div v-can="permissionCodes.fundRecord.read" class="package-record-detail">
<template v-if="record">
<t-cell-group title="资金记录" theme="card" v-entrance-transition>
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
<t-cell title="状态"><template #note><t-tag :content="getFundStatusText(record.status)" :theme="getStatusTheme(record.status)" variant="light-outline" /></template></t-cell>
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
<t-cell title="客户套餐" :note="record.customerPackageId || record.bizId || '未知'" />
<t-cell title="发生时间" :note="record.occurredAt ? Time.toDateTime(record.occurredAt) : '未知'" />
<t-cell title="提交人" :note="record.operator?.name || record.operator?.nick || record.operatorUserId || '未知'" />
<t-cell title="处理人" :note="record.verifier?.name || record.verifier?.nick || record.verifierUserId || '未处理'" />
<t-cell v-if="record.sourceRecordId" title="原资金记录" :note="record.sourceRecordId" />
<t-cell v-if="record.reason" title="原因" :note="record.reason" />
<t-cell v-if="record.remark" title="备注" :note="record.remark" />
</t-cell-group>
<photo-scroller :model-value="photoURLList" :photo-items="photoItems" title="业务照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
<section v-if="isPendingReceive || canRevoke" class="actions" v-entrance-transition="70">
<t-button v-if="canVerifyReceive" theme="primary" @click="verifyReceive">核销收款</t-button>
<t-button v-if="canVerifyReceive" theme="default" variant="outline" @click="openReasonDialog('reject')">拒绝收款</t-button>
<t-button v-if="canCancelReceive" theme="default" variant="outline" @click="openReasonDialog('cancel')">撤销收款</t-button>
<t-button v-if="canRevoke" theme="danger" variant="outline" @click="toRevoke">资金冲正</t-button>
</section>
</template>
<t-empty v-else :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
<t-dialog v-model:visible="reasonDialogVisible" :title="reasonTitle" :confirm-btn="reasonActionText" @confirm="submitReason">
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { FundRecordStatus, GaoFundRecord } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ReasonAction = "reject" | "cancel";
defineOptions({ name: "FundRecordDetail" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const record = ref<GaoFundRecord>();
const isLoading = ref(false);
const isOperating = ref(false);
const reasonDialogVisible = ref(false);
const reasonText = ref("");
const reasonAction = ref<ReasonAction>("reject");
const isPendingReceive = computed(() => record.value?.operationType === "RECEIVE" && record.value.status === "PENDING");
const canVerifyReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.verify));
const canCancelReceive = computed(() => isPendingReceive.value && userStore.hasPermissions(permissionCodes.fundReceive.create));
const canRevoke = computed(() => record.value?.status === "VERIFIED" && record.value.operationType !== "REVOKE" && userStore.hasPermissions(permissionCodes.fund.revoke));
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝收款" : "撤销收款");
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
const photoItems = computed<PhotoItem[]>(() => (record.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
url: getAttachmentReadURL(item.id || "")
})));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
function getStatusTheme(status?: FundRecordStatus): "success" | "danger" | "default" {
return status === "VERIFIED" ? "success" : status === "REJECTED" ? "danger" : "default";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
record.value = await PackageAPI.fundRecordDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "资金记录加载失败"));
} finally {
isLoading.value = false;
}
}
async function refreshRelatedPackage(): Promise<void> {
const customerPackageId = record.value?.customerPackageId || record.value?.bizId;
if (customerPackageId) {
await PackageAPI.customerPackageDetail(customerPackageId);
}
}
async function verifyReceive(): Promise<void> {
if (!record.value?.id || !canVerifyReceive.value || isOperating.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "核销收款",
content: "核销后该金额才会计入套餐的已收款。确定继续?",
confirmBtn: "核销",
onConfirm: async () => {
isOperating.value = true;
try {
await PackageAPI.verifyPayment(record.value!.id!);
await refreshRelatedPackage();
Message.success("收款已核销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "收款核销失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
dialog.destroy();
}
}
});
}
function openReasonDialog(action: ReasonAction): void {
if (!isPendingReceive.value || isOperating.value) {
return;
}
reasonAction.value = action;
reasonText.value = "";
reasonDialogVisible.value = true;
}
async function submitReason(): Promise<void> {
if (!record.value?.id || !reasonText.value.trim() || isOperating.value) {
Message.warning("请输入原因");
return;
}
isOperating.value = true;
try {
if (reasonAction.value === "reject") {
await PackageAPI.rejectPayment(record.value.id, reasonText.value.trim());
} else {
await PackageAPI.cancelPayment(record.value.id, reasonText.value.trim());
}
reasonDialogVisible.value = false;
await refreshRelatedPackage();
Message.success(reasonAction.value === "reject" ? "收款已拒绝" : "收款已撤销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "收款状态操作失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
}
}
async function toRevoke(): Promise<void> {
if (record.value?.id && canRevoke.value) {
await router.push(`/management/fund-records/${record.value.id}/revoke`);
}
}
onMounted(loadData);
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
:deep(.photo-scroller) {
padding: 0 1rem;
}
.actions {
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.t-button {
flex: 1 1 8rem;
}
}
}
</style>
+168
View File
@@ -0,0 +1,168 @@
<template>
<div v-can="permissionCodes.fundRecord.read" class="package-record-list">
<t-dropdown-menu>
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperation" />
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
</t-dropdown-menu>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="recordList.length" theme="card">
<t-cell
v-for="(item, index) in recordList"
:key="item.id"
:title="`${getFundOperationText(item.operationType)} · ${getFundStatusText(item.status)}`"
:description="getDescription(item)"
arrow
hover
v-entrance-transition="index * 30"
@click="toDetail(item)"
>
<template #title>
<div class="title-row">
<span v-text="getFundOperationText(item.operationType)" />
<t-tag :content="getFundStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #note><span class="amount" v-text="formatPackageAmount(item.amount)" /></template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载资金流水' : '暂无资金流水'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import PackageAPI from "@/api/PackageAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { FundOperationType, FundRecordStatus, GaoFundRecord } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "FundRecordList" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const recordList = ref<GaoFundRecord[]>([]);
const operationType = ref("ALL");
const status = ref("ALL");
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const operationOptions = [
{ label: "全部类型", value: "ALL" },
{ label: "线下收款", value: "RECEIVE" },
{ label: "退款", value: "REFUND" },
{ label: "确认无需退款", value: "CONFIRM_NO_REFUND" },
{ label: "资金冲正", value: "REVOKE" }
];
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "待核销", value: "PENDING" },
{ label: "已核销", value: "VERIFIED" },
{ label: "已拒绝", value: "REJECTED" },
{ label: "已撤销", value: "CANCELLED" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.fundRecordList({
index: pageIndex.value,
size: 15,
customerPackageId: String(route.query.customerPackageId || "") || undefined,
operationType: operationType.value === "ALL" ? undefined : operationType.value as FundOperationType,
status: status.value === "ALL" ? undefined : status.value as FundRecordStatus
});
const list = result.list || [];
recordList.value = recordList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= recordList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
recordList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectOperation(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
operationType.value = String(value);
await reload();
}
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
status.value = String(value);
await reload();
}
}
function getDescription(item: GaoFundRecord): string {
return `${item.customer?.name || item.customerId || "未知客户"}|${item.occurredAt ? Time.toDateTime(item.occurredAt) : "时间未知"}`;
}
function getStatusTheme(statusValue?: FundRecordStatus): "success" | "warning" | "danger" | "default" {
return statusValue === "VERIFIED" ? "success" : statusValue === "PENDING" ? "warning" : statusValue === "REJECTED" ? "danger" : "default";
}
async function toDetail(item: GaoFundRecord): Promise<void> {
if (item.id) {
await router.push(`/management/fund-records/${item.id}`);
}
}
onMounted(async () => {
navBarStore.clearRight();
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "资金流水加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.title-row {
gap: .5rem;
display: flex;
align-items: center;
}
.amount {
color: var(--td-brand-color);
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div v-can="permissionCodes.fund.revoke" class="package-operation-edit">
<t-cell-group v-if="record" title="原资金记录" theme="card" v-entrance-transition>
<t-cell title="操作类型" :note="getFundOperationText(record.operationType)" />
<t-cell title="金额" :note="formatPackageAmount(record.amount)" />
<t-cell title="原状态" :note="getFundStatusText(record.status)" />
<t-cell title="客户套餐" :note="record.customerPackageId || record.bizId || '未知'" />
</t-cell-group>
<t-form v-if="record" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="冲正原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入冲正原因" /></t-form-item>
<t-form-item label="冲正时间" arrow @click="pickerVisible = true"><span class="picker-text" v-text="occurredAtText || '使用当前时间'" /></t-form-item>
<t-form-item label="备注"><t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" /></t-form-item>
</t-form>
<package-photo-field v-if="record" v-model="photoItems" required title="冲正凭证照片" />
<t-empty v-if="!record" :description="isLoading ? '正在加载资金记录' : '资金记录不存在'" />
<t-popup v-model="pickerVisible" placement="bottom">
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="冲正时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoFundRecord, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, formatPackageAmount, getFundOperationText, getFundStatusText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "FundRecordRevoke" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const record = ref<GaoFundRecord>();
const reason = ref("");
const remark = ref("");
const occurredAtText = ref("");
const draftTime = ref("");
const pickerVisible = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
function confirmTime(value: string | number): void {
occurredAtText.value = String(value);
pickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
record.value = await PackageAPI.fundRecordDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "资金记录加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
if (!record.value?.id || record.value.status !== "VERIFIED" || record.value.operationType === "REVOKE") {
Message.warning("只有已核销且非冲正的资金记录可以冲正");
return;
}
if (!reason.value.trim()) {
Message.warning("请输入冲正原因");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("资金冲正必须上传至少一张照片");
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.revokeFundRecord({
requestNo: createPackageRequestNo(),
fundRecordId: record.value.id,
occurredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("资金记录已冲正");
const customerPackageId = record.value.customerPackageId || record.value.bizId;
if (customerPackageId) {
await router.replace(`/management/customer-packages/${customerPackageId}`);
return;
}
await router.replace(`/management/fund-records/${result.id || record.value.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "资金冲正失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.fund.revoke)) {
navBarStore.setRightText("提交冲正", "var(--td-danger-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<div v-can="permissionCodes.packageConfig.read" class="package-config-detail">
<template v-if="config">
<section class="hero" v-entrance-transition>
<div class="title-row"><h2 v-text="config.name || '未命名套餐'" /><t-tag :content="getPackageConfigStatusText(config.status)" variant="light-outline" :theme="getStatusTheme(config.status)" /></div>
<p v-text="config.description || '暂无套餐说明'" />
</section>
<t-cell-group title="基本信息" theme="card" v-entrance-transition="70">
<t-cell title="所属门店" :note="storeName" />
<t-cell title="标准价" :note="formatPackageAmount(config.standardAmount)" />
<t-cell title="有效期" :note="formatValidity" />
<t-cell title="最后修改人" :note="config.updater?.name || config.updater?.nick || '未知'" />
</t-cell-group>
<t-cell-group title="产品权益" theme="card" v-entrance-transition="140">
<t-cell
v-for="item in config.itemList || []"
:key="item.id || `${item.productName}-${item.unit}`"
:title="item.productName"
:note="`${item.defaultQuantity} ${item.unit}${item.remark ? `|${item.remark}` : ''}`"
/>
<div v-if="!config.itemList?.length" class="empty" v-text="'暂无产品权益'" />
</t-cell-group>
<t-button v-if="canDelete" class="delete-button" theme="danger" variant="outline" @click="removeConfig">删除配置</t-button>
</template>
<t-empty v-else :description="isLoading ? '正在加载套餐配置' : '套餐配置不存在'" />
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageConfigDetail" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const config = ref<GaoPackageConfig>();
const isLoading = ref(false);
const canUpdate = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.update));
const canDelete = computed(() => !!config.value && userStore.hasPermissions(permissionCodes.packageConfig.delete));
const storeName = computed(() => {
const storeId = config.value?.storeId;
return currentStoreStore.storeList.find(item => item.id === storeId)?.name || storeId || "未绑定门店";
});
const formatValidity = computed(() => {
const begin = config.value?.beginAt ? Time.toDateTime(config.value.beginAt) : "不限起始";
const end = config.value?.endAt ? Time.toDateTime(config.value.endAt) : "不限结束";
return `${begin} 至 ${end}`;
});
function getStatusTheme(status?: PackageConfigStatus): "success" | "warning" | "default" {
return status === "ACTIVE" ? "success" : status === "INACTIVE" ? "warning" : "default";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
if (userStore.hasRoles(gaoRoleCodes.globalManager)) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
config.value = await PackageAPI.configDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
} finally {
isLoading.value = false;
}
}
async function toEdit(): Promise<void> {
if (config.value?.id && canUpdate.value) {
await router.push(`/management/package-configs/${config.value.id}/edit`);
}
}
function removeConfig(): void {
if (!config.value?.id || !canDelete.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "删除套餐配置",
content: "删除后该配置不再出现在可购买列表中,已购买的客户套餐不受影响。",
confirmBtn: { content: "删除", theme: "danger" },
onConfirm: async () => {
try {
await PackageAPI.configRemove(config.value!.id!);
Message.success("套餐配置已删除");
await router.replace("/management/package-configs");
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置删除失败"));
} finally {
dialog.destroy();
}
}
});
}
onMounted(async () => {
await loadData();
navBarStore.clearRight();
if (canUpdate.value) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
});
watch(canUpdate, value => {
navBarStore.clearRight();
if (value) {
navBarStore.setRightText("编辑", "var(--td-brand-color)", toEdit);
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-config-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.hero {
margin: 0 1rem;
padding: 1rem;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: 1rem;
.title-row {
gap: .5rem;
display: flex;
align-items: center;
justify-content: space-between;
}
h2 {
margin: 0;
font-size: 1.125rem;
}
p {
color: var(--td-text-color-secondary);
margin: .5rem 0 0;
line-height: 1.5;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
.delete-button {
margin: 0 1rem;
}
}
</style>
+358
View File
@@ -0,0 +1,358 @@
<template>
<div v-can="savePermissionCode" class="package-config-edit">
<t-form class="form" label-align="left" label-width="6rem" v-entrance-transition>
<t-form-item v-if="isGlobalManager" label="所属门店" arrow @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="套餐名称" required-mark>
<t-input v-model="form.name" borderless maxlength="100" placeholder="请输入套餐名称" />
</t-form-item>
<t-form-item label="套餐说明">
<t-textarea v-model="form.description" borderless maxlength="500" autosize placeholder="可填写套餐说明" />
</t-form-item>
<t-form-item label="标准价(元)" required-mark>
<t-input v-model.trim="standardAmountText" type="number" align="right" borderless placeholder="例如 1288.00" />
</t-form-item>
<t-form-item label="状态" arrow @click="openStatusPicker">
<span class="picker-text" v-text="getPackageConfigStatusText(form.status)" />
</t-form-item>
<t-form-item label="开始时间" arrow @click="openBeginPicker">
<span class="picker-text" v-text="beginAtText || '不限'" />
</t-form-item>
<t-form-item label="结束时间" arrow @click="openEndPicker">
<span class="picker-text" v-text="endAtText || '不限'" />
</t-form-item>
</t-form>
<section class="items" v-entrance-transition="70">
<div class="section-title"><strong>产品权益</strong><span>至少一项,数量最多三位小数</span></div>
<div v-for="(item, index) in form.itemList" :key="item.clientKey" class="item-card">
<div class="item-head"><span v-text="`权益 ${index + 1}`" /><t-button theme="danger" variant="text" size="small" @click="removeItem(index)">删除</t-button></div>
<t-input v-model="item.productName" borderless placeholder="产品名称" />
<t-input v-model.trim="item.unit" borderless placeholder="单位,例如 盒、次" />
<t-input v-model.trim="item.defaultQuantity" type="number" borderless placeholder="默认数量" />
<t-input v-model="item.remark" borderless placeholder="备注(可选)" />
</div>
<t-button class="add-item" theme="primary" variant="dashed" block @click="addItem">添加产品权益</t-button>
</section>
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker v-model="storePickerDraft" :columns="storePickerColumns" title="选择门店" @cancel="storePickerVisible = false" @confirm="confirmStore" />
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker v-model="statusPickerDraft" :columns="statusOptions" title="状态" @cancel="statusPickerVisible = false" @confirm="confirmStatus" />
</t-popup>
<t-popup v-model="beginPickerVisible" placement="bottom">
<t-date-time-picker v-model="beginDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="开始时间" @cancel="beginPickerVisible = false" @confirm="confirmBegin" />
</t-popup>
<t-popup v-model="endPickerVisible" placement="bottom">
<t-date-time-picker v-model="endDraft" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="结束时间" @cancel="endPickerVisible = false" @confirm="confirmEnd" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageConfig, PackageConfigItem, PackageConfigStatus } from "@/types/Package";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmountInput, getPackageConfigStatusText, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
type PackageConfigFormItem = PackageConfigItem & { clientKey: string };
defineOptions({ name: "PackageConfigEdit" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const currentStoreStore = useCurrentStoreStore();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.packageConfig.create : permissionCodes.packageConfig.update);
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const isSaving = ref(false);
const form = reactive({
id: "",
storeId: "",
name: "",
description: "",
status: "DRAFT" as PackageConfigStatus,
beginAt: undefined as number | undefined,
endAt: undefined as number | undefined,
itemList: [] as PackageConfigFormItem[]
});
const standardAmountText = ref("");
const beginAtText = ref("");
const endAtText = ref("");
const storePickerVisible = ref(false);
const storePickerDraft = ref<PickerValue[]>([]);
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<PickerValue[]>([]);
const beginPickerVisible = ref(false);
const endPickerVisible = ref(false);
const beginDraft = ref("");
const endDraft = ref("");
const statusOptions = [
{ label: "草稿", value: "DRAFT" },
{ label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" }
];
const storePickerColumns = computed(() => [[...currentStoreStore.storeList].map(item => ({ label: item.name || item.code || item.id || "未知门店", value: item.id || "" }))]);
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === form.storeId)?.name || form.storeId || "请选择门店");
function createItem(item?: Partial<PackageConfigItem>): PackageConfigFormItem {
return {
...item,
clientKey: window.crypto.randomUUID(),
productName: item?.productName || "",
unit: item?.unit || "",
defaultQuantity: item?.defaultQuantity ?? "",
remark: item?.remark || ""
};
}
function addItem(): void {
form.itemList.push(createItem());
}
function openStorePicker(): void {
storePickerDraft.value = [form.storeId];
storePickerVisible.value = true;
}
function openStatusPicker(): void {
statusPickerDraft.value = [form.status];
statusPickerVisible.value = true;
}
function removeItem(index: number): void {
if (1 < form.itemList.length) {
form.itemList.splice(index, 1);
return;
}
Message.warning("套餐至少需要一项产品权益");
}
function confirmStore(value: Array<string | number>): void {
const storeId = String(value[0] || "");
if (storeId) {
form.storeId = storeId;
}
storePickerVisible.value = false;
}
function confirmStatus(value: Array<string | number>): void {
const status = String(value[0] || "");
if (status === "DRAFT" || status === "ACTIVE" || status === "INACTIVE") {
form.status = status;
}
statusPickerVisible.value = false;
}
function openBeginPicker(): void {
beginDraft.value = beginAtText.value || Time.formatDateTime(new Date());
beginPickerVisible.value = true;
}
function openEndPicker(): void {
endDraft.value = endAtText.value || Time.formatDateTime(new Date());
endPickerVisible.value = true;
}
function confirmBegin(value: string | number): void {
beginAtText.value = String(value);
beginPickerVisible.value = false;
}
function confirmEnd(value: string | number): void {
endAtText.value = String(value);
endPickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function validateForm(): boolean {
if (isGlobalManager.value && !form.storeId) {
Message.warning("请选择所属门店");
return false;
}
if (!form.name.trim()) {
Message.warning("请输入套餐名称");
return false;
}
if (parsePackageAmount(standardAmountText.value) === undefined) {
Message.warning("标准价必须是合法的元金额,最多两位小数");
return false;
}
if (!form.itemList.length) {
Message.warning("请至少添加一项产品权益");
return false;
}
const keySet = new Set<string>();
for (const item of form.itemList) {
const quantity = normalizePackageQuantity(item.defaultQuantity);
const key = `${item.productName.trim()}\u0000${item.unit.trim()}`;
if (!item.productName.trim() || !item.unit.trim() || !quantity || quantity === "0") {
Message.warning("产品名称、单位和正数量不能为空");
return false;
}
if (keySet.has(key)) {
Message.warning("产品名称和单位组合不能重复");
return false;
}
keySet.add(key);
}
form.beginAt = resolveTimestamp(beginAtText.value);
form.endAt = resolveTimestamp(endAtText.value);
if (form.beginAt !== undefined && form.endAt !== undefined && form.endAt < form.beginAt) {
Message.warning("结束时间不能早于开始时间");
return false;
}
return true;
}
async function save(): Promise<void> {
if (isSaving.value || !validateForm()) {
return;
}
isSaving.value = true;
try {
const payload: GaoPackageConfig = {
id: form.id || undefined,
storeId: form.storeId || undefined,
name: form.name.trim(),
description: form.description.trim(),
standardAmount: parsePackageAmount(standardAmountText.value)!,
status: form.status,
beginAt: form.beginAt ?? null,
endAt: form.endAt ?? null,
itemList: form.itemList.map((item, index) => ({
productName: item.productName.trim(),
unit: item.unit.trim(),
defaultQuantity: normalizePackageQuantity(item.defaultQuantity)!,
sort: index,
remark: item.remark?.trim() || null
}))
};
if (isCreateMode.value) {
const id = await PackageAPI.configCreate(payload);
Message.success("套餐配置已创建");
await router.replace(`/management/package-configs/${id}`);
return;
}
await PackageAPI.configUpdate(payload);
Message.success("套餐配置已保存");
await router.replace(`/management/package-configs/${routeId.value}`);
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置保存失败"));
} finally {
isSaving.value = false;
}
}
async function loadData(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
} else {
await currentStoreStore.loadLoginStore();
}
if (!form.storeId) {
form.storeId = currentStoreStore.currentStoreId;
}
if (isCreateMode.value) {
addItem();
return;
}
const result = await PackageAPI.configDetail(routeId.value);
Object.assign(form, {
id: result.id || routeId.value,
storeId: result.storeId || form.storeId,
name: result.name || "",
description: result.description || "",
status: result.status || "DRAFT",
beginAt: result.beginAt || undefined,
endAt: result.endAt || undefined,
itemList: (result.itemList || []).map(createItem)
});
standardAmountText.value = formatPackageAmountInput(result.standardAmount);
beginAtText.value = result.beginAt ? Time.toDateTime(result.beginAt) : "";
endAtText.value = result.endAt ? Time.toDateTime(result.endAt) : "";
if (!form.itemList.length) {
addItem();
}
}
onMounted(async () => {
if (userStore.hasPermissions(savePermissionCode.value)) {
navBarStore.setRightText("保存", "var(--td-brand-color)", save);
}
try {
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-config-edit {
padding: calc(var(--app-nav-height) + 1rem) 0 calc(1rem + env(safe-area-inset-bottom, 0));
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.items {
padding: 1rem;
}
.section-title,
.item-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
color: var(--td-text-color-secondary);
margin-bottom: .5rem;
font-size: .8125rem;
}
.item-card {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
display: grid;
background: var(--td-bg-color-container);
box-shadow: var(--td-shadow-1);
border-radius: .75rem;
}
.item-head {
color: var(--td-text-color-secondary);
}
.add-item {
border-radius: .75rem;
}
}
</style>
+233
View File
@@ -0,0 +1,233 @@
<template>
<div v-can="permissionCodes.packageConfig.read" class="package-config-list">
<div class="filters">
<t-input
v-model.trim="keyword"
class="keyword"
borderless
clearable
placeholder="搜索套餐名称或说明"
@enter="reload"
/>
<t-button theme="primary" size="small" @click="reload">搜索</t-button>
</div>
<t-dropdown-menu>
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
</t-dropdown-menu>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="configList.length" theme="card" class="list">
<t-swipe-cell v-for="(item, index) in configList" :key="item.id" v-entrance-transition="index * 30">
<t-cell class="item" :arrow="!!item.id" :hover="!!item.id" @click="toDetail(item)">
<template #title>
<div class="title-row">
<strong v-text="item.name || '未命名套餐'" />
<t-tag :content="getPackageConfigStatusText(item.status)" variant="light-outline" :theme="getStatusTheme(item.status)" />
</div>
</template>
<template #description>
<div class="description">
<span v-text="formatPackageAmount(item.standardAmount)" />
<span v-text="`${item.itemList?.length || 0} 项权益`" />
<span v-text="formatValidity(item)" />
</div>
</template>
</t-cell>
<template v-if="canDelete && item.id" #right>
<t-button class="delete" theme="danger" @click.stop="confirmRemove(item)">删除</t-button>
</template>
</t-swipe-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载套餐配置' : '暂无套餐配置'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
<nav-bar-action-menu v-if="canCreate" :actions="actionList" />
</template>
<script setup lang="ts">
import { DialogPlugin, type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import type { GaoPackageConfig, PackageConfigStatus } from "@/types/Package";
import type { NavBarAction } from "@/types/NavBarAction";
import NavBarActionMenu from "@/components/NavBarActionMenu.vue";
import { useNavBarStore } from "@/store/navBarStore";
import { getErrorMessage } from "@/utils/error";
import { formatPackageAmount, getPackageConfigStatusText } from "@/utils/package";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageConfigList" });
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const canCreate = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.create));
const canDelete = computed(() => userStore.hasPermissions(permissionCodes.packageConfig.delete));
const actionList = computed<NavBarAction[]>(() => canCreate.value ? [{
key: "create",
text: "新增套餐配置",
icon: "add",
theme: "primary",
action: toCreate
}] : []);
const keyword = ref("");
const status = ref("ALL");
const configList = ref<GaoPackageConfig[]>([]);
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "草稿", value: "DRAFT" },
{ label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.configList({
index: pageIndex.value,
size: 15,
keyword: keyword.value || undefined,
status: status.value === "ALL" ? undefined : status.value as PackageConfigStatus
});
const list = result.list || [];
configList.value = configList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= configList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
configList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
status.value = String(value);
await reload();
}
async function toCreate(): Promise<void> {
await router.push("/management/package-configs/create");
}
async function toDetail(item: GaoPackageConfig): Promise<void> {
if (item.id) {
await router.push(`/management/package-configs/${item.id}`);
}
}
function getStatusTheme(statusValue?: PackageConfigStatus): "success" | "warning" | "default" {
return statusValue === "ACTIVE" ? "success" : statusValue === "INACTIVE" ? "warning" : "default";
}
function formatValidity(item: GaoPackageConfig): string {
const begin = item.beginAt ? Time.toDateTime(item.beginAt) : "不限起始";
const end = item.endAt ? Time.toDateTime(item.endAt) : "不限结束";
return `${begin} 至 ${end}`;
}
function confirmRemove(item: GaoPackageConfig): void {
if (!item.id || !canDelete.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "删除套餐配置",
content: `确定删除“${item.name || "未命名套餐"}”?删除后不可再用于购买。`,
confirmBtn: { content: "删除", theme: "danger" },
onConfirm: async () => {
try {
await PackageAPI.configRemove(item.id!);
configList.value = configList.value.filter(config => config.id !== item.id);
total.value = Math.max(0, total.value - 1);
Message.success("套餐配置已删除");
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置删除失败"));
} finally {
dialog.destroy();
}
}
});
}
onMounted(async () => {
navBarStore.clearRight();
navBarStore.setCustomRightVisible(canCreate.value);
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "套餐配置加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-config-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.filters {
gap: .5rem;
margin: 0 1rem .75rem;
display: flex;
.keyword {
flex: 1;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
}
}
.item {
.title-row,
.description {
gap: .5rem;
display: flex;
align-items: center;
}
.description {
color: var(--td-text-color-secondary);
margin-top: .35rem;
font-size: .8125rem;
flex-wrap: wrap;
}
.delete {
height: 100%;
padding: 0 1rem;
border-radius: 0;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
+203
View File
@@ -0,0 +1,203 @@
<template>
<div v-can="permissionCodes.packageDelivery.read" class="package-record-detail">
<template v-if="delivery">
<t-cell-group title="发放单信息" theme="card" v-entrance-transition>
<t-cell title="操作类型" :note="getDeliveryOperationText(delivery.operationType)" />
<t-cell title="状态"><template #note><t-tag :content="getDeliveryStatusText(delivery.status)" :theme="getStatusTheme(delivery.status)" variant="light-outline" /></template></t-cell>
<t-cell title="客户套餐" :note="delivery.customerPackageId || '未知'" />
<t-cell title="发放时间" :note="delivery.deliveredAt ? Time.toDateTime(delivery.deliveredAt) : '未知'" />
<t-cell title="提交人" :note="delivery.operator?.name || delivery.operator?.nick || delivery.operatorUserId || '未知'" />
<t-cell title="处理人" :note="delivery.verifier?.name || delivery.verifier?.nick || delivery.verifierUserId || '未处理'" />
<t-cell v-if="delivery.sourceDeliveryId" title="原发放单" :note="delivery.sourceDeliveryId" />
<t-cell v-if="delivery.reason" title="原因" :note="delivery.reason" />
</t-cell-group>
<t-cell-group title="本单明细" theme="card" v-entrance-transition="70">
<t-cell
v-for="item in delivery.itemList || []"
:key="item.customerPackageItemId || item.productNameSnapshot"
:title="item.productNameSnapshot || '产品'"
:note="`${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || ''}`"
/>
<div v-if="!delivery.itemList?.length" class="empty" v-text="'暂无明细'" />
</t-cell-group>
<photo-scroller :model-value="photoURLList" :photo-items="photoItems" title="业务照片" :show-add="false" :show-remove="false" :show-viewer-actions="false" />
<section class="actions" v-if="canVerify || canCancel || canRevoke" v-entrance-transition="140">
<t-button v-if="canVerify && isPending" theme="primary" @click="verify">核销发放</t-button>
<t-button v-if="canVerify && isPending" theme="default" variant="outline" @click="openReasonDialog('reject')">拒绝发放</t-button>
<t-button v-if="canCancel && isPending" theme="default" variant="outline" @click="openReasonDialog('cancel')">撤销发放</t-button>
<t-button v-if="canRevoke && isVerifiedDelivery" theme="danger" variant="outline" @click="toRevoke">整单冲正</t-button>
</section>
</template>
<t-empty v-else :description="isLoading ? '正在加载发放单' : '发放单不存在'" />
<t-dialog v-model:visible="reasonDialogVisible" :title="reasonTitle" :confirm-btn="reasonActionText" @confirm="submitReason">
<t-textarea v-model.trim="reasonText" borderless autosize placeholder="请输入原因" />
</t-dialog>
</div>
</template>
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { DeliveryStatus, GaoPackageDelivery } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { getAttachmentReadURL } from "@/utils/attachment";
import { getDeliveryOperationText, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
type ReasonAction = "reject" | "cancel";
defineOptions({ name: "PackageDeliveryDetail" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const delivery = ref<GaoPackageDelivery>();
const isLoading = ref(false);
const isOperating = ref(false);
const reasonDialogVisible = ref(false);
const reasonText = ref("");
const reasonAction = ref<ReasonAction>("reject");
const isPending = computed(() => delivery.value?.operationType === "DELIVER" && delivery.value.status === "PENDING");
const isVerifiedDelivery = computed(() => delivery.value?.operationType === "DELIVER" && delivery.value.status === "VERIFIED");
const canVerify = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.verify));
const canCancel = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.create));
const canRevoke = computed(() => userStore.hasPermissions(permissionCodes.packageDelivery.revoke));
const reasonTitle = computed(() => reasonAction.value === "reject" ? "拒绝发放" : "撤销发放");
const reasonActionText = computed(() => reasonAction.value === "reject" ? "拒绝" : "撤销");
const photoItems = computed<PhotoItem[]>(() => (delivery.value?.attachmentList || [])
.filter(item => !!item.id)
.map(item => ({
attachment: item,
attachType: item.attachType || "PHOTO",
url: getAttachmentReadURL(item.id || "")
})));
const photoURLList = computed(() => photoItems.value.map(item => item.url));
function getStatusTheme(status?: DeliveryStatus): "success" | "danger" | "default" {
return status === "VERIFIED" ? "success" : status === "REJECTED" ? "danger" : "default";
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
delivery.value = await PackageAPI.deliveryDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "发放单加载失败"));
} finally {
isLoading.value = false;
}
}
async function refreshRelatedPackage(): Promise<void> {
if (delivery.value?.customerPackageId) {
await PackageAPI.customerPackageDetail(delivery.value.customerPackageId);
}
}
async function verify(): Promise<void> {
if (!delivery.value?.id || !isPending.value || !canVerify.value || isOperating.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "核销发放",
content: "核销后会增加已发数量,所有权益完成核销后套餐可能变为已履约。确定继续?",
confirmBtn: "核销",
onConfirm: async () => {
isOperating.value = true;
try {
await PackageAPI.verifyDelivery(delivery.value!.id!);
await refreshRelatedPackage();
Message.success("发放单已核销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "发放核销失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
dialog.destroy();
}
}
});
}
function openReasonDialog(action: ReasonAction): void {
if (isOperating.value || !isPending.value) {
return;
}
reasonAction.value = action;
reasonText.value = "";
reasonDialogVisible.value = true;
}
async function submitReason(): Promise<void> {
if (!delivery.value?.id || !reasonText.value.trim() || isOperating.value) {
Message.warning("请输入原因");
return;
}
isOperating.value = true;
try {
if (reasonAction.value === "reject") {
await PackageAPI.rejectDelivery(delivery.value.id, reasonText.value.trim());
} else {
await PackageAPI.cancelDelivery(delivery.value.id, reasonText.value.trim());
}
await refreshRelatedPackage();
reasonDialogVisible.value = false;
Message.success(reasonAction.value === "reject" ? "发放单已拒绝" : "发放单已撤销");
await loadData();
} catch (error) {
Message.error(getErrorMessage(error, "发放状态操作失败,请刷新后确认状态"));
} finally {
isOperating.value = false;
}
}
async function toRevoke(): Promise<void> {
if (delivery.value?.id && isVerifiedDelivery.value && canRevoke.value) {
await router.push(`/management/package-deliveries/${delivery.value.id}/revoke`);
}
}
onMounted(loadData);
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-detail {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
:deep(.photo-scroller) {
padding: 0 1rem;
}
.actions {
gap: .5rem;
padding: 0 1rem;
display: flex;
flex-wrap: wrap;
.t-button {
flex: 1 1 8rem;
}
}
.empty {
color: var(--td-text-color-secondary);
padding: 1rem;
text-align: center;
}
}
</style>
+157
View File
@@ -0,0 +1,157 @@
<template>
<div v-can="permissionCodes.packageDelivery.read" class="package-record-list">
<div class="filters">
<t-dropdown-menu>
<t-dropdown-item v-model="status" label="状态" :options="statusOptions" @change="selectStatus" />
<t-dropdown-item v-model="operationType" label="操作类型" :options="operationOptions" @change="selectOperation" />
</t-dropdown-menu>
</div>
<t-pull-down-refresh v-model="isRefreshing" :refresh-timeout="30000" @refresh="reload" @scrolltolower="loadMore">
<t-cell-group v-if="deliveryList.length" theme="card">
<t-cell
v-for="(item, index) in deliveryList"
:key="item.id"
:title="`${getDeliveryOperationText(item.operationType)} · ${getDeliveryStatusText(item.status)}`"
:description="getDescription(item)"
arrow
hover
v-entrance-transition="index * 30"
@click="toDetail(item)"
>
<template #note><span v-text="getItemSummary(item)" /></template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="loading ? '正在加载发放记录' : '暂无发放记录'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
</template>
<script setup lang="ts">
import { type DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import PackageAPI from "@/api/PackageAPI";
import { useNavBarStore } from "@/store/navBarStore";
import type { DeliveryOperationType, DeliveryStatus, GaoPackageDelivery } from "@/types/Package";
import { getDeliveryOperationText, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageDeliveryList" });
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const deliveryList = ref<GaoPackageDelivery[]>([]);
const pageIndex = ref(0);
const total = ref(0);
const loading = ref(false);
const isRefreshing = ref(false);
const noMore = ref(false);
const status = ref("ALL");
const operationType = ref("ALL");
const statusOptions = [
{ label: "全部状态", value: "ALL" },
{ label: "待核销", value: "PENDING" },
{ label: "已核销", value: "VERIFIED" },
{ label: "已拒绝", value: "REJECTED" },
{ label: "已撤销", value: "CANCELLED" },
{ label: "已冲正", value: "REVOKED" }
];
const operationOptions = [
{ label: "全部类型", value: "ALL" },
{ label: "正常发放", value: "DELIVER" },
{ label: "发放冲正", value: "REVOKE" }
];
const loadStateText = computed(() => loading.value ? "正在加载" : noMore.value ? "已经到底了" : "上拉加载更多");
async function loadMore(): Promise<void> {
if (loading.value || noMore.value) {
return;
}
loading.value = true;
try {
const result = await PackageAPI.deliveryList({
index: pageIndex.value,
size: 15,
customerPackageId: String(route.query.customerPackageId || "") || undefined,
status: status.value === "ALL" ? undefined : status.value as DeliveryStatus,
operationType: operationType.value === "ALL" ? undefined : operationType.value as DeliveryOperationType
});
const list = result.list || [];
deliveryList.value = deliveryList.value.concat(list);
total.value = result.total || 0;
pageIndex.value += 1;
noMore.value = list.length < 15 || total.value <= deliveryList.value.length;
} finally {
loading.value = false;
}
}
async function reload(): Promise<void> {
if (loading.value) {
return;
}
pageIndex.value = 0;
deliveryList.value = [];
total.value = 0;
noMore.value = false;
await loadMore();
}
async function selectStatus(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
status.value = String(value);
await reload();
}
}
async function selectOperation(value: DropdownValue): Promise<void> {
if (!Array.isArray(value)) {
operationType.value = String(value);
await reload();
}
}
function getDescription(item: GaoPackageDelivery): string {
return `${item.customerPackageId || "未知客户套餐"}|${item.deliveredAt ? Time.toDateTime(item.deliveredAt) : "时间未知"}`;
}
function getItemSummary(item: GaoPackageDelivery): string {
return (item.itemList || []).map(detail => `${detail.productNameSnapshot || "产品"} ${getPackageQuantityText(detail.quantity)} ${detail.unitSnapshot || ""}`).join("、") || "无明细";
}
async function toDetail(item: GaoPackageDelivery): Promise<void> {
if (item.id) {
await router.push(`/management/package-deliveries/${item.id}`);
}
}
onMounted(async () => {
navBarStore.clearRight();
try {
await reload();
} catch (error) {
Message.error(getErrorMessage(error, "发放记录加载失败"));
}
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-record-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
.filters {
margin-bottom: .75rem;
}
.empty {
color: var(--td-text-color-secondary);
padding: 2rem 1rem;
text-align: center;
}
}
</style>
+152
View File
@@ -0,0 +1,152 @@
<template>
<div v-can="permissionCodes.packageDelivery.revoke" class="package-operation-edit">
<t-cell-group v-if="delivery" title="原发放单" theme="card" v-entrance-transition>
<t-cell title="发放单号" :note="delivery.id" />
<t-cell title="客户套餐" :note="delivery.customerPackageId || '未知'" />
<t-cell title="原状态" :note="getDeliveryStatusText(delivery.status)" />
<t-cell title="原明细" :note="getItemSummary" />
</t-cell-group>
<t-form v-if="delivery" class="form" label-align="left" label-width="6rem" v-entrance-transition="70">
<t-form-item label="冲正原因" required-mark><t-textarea v-model.trim="reason" borderless autosize maxlength="500" placeholder="请输入冲正原因" /></t-form-item>
<t-form-item label="冲正时间" arrow @click="pickerVisible = true"><span class="picker-text" v-text="occurredAtText || '使用当前时间'" /></t-form-item>
<t-form-item label="备注"><t-textarea v-model.trim="remark" borderless autosize maxlength="500" placeholder="备注(可选)" /></t-form-item>
</t-form>
<package-photo-field v-if="delivery" v-model="photoItems" required title="冲正凭证照片" />
<t-empty v-if="!delivery" :description="isLoading ? '正在加载发放单' : '发放单不存在'" />
<t-popup v-model="pickerVisible" placement="bottom">
<t-date-time-picker v-model="draftTime" format="YYYY-MM-DD HH:mm" :mode="['date', 'minute']" title="冲正时间" @cancel="pickerVisible = false" @confirm="confirmTime" />
</t-popup>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "timi-web-api";
import PackageAPI from "@/api/PackageAPI";
import PackagePhotoField from "@/components/PackagePhotoField.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { GaoPackageDelivery, PackageAttachment } from "@/types/Package";
import type { PhotoItem } from "@/types/Photo";
import { createPackageRequestNo, getDeliveryStatusText, getPackageQuantityText } from "@/utils/package";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "PackageDeliveryRevoke" });
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const navBarStore = useNavBarStore();
const delivery = ref<GaoPackageDelivery>();
const reason = ref("");
const remark = ref("");
const occurredAtText = ref("");
const draftTime = ref("");
const pickerVisible = ref(false);
const photoItems = ref<PhotoItem[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const getItemSummary = computed(() => (delivery.value?.itemList || []).map(item => `${item.productNameSnapshot || "产品"} ${getPackageQuantityText(item.quantity)} ${item.unitSnapshot || ""}`).join("、") || "无明细");
function confirmTime(value: string | number): void {
occurredAtText.value = String(value);
pickerVisible.value = false;
}
function resolveTimestamp(value: string): number | undefined {
if (!value) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildAttachments(): PackageAttachment[] {
return photoItems.value.flatMap(item => item.tempFileId ? [{ tempFileId: item.tempFileId, attachType: "PHOTO" }] : []);
}
async function loadData(): Promise<void> {
const id = String(route.params.id || "");
if (!id) {
return;
}
isLoading.value = true;
try {
delivery.value = await PackageAPI.deliveryDetail(id);
} catch (error) {
Message.error(getErrorMessage(error, "发放单加载失败"));
} finally {
isLoading.value = false;
}
}
async function save(): Promise<void> {
if (isSaving.value) {
return;
}
if (!delivery.value?.id || delivery.value.status !== "VERIFIED" || delivery.value.operationType !== "DELIVER") {
Message.warning("只有已核销的正常发放单可以冲正");
return;
}
if (!reason.value.trim()) {
Message.warning("请输入冲正原因");
return;
}
if (!photoItems.value.some(item => item.tempFileId)) {
Message.warning("冲正必须上传至少一张照片");
return;
}
isSaving.value = true;
try {
const result = await PackageAPI.revokeDelivery({
requestNo: createPackageRequestNo(),
deliveryId: delivery.value.id,
deliveredAt: resolveTimestamp(occurredAtText.value),
reason: reason.value.trim(),
attachmentList: buildAttachments(),
remark: remark.value.trim() || null
});
Message.success("发放单已冲正");
if (result.customerPackageId) {
await router.replace(`/management/customer-packages/${result.customerPackageId}`);
return;
}
await router.replace(`/management/package-deliveries/${delivery.value.id}`);
} catch (error) {
Message.error(getErrorMessage(error, "发放冲正失败,请刷新后确认状态"));
} finally {
isSaving.value = false;
}
}
onMounted(async () => {
if (userStore.hasPermissions(permissionCodes.packageDelivery.revoke)) {
navBarStore.setRightText("提交冲正", "var(--td-danger-color)", save);
}
await loadData();
});
onUnmounted(() => navBarStore.clearRight());
</script>
<style scoped lang="less">
.package-operation-edit {
gap: 1rem;
padding: calc(var(--app-nav-height) + 1rem) 0 2rem;
display: flex;
flex-direction: column;
.form {
border-radius: .75rem;
}
.picker-text {
width: 100%;
text-align: right;
}
.package-photo-field {
padding: 0 1rem;
}
}
</style>
+29
View File
@@ -196,6 +196,35 @@ const menuGroupList = computed<MenuGroup[]>(() => [
}
]
},
{
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: [
+2
View File
@@ -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
+51
View File
@@ -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;
+5
View File
@@ -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<string, Record<string, string[]>>;
export type EventItem = {
storeId?: string;
bizType?: EventBizType;
packageConfigId?: string;
packageConfig?: GaoPackageConfig;
name: string;
description?: string;
color?: string;
+212
View File
@@ -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<User>;
} & 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<Attachment> & {
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<User>;
verifierUserId?: string;
verifier?: Partial<User>;
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<User>;
verifierUserId?: string;
verifier?: Partial<User>;
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<T> = PageResult<T> & {
pages?: number;
};
+11
View File
@@ -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<EventItem, "bizType">): boolean {
return event?.bizType === "PACKAGE_PURCHASE";
}
+130
View File
@@ -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<string, string>)[status || ""] || "未知";
}
export function getCustomerPackageStatusText(status?: CustomerPackageStatus): string {
return ({ ACTIVE: "进行中", FULFILLED: "已履约", CANCELLED: "已取消" } as Record<string, string>)[status || ""] || "未知";
}
export function getPaymentStatusText(status?: PaymentStatus): string {
return ({ UNPAID: "未收款", PARTIALLY_PAID: "部分收款", PAID: "已收款" } as Record<string, string>)[status || ""] || "未知";
}
export function getRefundStatusText(status?: RefundStatus): string {
return ({ NOT_REQUIRED: "无需退款", PENDING: "待退款", REFUNDED: "已退款" } as Record<string, string>)[status || ""] || "未知";
}
export function getDeliveryStatusText(status?: DeliveryStatus): string {
return ({ PENDING: "待核销", VERIFIED: "已核销", REJECTED: "已拒绝", CANCELLED: "已撤销", REVOKED: "已冲正" } as Record<string, string>)[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<string, string>)[type || ""] || "未知";
}
export function getFundStatusText(status?: FundRecordStatus): string {
return ({ PENDING: "待核销", VERIFIED: "已核销", REJECTED: "已拒绝", CANCELLED: "已撤销" } as Record<string, string>)[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
};
}
+46
View File
@@ -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,