diff --git a/src/assets/styles/global.less b/src/assets/styles/global.less
index 4c5d5dd..5150856 100644
--- a/src/assets/styles/global.less
+++ b/src/assets/styles/global.less
@@ -84,7 +84,7 @@ html.theme-dark body {
gap: 1rem;
}
-.load-state {
+.center-tips {
color: var(--tui-gray);
padding: .75rem;
text-align: center;
diff --git a/src/components/CustomerSelector.vue b/src/components/CustomerSelector.vue
index 5b2c5ff..ace3375 100644
--- a/src/components/CustomerSelector.vue
+++ b/src/components/CustomerSelector.vue
@@ -261,7 +261,7 @@ onUnmounted(() => {
background: rgba(15, 23, 42, .96);
}
- .load-state {
+ .center-tips {
color: #94a3b8;
}
}
diff --git a/src/components/EventSelector.vue b/src/components/EventSelector.vue
index 3380a08..07b8e86 100644
--- a/src/components/EventSelector.vue
+++ b/src/components/EventSelector.vue
@@ -2,26 +2,35 @@
暂无可用登记事件
@@ -35,12 +44,16 @@ interface Props {
label?: string;
requiredMark?: boolean;
disabled?: boolean;
+ selectionMode?: SelectionMode;
}
+type SelectionMode = "single" | "multiple";
+
const props = withDefaults(defineProps(), {
label: "登记事件",
requiredMark: false,
- disabled: false
+ disabled: false,
+ selectionMode: "multiple"
});
const modelValue = defineModel({ required: true });
@@ -63,6 +76,10 @@ function toggleEvent(item: EventItem): void {
if (props.disabled || !eventId) {
return;
}
+ if (props.selectionMode === "single") {
+ modelValue.value = [eventId];
+ return;
+ }
if (modelValue.value.includes(eventId)) {
modelValue.value = modelValue.value.filter(id => id !== eventId);
return;
@@ -94,6 +111,26 @@ function toggleEvent(item: EventItem): void {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
+ .item-wrapper {
+ width: 100%;
+ min-width: 0;
+ position: relative;
+
+ .package-badge {
+ top: -.375rem;
+ right: -.375rem;
+ z-index: 1;
+ padding: .125rem .375rem;
+ position: absolute;
+ max-width: 50%;
+ font-size: .6875rem;
+ line-height: 1.25;
+ background: var(--td-error-color);
+ border-radius: .35rem;
+ pointer-events: none;
+ }
+ }
+
.item {
gap: .5rem;
width: 100%;
diff --git a/src/pages/customer/CustomerList.vue b/src/pages/customer/CustomerList.vue
index 9b07c16..a91fc7e 100644
--- a/src/pages/customer/CustomerList.vue
+++ b/src/pages/customer/CustomerList.vue
@@ -66,8 +66,8 @@
-
-
+
+
{
.items {
- .load-state,
+ .center-tips,
.item,
.empty-block {
color: #e2e8f0;
diff --git a/src/pages/event/EventList.vue b/src/pages/event/EventList.vue
index ea5cc84..b76de86 100644
--- a/src/pages/event/EventList.vue
+++ b/src/pages/event/EventList.vue
@@ -69,7 +69,7 @@
-
+
diff --git a/src/pages/event/EventRecordEdit.vue b/src/pages/event/EventRecordEdit.vue
index 81e1996..4af42af 100644
--- a/src/pages/event/EventRecordEdit.vue
+++ b/src/pages/event/EventRecordEdit.vue
@@ -50,17 +50,19 @@
class="event-selector"
:event-list="eventList"
:disabled="isEditMode"
+ :selection-mode="isPackagePurchaseSelected ? 'single' : 'multiple'"
required-mark
@update:model-value="handleEventSelectionChange"
/>
-
+
+
@@ -111,7 +120,7 @@
-
+
@@ -201,7 +210,7 @@
+
+
diff --git a/src/pages/event/component/useEventRecordPhoto.ts b/src/pages/event/component/useEventRecordPhoto.ts
new file mode 100644
index 0000000..f95883e
--- /dev/null
+++ b/src/pages/event/component/useEventRecordPhoto.ts
@@ -0,0 +1,92 @@
+import { computed, ref } from "vue";
+import { AttachmentAPI, type Attachment } from "timi-web-api";
+import type { PhotoItem } from "@/types/Photo";
+import { getAttachmentReadURL } from "@/utils/attachment";
+import { compressUploadImageList } from "@/utils/imageCompression";
+
+export function useEventRecordPhoto() {
+ const recordPhotoInputRef = ref();
+ const photoItemList = ref([]);
+ const isPhotoUploading = ref(false);
+ const photoList = computed({
+ get() {
+ return photoItemList.value.map(item => item.url);
+ },
+ set(value) {
+ const urlSet = new Set(value);
+ photoItemList.value = photoItemList.value.filter(item => urlSet.has(item.url));
+ }
+ });
+
+ function capturePhoto(): void {
+ recordPhotoInputRef.value?.click();
+ }
+
+ async function handleRecordPhotoSelected(event: Event): Promise {
+ const input = event.target as HTMLInputElement;
+ const fileList = Array.from(input.files || []);
+ if (!fileList.length) {
+ return;
+ }
+ isPhotoUploading.value = true;
+ try {
+ const compressedFileList = await compressUploadImageList(fileList);
+ const tempFileList = await AttachmentAPI.upload(compressedFileList);
+ for (const item of tempFileList) {
+ if (!item.id) {
+ continue;
+ }
+ photoItemList.value.push({
+ tempFileId: item.id,
+ url: AttachmentAPI.getTempReadURL(item.id)
+ });
+ }
+ } finally {
+ isPhotoUploading.value = false;
+ input.value = "";
+ }
+ }
+
+ function setExistingPhotoList(attachmentList?: Attachment[]): void {
+ photoItemList.value = (attachmentList || [])
+ .filter(item => item.attachType === "PHOTO" || !item.attachType)
+ .filter(item => !!item.id)
+ .map(item => ({
+ attachment: item,
+ previewUrl: getAttachmentReadURL(item.id || ""),
+ url: getAttachmentReadURL(item.id || "", 90)
+ }));
+ }
+
+ function buildTempFileIdList(): string[] {
+ return photoItemList.value.map(item => item.tempFileId).filter((item): item is string => !!item);
+ }
+
+ function buildPhotoAttachmentList(): Array> {
+ return photoItemList.value.map((item) => {
+ if (item.attachment) {
+ return {
+ ...item.attachment,
+ attachType: item.attachType || item.attachment.attachType || "PHOTO"
+ };
+ }
+
+ return {
+ tempFileId: item.tempFileId,
+ attachType: item.attachType || "PHOTO"
+ };
+ });
+ }
+
+ return {
+ recordPhotoInputRef,
+ photoItemList,
+ isPhotoUploading,
+ photoList,
+ capturePhoto,
+ handleRecordPhotoSelected,
+ setExistingPhotoList,
+ buildTempFileIdList,
+ buildPhotoAttachmentList
+ };
+}
diff --git a/src/pages/event/component/usePackagePurchaseRecord.ts b/src/pages/event/component/usePackagePurchaseRecord.ts
new file mode 100644
index 0000000..a727b9f
--- /dev/null
+++ b/src/pages/event/component/usePackagePurchaseRecord.ts
@@ -0,0 +1,165 @@
+import { computed, ref, type Ref } from "vue";
+import PackageAPI from "@/api/PackageAPI";
+import type { Customer } from "@/types/Customer";
+import type { EventItem } from "@/types/EventItem";
+import type { PackagePurchaseItem } from "@/types/Package";
+import { isPackagePurchaseEvent } from "@/utils/event";
+import { createPackageRequestNo, formatPackageAmountInput, normalizePackageQuantity, parsePackageAmount } from "@/utils/package";
+import { permissionCodes } from "@/utils/permission";
+import { Message } from "@/utils/message";
+
+interface UsePackagePurchaseRecordOptions {
+ eventList: Ref;
+ selectedEventIdList: Ref;
+ selectedCustomerIdList: Ref;
+ selectedCustomerList: Ref;
+ note: Ref;
+ hasPermission: (permissionCode: string) => boolean;
+ buildRegisteredAt: () => number;
+ buildTempFileIdList: () => string[];
+}
+
+export function usePackagePurchaseRecord(options: UsePackagePurchaseRecordOptions) {
+ const selectedPackageEvent = computed(() => options.selectedEventIdList.value
+ .map(eventId => options.eventList.value.find(item => item.id === eventId))
+ .find((item): item is EventItem => !!item && isPackagePurchaseEvent(item)));
+ const isPackagePurchaseSelected = computed(() => !!selectedPackageEvent.value);
+ const selectedPackageConfig = computed(() => selectedPackageEvent.value?.packageConfig);
+ const isSelectedPackageConfigSaleable = computed(() => {
+ const config = selectedPackageConfig.value;
+ if (!config || config.status !== "ACTIVE") {
+ return false;
+ }
+ const now = Date.now();
+ return (config.beginAt === undefined || config.beginAt === null || config.beginAt <= now)
+ && (config.endAt === undefined || config.endAt === null || now <= config.endAt);
+ });
+ const canPriceOverride = computed(() => options.hasPermission(permissionCodes.customerPackage.priceOverride));
+ const saleAmountText = ref("");
+ const packagePurchaseItemList = ref([]);
+
+ function handleEventSelectionChange(eventIdList: string[]): void {
+ const packageEvent = eventIdList
+ .map(eventId => options.eventList.value.find(item => item.id === eventId))
+ .find(item => isPackagePurchaseEvent(item));
+ if (packageEvent?.id) {
+ if (!options.hasPermission(permissionCodes.customerPackage.create)) {
+ Message.warning("当前账号没有套餐购买权限");
+ return;
+ }
+ options.selectedEventIdList.value = [packageEvent.id];
+ keepSingleCustomer();
+ applySelectedPackageEvent(packageEvent);
+ return;
+ }
+ options.selectedEventIdList.value = eventIdList;
+ resetPackagePurchaseForm();
+ }
+
+ function applySelectedPackageEvent(event: EventItem): void {
+ const config = event.packageConfig;
+ if (!config) {
+ resetPackagePurchaseForm();
+ return;
+ }
+
+ saleAmountText.value = formatPackageAmountInput(config.standardAmount);
+ packagePurchaseItemList.value = (config.itemList || []).map((item, index) => ({
+ productName: item.productName,
+ unit: item.unit,
+ totalQuantity: normalizePackageQuantity(item.defaultQuantity) || "",
+ sort: item.sort ?? index,
+ remark: item.remark ?? null
+ }));
+ }
+
+ function resetPackagePurchaseForm(): void {
+ saleAmountText.value = "";
+ packagePurchaseItemList.value = [];
+ }
+
+ async function submitPackagePurchase(): Promise {
+ const eventId = selectedPackageEvent.value?.id;
+ const customerId = options.selectedCustomerList.value[0]?.id;
+ const saleAmount = parsePackageAmount(saleAmountText.value);
+ if (!eventId || !customerId || saleAmount === undefined) {
+ return false;
+ }
+
+ await PackageAPI.purchase({
+ requestNo: createPackageRequestNo(),
+ eventId,
+ customerId,
+ saleAmount,
+ purchasedAt: options.buildRegisteredAt(),
+ itemList: packagePurchaseItemList.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: options.buildTempFileIdList().map(tempFileId => ({
+ tempFileId,
+ attachType: "PHOTO"
+ })),
+ remark: options.note.value.trim() || null
+ });
+ return true;
+ }
+
+ function validatePackagePurchaseForm(): boolean {
+ if (!options.hasPermission(permissionCodes.customerPackage.create)) {
+ Message.warning("当前账号没有套餐购买权限");
+ return false;
+ }
+ if (options.selectedCustomerList.value.length < 1 || 1 < options.selectedCustomerList.value.length || !options.selectedCustomerList.value[0]?.id) {
+ Message.warning("套餐购买只能选择一个客户");
+ return false;
+ }
+ if (!selectedPackageConfig.value) {
+ Message.warning("当前购买事件未绑定有效套餐配置");
+ return false;
+ }
+ if (!isSelectedPackageConfigSaleable.value) {
+ Message.warning("事件绑定的套餐当前不可购买,请检查套餐状态和有效期");
+ return false;
+ }
+ if (parsePackageAmount(saleAmountText.value) === undefined) {
+ Message.warning("成交价必须是合法的元金额,最多两位小数");
+ return false;
+ }
+ if (!packagePurchaseItemList.value.length) {
+ Message.warning("购买权益至少需要一项");
+ return false;
+ }
+ for (const item of packagePurchaseItemList.value) {
+ const quantity = normalizePackageQuantity(item.totalQuantity);
+ if (!item.productName.trim() || !item.unit.trim() || !quantity || quantity === "0") {
+ Message.warning("产品名称、单位和正数量不能为空");
+ return false;
+ }
+ }
+ return true;
+ }
+
+ function keepSingleCustomer(): void {
+ const customer = options.selectedCustomerList.value[0];
+ options.selectedCustomerList.value = customer ? [customer] : [];
+ options.selectedCustomerIdList.value = customer?.id ? [customer.id] : [];
+ }
+
+ return {
+ selectedPackageEvent,
+ isPackagePurchaseSelected,
+ selectedPackageConfig,
+ canPriceOverride,
+ saleAmountText,
+ packagePurchaseItemList,
+ handleEventSelectionChange,
+ applySelectedPackageEvent,
+ resetPackagePurchaseForm,
+ submitPackagePurchase,
+ validatePackagePurchaseForm
+ };
+}
diff --git a/src/pages/notify/NotifyList.vue b/src/pages/notify/NotifyList.vue
index b9d29d3..f0315d7 100644
--- a/src/pages/notify/NotifyList.vue
+++ b/src/pages/notify/NotifyList.vue
@@ -53,7 +53,7 @@
-
+
diff --git a/src/pages/notify/UserNotifyList.vue b/src/pages/notify/UserNotifyList.vue
index 7102962..19fa76e 100644
--- a/src/pages/notify/UserNotifyList.vue
+++ b/src/pages/notify/UserNotifyList.vue
@@ -60,7 +60,7 @@
v-else-if="!isLoading"
description="暂无站内通知"
/>
-
+
diff --git a/src/pages/package/CustomerPackageDelivery.vue b/src/pages/package/CustomerPackageDelivery.vue
index 767c763..5eae656 100644
--- a/src/pages/package/CustomerPackageDelivery.vue
+++ b/src/pages/package/CustomerPackageDelivery.vue
@@ -6,7 +6,7 @@
本次发放权益只能提交不超过可发数量的产品
-
+
@@ -202,7 +202,7 @@ onUnmounted(() => navBarStore.clearRight());
margin-left: .5rem;
}
- .item-card {
+ .item {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
diff --git a/src/pages/package/CustomerPackageList.vue b/src/pages/package/CustomerPackageList.vue
index c8ac0a9..57daf86 100644
--- a/src/pages/package/CustomerPackageList.vue
+++ b/src/pages/package/CustomerPackageList.vue
@@ -31,21 +31,17 @@
-
+
-
-
-
diff --git a/src/pages/package/FundRecordList.vue b/src/pages/package/FundRecordList.vue
index aa56c66..6aa47e5 100644
--- a/src/pages/package/FundRecordList.vue
+++ b/src/pages/package/FundRecordList.vue
@@ -26,7 +26,7 @@
-
+
diff --git a/src/pages/package/PackageConfigEdit.vue b/src/pages/package/PackageConfigEdit.vue
index ecb2ee6..1458da3 100644
--- a/src/pages/package/PackageConfigEdit.vue
+++ b/src/pages/package/PackageConfigEdit.vue
@@ -25,7 +25,7 @@
产品权益至少一项,数量最多三位小数
-
+
删除
@@ -337,7 +337,7 @@ onUnmounted(() => navBarStore.clearRight());
font-size: .8125rem;
}
- .item-card {
+ .item {
gap: .5rem;
padding: .75rem;
margin-bottom: .75rem;
diff --git a/src/pages/package/PackageConfigList.vue b/src/pages/package/PackageConfigList.vue
index b74eb97..03f14d8 100644
--- a/src/pages/package/PackageConfigList.vue
+++ b/src/pages/package/PackageConfigList.vue
@@ -38,7 +38,7 @@
-
+
diff --git a/src/pages/package/PackageDeliveryList.vue b/src/pages/package/PackageDeliveryList.vue
index fda1aae..596f171 100644
--- a/src/pages/package/PackageDeliveryList.vue
+++ b/src/pages/package/PackageDeliveryList.vue
@@ -22,7 +22,7 @@
-
+
diff --git a/src/pages/point/PointAccountList.vue b/src/pages/point/PointAccountList.vue
index 062be9d..4f4c3b7 100644
--- a/src/pages/point/PointAccountList.vue
+++ b/src/pages/point/PointAccountList.vue
@@ -35,7 +35,7 @@
-
+
@@ -249,7 +249,7 @@ onMounted(async () => {
}
}
- .load-state {
+ .center-tips {
color: #52607a;
padding: 1rem;
font-size: .8125rem;
@@ -261,7 +261,7 @@ onMounted(async () => {
.point-account-list {
.description,
.zero,
- .load-state {
+ .center-tips {
color: #94a3b8;
}
diff --git a/src/pages/point/PointLedgerList.vue b/src/pages/point/PointLedgerList.vue
index 6c44fee..415a847 100644
--- a/src/pages/point/PointLedgerList.vue
+++ b/src/pages/point/PointLedgerList.vue
@@ -113,7 +113,7 @@
-
+
diff --git a/src/pages/point/PointRuleList.vue b/src/pages/point/PointRuleList.vue
index 61daf6b..44c7cb9 100644
--- a/src/pages/point/PointRuleList.vue
+++ b/src/pages/point/PointRuleList.vue
@@ -51,7 +51,7 @@
-
+
diff --git a/src/pages/store/StoreDiaryList.vue b/src/pages/store/StoreDiaryList.vue
index 5f5ff70..0244fb8 100644
--- a/src/pages/store/StoreDiaryList.vue
+++ b/src/pages/store/StoreDiaryList.vue
@@ -40,7 +40,7 @@
-
+
@@ -462,7 +462,7 @@ onUnmounted(() => navBarStore.clearRight());
}
}
- .load-state {
+ .center-tips {
padding: 1rem;
color: var(--td-text-color-placeholder);
font-size: .8125rem;
diff --git a/src/pages/store/StoreList.vue b/src/pages/store/StoreList.vue
index f011c4f..9a9c492 100644
--- a/src/pages/store/StoreList.vue
+++ b/src/pages/store/StoreList.vue
@@ -44,8 +44,8 @@
-
-
+
+
-
+
{
text-align: center;
}
- .load-state {
- color: #52607a;
- padding: .75rem 1rem 1rem;
- text-align: center;
- }
-
}
.edit-dialog {
@@ -360,7 +354,7 @@ onUnmounted(() => {
.tag-list {
.empty,
- .load-state {
+ .center-tips {
color: #94a3b8;
}
}
diff --git a/src/pages/user/UserList.vue b/src/pages/user/UserList.vue
index 7569427..cb5b805 100644
--- a/src/pages/user/UserList.vue
+++ b/src/pages/user/UserList.vue
@@ -42,8 +42,8 @@
-
-
+
+