add package
This commit is contained in:
@@ -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));
|
||||
},
|
||||
|
||||
Generated
+5
-26
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 右侧操作 ----------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user