support consume point

This commit is contained in:
Timi
2026-08-18 16:46:13 +08:00
parent eaee1ae49b
commit eb6b8beb6b
14 changed files with 281 additions and 99 deletions
+1 -1
View File
@@ -171,7 +171,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { DialogPlugin, Toast } from "tdesign-mobile-vue"; import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import { PassTimeLabel, Text, Time, useUserStore } from "timi-web"; import { PassTimeLabel, Text, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
import TagAPI from "@/api/TagAPI"; import TagAPI from "@/api/TagAPI";
+9 -2
View File
@@ -9,8 +9,8 @@
<t-cell-group v-if="record" title="登记信息" theme="card"> <t-cell-group v-if="record" title="登记信息" theme="card">
<t-cell title="登记事件" :note="record.event?.name || '未知事件'" /> <t-cell title="登记事件" :note="record.event?.name || '未知事件'" />
<t-cell v-if="record.numberValue !== undefined && record.numberValue !== null" title="事件数值" :note="getNumberValueText(record)" /> <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="`${record.pointValue} 分`" /> <t-cell v-if="record.pointValue !== undefined && record.pointValue !== null" title="提交积分值" :note="getPointValueText(record.pointValue)" />
<t-cell v-if="record.earnedPointValue !== undefined && record.earnedPointValue !== null" title="本次获得积分" :note="`${record.earnedPointValue} 分`" /> <t-cell v-if="record.earnedPointValue !== undefined && record.earnedPointValue !== null" title="本次积分变化" :note="getPointValueText(record.earnedPointValue)" />
<t-cell title="登记时间" :note="record.registeredAt ? Time.toPassedDateTime(record.registeredAt) : '-'" /> <t-cell title="登记时间" :note="record.registeredAt ? Time.toPassedDateTime(record.registeredAt) : '-'" />
<t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" /> <t-cell title="登记人" :note="Text.display(record.operator?.nick || record.operator?.name)" />
<t-cell title="客户编码" :note="Text.display(record.customer?.code)" /> <t-cell title="客户编码" :note="Text.display(record.customer?.code)" />
@@ -63,6 +63,13 @@ function getNumberValueText(value: EventRecord): string {
return formatEventNumberValue(value.event, value.numberValue); return formatEventNumberValue(value.event, value.numberValue);
} }
function getPointValueText(value?: number | null): string {
if (value === undefined || value === null) {
return "-";
}
return `${0 < value ? "获得" : "消耗"} ${Math.abs(value)} 分`;
}
// ---------- 页面数据 ---------- // ---------- 页面数据 ----------
async function loadRecord(): Promise<void> { async function loadRecord(): Promise<void> {
+23 -13
View File
@@ -68,7 +68,10 @@
</t-form-item> </t-form-item>
</div> </div>
<div v-if="isEditMode" class="edit-point-tip">已产生积分的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建。</div> <div v-if="isEditMode" class="edit-point-tip">已产生积分的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建。</div>
<t-form-item v-if="showSourceValueInput" label="自定义积分" required-mark> <t-form-item v-if="showSourceValueInput" label="积分动作">
<span class="picker-text" v-text="getPointActionText(sourceValueAction)" />
</t-form-item>
<t-form-item v-if="showSourceValueInput" label="积分数量" required-mark>
<t-input <t-input
v-model.trim="pointValueText" v-model.trim="pointValueText"
type="number" type="number"
@@ -198,10 +201,11 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord"; import type { EventRecord } from "@/types/EventRecord";
import type { PointAction } from "@/types/Point";
import { getEventRecordPhotoURL } from "@/utils/eventRecord"; import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue"; import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { loadSourceValueRange } from "@/utils/point"; import { loadSourceValueConfig, type SourceValueConfig } from "@/utils/point";
import { compressUploadImageList } from "@/utils/imageCompression"; import { compressUploadImageList } from "@/utils/imageCompression";
defineOptions({ defineOptions({
@@ -456,40 +460,46 @@ function validateNumberValueList(): boolean {
// ---------- 积分规则联动 ---------- // ---------- 积分规则联动 ----------
const pointValueText = ref(""); const pointValueText = ref("");
const sourceValueRange = ref<[number, number]>(); const sourceValueConfig = ref<SourceValueConfig>();
const sourceValueAction = ref<PointAction>("EARN");
let sourceValueLoadToken = 0; let sourceValueLoadToken = 0;
const showSourceValueInput = computed(() => ( const showSourceValueInput = computed(() => (
!isEditMode.value !isEditMode.value
&& userStore.hasPermissions(permissionCodes.point.sourceValue) && userStore.hasPermissions(permissionCodes.point.sourceValue)
&& !!sourceValueRange.value && !!sourceValueConfig.value
)); ));
const sourceValuePlaceholder = computed(() => { const sourceValuePlaceholder = computed(() => {
const range = sourceValueRange.value; const range = sourceValueConfig.value?.range;
return range ? `请输入 ${range[0]}-${range[1]} 的正整数` : "请输入积分"; return range ? `请输入 ${range[0]}-${range[1]} 的数量` : "请输入数量";
}); });
async function loadSourceValueRule(): Promise<void> { async function loadSourceValueRule(): Promise<void> {
const token = sourceValueLoadToken + 1; const token = sourceValueLoadToken + 1;
sourceValueLoadToken = token; sourceValueLoadToken = token;
sourceValueRange.value = undefined; sourceValueConfig.value = undefined;
if (isEditMode.value || !userStore.hasPermissions(permissionCodes.point.sourceValue) || !selectedEventIdList.value.length) { if (isEditMode.value || !userStore.hasPermissions(permissionCodes.point.sourceValue) || !selectedEventIdList.value.length) {
return; return;
} }
try { try {
sourceValueRange.value = await loadSourceValueRange(selectedEventIdList.value); sourceValueConfig.value = await loadSourceValueConfig(selectedEventIdList.value);
sourceValueAction.value = sourceValueConfig.value?.action || "EARN";
if (token !== sourceValueLoadToken) { if (token !== sourceValueLoadToken) {
return; return;
} }
} catch { } catch {
sourceValueRange.value = undefined; sourceValueConfig.value = undefined;
} }
} }
function resolveSourceValue(): number | undefined { function resolveSourceValue(): number | undefined {
const value = Number(pointValueText.value); const value = Number(pointValueText.value);
return Number.isSafeInteger(value) && 0 < value ? value : undefined; return Number.isSafeInteger(value) && 0 < value ? sourceValueAction.value === "CONSUME" ? -value : value : undefined;
}
function getPointActionText(action: PointAction): string {
return action === "CONSUME" ? "消耗积分" : "发放积分";
} }
// ---------- 照片 ---------- // ---------- 照片 ----------
@@ -641,9 +651,9 @@ function validateForm(): boolean {
} }
if (showSourceValueInput.value) { if (showSourceValueInput.value) {
const pointValue = resolveSourceValue(); const pointValue = resolveSourceValue();
const range = sourceValueRange.value; const range = sourceValueConfig.value?.range;
if (!pointValue || !range || pointValue < range[0] || range[1] < pointValue) { if (!pointValue || !range || Math.abs(pointValue) < range[0] || range[1] < Math.abs(pointValue)) {
Toast.warning(`自定义积分必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`); Toast.warning(`积分数量必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`);
return false; return false;
} }
} }
+16 -13
View File
@@ -83,7 +83,7 @@
</t-form-item> </t-form-item>
</div> </div>
<div v-if="showSourceValueInput" class="source-value-panel"> <div v-if="showSourceValueInput" class="source-value-panel">
<div class="source-value-title">自定义积分</div> <div class="source-value-title" v-text="sourceValueAction === 'CONSUME' ? '消耗积分' : '发放积分'" />
<t-input <t-input
v-model.trim="pointValueText" v-model.trim="pointValueText"
type="number" type="number"
@@ -146,10 +146,11 @@ import PhotoScroller from "@/components/PhotoScroller.vue";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import type { PointAction } from "@/types/Point";
import { getCustomerPhotoURL } from "@/utils/customer"; import { getCustomerPhotoURL } from "@/utils/customer";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue"; import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { loadSourceValueRange } from "@/utils/point"; import { loadSourceValueConfig, type SourceValueConfig } from "@/utils/point";
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression"; import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
import EventSelector from "@/components/EventSelector.vue"; import EventSelector from "@/components/EventSelector.vue";
import EventRecordAPI from "@/api/EventRecordAPI.ts"; import EventRecordAPI from "@/api/EventRecordAPI.ts";
@@ -171,31 +172,33 @@ const selectedNumberEventList = computed(() => selectedEventIdList.value
.map(eventId => eventList.value.find(item => item.id === eventId)) .map(eventId => eventList.value.find(item => item.id === eventId))
.filter((item): item is EventItem => item?.valueType === "NUMBER")); .filter((item): item is EventItem => item?.valueType === "NUMBER"));
const pointValueText = ref(""); const pointValueText = ref("");
const sourceValueRange = ref<[number, number]>(); const sourceValueConfig = ref<SourceValueConfig>();
const sourceValueAction = ref<PointAction>("EARN");
const showSourceValueInput = computed(() => ( const showSourceValueInput = computed(() => (
userStore.hasPermissions(permissionCodes.point.sourceValue) userStore.hasPermissions(permissionCodes.point.sourceValue)
&& !!sourceValueRange.value && !!sourceValueConfig.value
)); ));
const sourceValuePlaceholder = computed(() => { const sourceValuePlaceholder = computed(() => {
const range = sourceValueRange.value; const range = sourceValueConfig.value?.range;
return range ? `请输入 ${range[0]}-${range[1]} 的正整数` : "请输入积分"; return range ? `请输入 ${range[0]}-${range[1]} 的数量` : "请输入数量";
}); });
async function loadSourceValueRule(): Promise<void> { async function loadSourceValueRule(): Promise<void> {
if (!selectedEventIdList.value.length || !userStore.hasPermissions(permissionCodes.point.sourceValue)) { if (!selectedEventIdList.value.length || !userStore.hasPermissions(permissionCodes.point.sourceValue)) {
sourceValueRange.value = undefined; sourceValueConfig.value = undefined;
return; return;
} }
try { try {
sourceValueRange.value = await loadSourceValueRange(selectedEventIdList.value); sourceValueConfig.value = await loadSourceValueConfig(selectedEventIdList.value);
sourceValueAction.value = sourceValueConfig.value?.action || "EARN";
} catch { } catch {
sourceValueRange.value = undefined; sourceValueConfig.value = undefined;
} }
} }
function resolveSourceValue(): number | undefined { function resolveSourceValue(): number | undefined {
const value = Number(pointValueText.value); const value = Number(pointValueText.value);
return Number.isSafeInteger(value) && 0 < value ? value : undefined; return Number.isSafeInteger(value) && 0 < value ? sourceValueAction.value === "CONSUME" ? -value : value : undefined;
} }
function getNumberValuePlaceholder(event: EventItem): string { function getNumberValuePlaceholder(event: EventItem): string {
@@ -640,9 +643,9 @@ function validateForm(): string {
} }
if (showSourceValueInput.value) { if (showSourceValueInput.value) {
const pointValue = resolveSourceValue(); const pointValue = resolveSourceValue();
const range = sourceValueRange.value; const range = sourceValueConfig.value?.range;
if (!pointValue || !range || pointValue < range[0] || range[1] < pointValue) { if (!pointValue || !range || Math.abs(pointValue) < range[0] || range[1] < Math.abs(pointValue)) {
showToast(`自定义积分必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`, "warning"); showToast(`积分数量必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`, "warning");
return ""; return "";
} }
} }
+16 -13
View File
@@ -28,7 +28,7 @@
/> />
</div> </div>
<div v-if="showSourceValueInput" class="source-value-panel"> <div v-if="showSourceValueInput" class="source-value-panel">
<div class="source-value-title">自定义积分</div> <div class="source-value-title" v-text="sourceValueAction === 'CONSUME' ? '消耗积分' : '发放积分'" />
<t-input <t-input
v-model.trim="pointValueText" v-model.trim="pointValueText"
type="number" type="number"
@@ -99,11 +99,12 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord"; import type { EventRecord } from "@/types/EventRecord";
import type { PointAction } from "@/types/Point";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue"; import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
import { getEventRecordPhotoURL } from "@/utils/eventRecord"; import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { Storage, Time, useUserStore } from "timi-web"; import { Storage, Time, useUserStore } from "timi-web";
import { loadSourceValueRange } from "@/utils/point"; import { loadSourceValueConfig, type SourceValueConfig } from "@/utils/point";
defineOptions({ defineOptions({
name: "QuickSignInPage" name: "QuickSignInPage"
@@ -206,34 +207,36 @@ async function toRecordDetail(id: string): Promise<void> {
const submitting = ref(false); const submitting = ref(false);
const pointValueText = ref(""); const pointValueText = ref("");
const numberValueText = ref(""); const numberValueText = ref("");
const sourceValueRange = ref<[number, number]>(); const sourceValueConfig = ref<SourceValueConfig>();
const sourceValueAction = ref<PointAction>("EARN");
const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedEventId.value); const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedEventId.value);
const showNumberValueInput = computed(() => selectedEvent.value?.valueType === "NUMBER"); const showNumberValueInput = computed(() => selectedEvent.value?.valueType === "NUMBER");
const numberValuePlaceholder = computed(() => selectedEvent.value ? getEventNumberValuePlaceholder(selectedEvent.value) : "请输入事件数值"); const numberValuePlaceholder = computed(() => selectedEvent.value ? getEventNumberValuePlaceholder(selectedEvent.value) : "请输入事件数值");
const showSourceValueInput = computed(() => ( const showSourceValueInput = computed(() => (
userStore.hasPermissions(permissionCodes.point.sourceValue) userStore.hasPermissions(permissionCodes.point.sourceValue)
&& !!sourceValueRange.value && !!sourceValueConfig.value
)); ));
const sourceValuePlaceholder = computed(() => { const sourceValuePlaceholder = computed(() => {
const range = sourceValueRange.value; const range = sourceValueConfig.value?.range;
return range ? `请输入 ${range[0]}-${range[1]} 的正整数` : "请输入积分"; return range ? `请输入 ${range[0]}-${range[1]} 的数量` : "请输入数量";
}); });
async function loadSourceValueRule(): Promise<void> { async function loadSourceValueRule(): Promise<void> {
if (!selectedEventId.value || !userStore.hasPermissions(permissionCodes.point.sourceValue)) { if (!selectedEventId.value || !userStore.hasPermissions(permissionCodes.point.sourceValue)) {
sourceValueRange.value = undefined; sourceValueConfig.value = undefined;
return; return;
} }
try { try {
sourceValueRange.value = await loadSourceValueRange([selectedEventId.value]); sourceValueConfig.value = await loadSourceValueConfig([selectedEventId.value]);
sourceValueAction.value = sourceValueConfig.value?.action || "EARN";
} catch { } catch {
sourceValueRange.value = undefined; sourceValueConfig.value = undefined;
} }
} }
function resolveSourceValue(): number | undefined { function resolveSourceValue(): number | undefined {
const value = Number(pointValueText.value); const value = Number(pointValueText.value);
return Number.isSafeInteger(value) && 0 < value ? value : undefined; return Number.isSafeInteger(value) && 0 < value ? sourceValueAction.value === "CONSUME" ? -value : value : undefined;
} }
function resolveNumberValue(): number | undefined { function resolveNumberValue(): number | undefined {
@@ -255,9 +258,9 @@ async function doSubmit(): Promise<void> {
Toast.warning("请填写事件数值"); Toast.warning("请填写事件数值");
return; return;
} }
const range = sourceValueRange.value; const range = sourceValueConfig.value?.range;
if (showSourceValueInput.value && (!pointValue || !range || pointValue < range[0] || range[1] < pointValue)) { if (showSourceValueInput.value && (!pointValue || !range || Math.abs(pointValue) < range[0] || range[1] < Math.abs(pointValue))) {
Toast.warning(`自定义积分必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`); Toast.warning(`积分数量必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`);
return; return;
} }
submitting.value = true; submitting.value = true;
+1 -1
View File
@@ -335,7 +335,7 @@ function formatTimestamp(timestamp: number) {
function formatSignedPoints(value?: number): string { function formatSignedPoints(value?: number): string {
const pointValue = value ?? 0; const pointValue = value ?? 0;
return `${0 < pointValue ? "+" : ""}${pointValue.toLocaleString("zh-CN")} 分`; return pointValue < 0 ? `扣除 ${Math.abs(pointValue).toLocaleString("zh-CN")} 分` : `增加 ${pointValue.toLocaleString("zh-CN")} 分`;
} }
watch([() => route.query.customerId, () => route.query.storeId], async ([customerIdValue, storeIdValue]) => { watch([() => route.query.customerId, () => route.query.storeId], async ([customerIdValue, storeIdValue]) => {
+36 -3
View File
@@ -43,12 +43,15 @@
<customer-profile-card :customer="selectedCustomer" /> <customer-profile-card :customer="selectedCustomer" />
<div class="balance" v-text="balanceText" /> <div class="balance" v-text="balanceText" />
</section> </section>
<t-form-item label="调整积分" required-mark> <t-form-item label="调整动作" arrow @click="openPointActionPicker">
<span class="picker-text" v-text="pointAction === 'CONSUME' ? '扣除积分' : '补发积分'" />
</t-form-item>
<t-form-item label="调整数量" required-mark>
<t-input <t-input
v-model.trim="pointDeltaText" v-model.trim="pointDeltaText"
type="number" type="number"
align="right" align="right"
placeholder="正数加分,负数扣分" placeholder="请输入数量"
borderless borderless
/> />
</t-form-item> </t-form-item>
@@ -82,12 +85,22 @@
@confirm="confirmStorePicker" @confirm="confirmStorePicker"
/> />
</t-popup> </t-popup>
<t-popup v-model="pointActionPickerVisible" placement="bottom">
<t-picker
v-model="pointActionPickerDraft"
:columns="pointActionPickerColumns"
title="调整动作"
@cancel="pointActionPickerVisible = false"
@confirm="confirmPointActionPicker"
/>
</t-popup>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Toast } from "tdesign-mobile-vue"; import { Toast } from "tdesign-mobile-vue";
import { useUserStore } from "timi-web"; import { useUserStore } from "timi-web";
import type { PointAction } from "@/types/Point";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
import CodeScanner from "@/components/CodeScanner.vue"; import CodeScanner from "@/components/CodeScanner.vue";
@@ -254,6 +267,13 @@ function handleScanStatusChange(value: string): void {
// ---------- 调整表单 ---------- // ---------- 调整表单 ----------
const pointDeltaText = ref(""); const pointDeltaText = ref("");
const pointAction = ref<PointAction>("EARN");
const pointActionPickerVisible = ref(false);
const pointActionPickerDraft = ref<Array<string | number>>([]);
const pointActionPickerColumns = [[
{ label: "补发积分", value: "EARN" },
{ label: "扣除积分", value: "CONSUME" }
]];
const remark = ref(""); const remark = ref("");
const adjustRequestId = ref(generateRequestId()); const adjustRequestId = ref(generateRequestId());
const isSaving = ref(false); const isSaving = ref(false);
@@ -261,7 +281,20 @@ const canSubmit = computed(() => !!selectedCustomerId.value && !!selectedStoreId
function parsePointDelta(): number { function parsePointDelta(): number {
const value = Number(pointDeltaText.value); const value = Number(pointDeltaText.value);
return Number.isSafeInteger(value) ? value : 0; return Number.isSafeInteger(value) && 0 < value ? pointAction.value === "CONSUME" ? -value : value : 0;
}
function openPointActionPicker(): void {
pointActionPickerDraft.value = [pointAction.value];
pointActionPickerVisible.value = true;
}
function confirmPointActionPicker(value: Array<string | number>): void {
const action = value[0];
if (action === "EARN" || action === "CONSUME") {
pointAction.value = action;
}
pointActionPickerVisible.value = false;
} }
async function submitAdjust(): Promise<void> { async function submitAdjust(): Promise<void> {
+16 -6
View File
@@ -45,8 +45,8 @@
</template> </template>
</t-cell> </t-cell>
<t-cell v-if="hasEventNumberValue" title="事件数值" :note="getEventNumberValueText()" /> <t-cell v-if="hasEventNumberValue" title="事件数值" :note="getEventNumberValueText()" />
<t-cell v-if="hasSubmittedPointValue" title="提交积分值" :note="getPointText(eventRecord.pointValue)" /> <t-cell v-if="hasSubmittedPointValue" title="提交积分值" :note="getSignedPointText(eventRecord.pointValue)" />
<t-cell v-if="hasEarnedPointValue" title="记录获得积分" :note="getPointText(eventRecord.earnedPointValue)" /> <t-cell v-if="hasEarnedPointValue" title="本次积分变化" :note="getSignedPointText(eventRecord.earnedPointValue)" />
<t-cell title="登记人" :note="Text.display(eventRecord.operator?.nick || eventRecord.operator?.name)" /> <t-cell title="登记人" :note="Text.display(eventRecord.operator?.nick || eventRecord.operator?.name)" />
<t-cell title="记录备注" :note="Text.display(eventRecord.remark)" /> <t-cell title="记录备注" :note="Text.display(eventRecord.remark)" />
</template> </template>
@@ -198,7 +198,7 @@ async function loadPageData(): Promise<void> {
function getChangeTypeText(changeType?: PointLedger["changeType"]): string { function getChangeTypeText(changeType?: PointLedger["changeType"]): string {
return ({ return ({
EARN: "获得积分", EARN: "积分变化",
REVOKE: "撤销积分", REVOKE: "撤销积分",
ADJUST: "人工调整" ADJUST: "人工调整"
} as Record<string, string>)[changeType || ""] || "未知流水"; } as Record<string, string>)[changeType || ""] || "未知流水";
@@ -212,13 +212,20 @@ function getPointDeltaText(pointDelta?: number): string {
if (pointDelta === undefined || pointDelta === null) { if (pointDelta === undefined || pointDelta === null) {
return "-"; return "-";
} }
return `${0 < pointDelta ? "+" : ""}${pointDelta} 分`; return `${0 < pointDelta ? "获得" : "消耗"} ${Math.abs(pointDelta)} 分`;
} }
function getPointText(value?: number | null): string { function getPointText(value?: number | null): string {
return value === undefined || value === null ? "-" : `${value} 分`; return value === undefined || value === null ? "-" : `${value} 分`;
} }
function getSignedPointText(value?: number | null): string {
if (value === undefined || value === null) {
return "-";
}
return `${0 < value ? "获得" : "消耗"} ${Math.abs(value)} 分`;
}
function getEventValueTypeText(event: EventItem): string { function getEventValueTypeText(event: EventItem): string {
if (event.valueType === "NUMBER") { if (event.valueType === "NUMBER") {
return `数值事件(${getEventValueUnitText(event.valueUnit)})`; return `数值事件(${getEventValueUnitText(event.valueUnit)})`;
@@ -252,7 +259,10 @@ function getRulePointText(rule: PointRule): string {
if (rule.pointMode === "TIER") { if (rule.pointMode === "TIER") {
return rule.conditionJson?.tiers?.length ? `${rule.conditionJson.tiers.length} 档` : "分档积分"; return rule.conditionJson?.tiers?.length ? `${rule.conditionJson.tiers.length} 档` : "分档积分";
} }
return getPointText(rule.pointValue); if (rule.pointValue === undefined || rule.pointValue === null) {
return "-";
}
return `${0 < rule.pointValue ? "发放" : "消耗"} ${Math.abs(rule.pointValue)} 分`;
} }
function getRuleConditionText(rule: PointRule): string { function getRuleConditionText(rule: PointRule): string {
@@ -278,7 +288,7 @@ function getRuleConditionText(rule: PointRule): string {
textList.push(`来源事件:${sourceEvent.value?.name || sourceEvent.value?.id || condition.sourceEventId}`); textList.push(`来源事件:${sourceEvent.value?.name || sourceEvent.value?.id || condition.sourceEventId}`);
} }
if (condition.tiers?.length) { if (condition.tiers?.length) {
textList.push(condition.tiers.map(tier => `${tier.min}~${tier.max ?? "以上"}:${tier.pointValue} 分`).join(",")); textList.push(condition.tiers.map(tier => `${tier.min}~${tier.max ?? "以上"}:${0 < tier.pointValue ? "发放" : "消耗"} ${Math.abs(tier.pointValue)} 分`).join(","));
} }
return textList.join(";") || "无额外条件"; return textList.join(";") || "无额外条件";
} }
+10 -16
View File
@@ -102,18 +102,20 @@
class="type-icon" class="type-icon"
:theme="item.pointDelta && 0 < item.pointDelta ? 'success' : 'danger'" :theme="item.pointDelta && 0 < item.pointDelta ? 'success' : 'danger'"
:content="getLedgerShortType(item)" :content="getLedgerShortType(item)"
variant="outline"
/> />
<div class="name" v-text="item.customer?.name" /> <div class="name" v-text="item.customer?.name" />
</div> </div>
</template> </template>
<template #description> <template #description>
<div class="description"> <div class="description">
<div v-text="`交易时间:${Time.toPassedDateTime(item.occurredAt)}`" /> <div v-text="`时间:${Time.toPassedDateTime(item.occurredAt)}`" />
<div class="remark" v-text="getLedgerDescription(item)" /> <div v-text="`余额:${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`" />
<div v-text="item.remark" />
</div> </div>
</template> </template>
<template #note> <template #note>
<div class="delta" :class="getLedgerTheme(item)" v-text="getLedgerDelta(item)" /> <div class="delta keep-text" :class="getLedgerTheme(item)" v-text="getLedgerDelta(item)" />
</template> </template>
</t-cell> </t-cell>
</t-cell-group> </t-cell-group>
@@ -410,21 +412,16 @@ function buildListOptions(): { allStores?: boolean; storeId?: string } | undefin
// ---------- 展示与导航 ---------- // ---------- 展示与导航 ----------
function getLedgerDescription(item: PointLedger): string {
const parts = [`余额 ${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`];
if (item.remark) {
parts.push(item.remark);
}
return parts.join("|");
}
function getLedgerDelta(item: PointLedger): string { function getLedgerDelta(item: PointLedger): string {
const value = item.pointDelta ?? 0; const value = item.pointDelta ?? 0;
return `${0 < value ? "+" : ""}${value}`; return `${0 < value ? "+" : "-"}${Math.abs(value)} 分`;
} }
function getLedgerShortType(item: PointLedger): string { function getLedgerShortType(item: PointLedger): string {
return item.changeType === "EARN" ? "得" : item.changeType === "REVOKE" ? "撤" : "调"; if (item.changeType === "EARN") {
return 0 < (item.pointDelta || 0) ? "获得" : "消耗";
}
return item.changeType === "REVOKE" ? "撤销" : "调整";
} }
function getLedgerTheme(item: PointLedger): string { function getLedgerTheme(item: PointLedger): string {
@@ -604,9 +601,6 @@ onMounted(async () => {
.type-icon { .type-icon {
display: flex; display: flex;
font-size: 1rem;
font-weight: 700;
border-radius: .5rem;
} }
} }
+70 -17
View File
@@ -25,13 +25,16 @@
<t-form-item label="积分值模式" :arrow="formModel.triggerType !== 'CUSTOMER_EVENT_VALUE_TIER'" @click="openModePicker"> <t-form-item label="积分值模式" :arrow="formModel.triggerType !== 'CUSTOMER_EVENT_VALUE_TIER'" @click="openModePicker">
<span class="picker-text" v-text="getModeText(formModel.pointMode)" /> <span class="picker-text" v-text="getModeText(formModel.pointMode)" />
</t-form-item> </t-form-item>
<t-form-item v-if="formModel.pointMode === 'FIXED'" label="固定积分" required-mark> <t-form-item v-if="formModel.pointMode === 'FIXED'" label="积分动作" arrow @click="openPointActionPicker">
<span class="picker-text" v-text="getPointActionText(formModel.pointAction)" />
</t-form-item>
<t-form-item v-if="formModel.pointMode === 'FIXED'" label="积分数量" required-mark>
<t-input <t-input
v-model.trim="formModel.pointValueText" v-model.trim="formModel.pointValueText"
type="number" type="number"
align="right" align="right"
borderless borderless
placeholder="请输入正整数" placeholder="请输入数量"
/> />
</t-form-item> </t-form-item>
<t-form-item v-if="formModel.triggerType === 'AFTER_COUNT_EVERY_TIME'" label="触发阈值" required-mark> <t-form-item v-if="formModel.triggerType === 'AFTER_COUNT_EVERY_TIME'" label="触发阈值" required-mark>
@@ -45,14 +48,17 @@
</t-form-item> </t-form-item>
<div v-if="formModel.pointMode === 'SOURCE_VALUE'" class="source-condition"> <div v-if="formModel.pointMode === 'SOURCE_VALUE'" class="source-condition">
<div class="source-title">自定义积分范围</div> <div class="source-title">自定义积分范围</div>
<t-form-item label="积分动作" arrow @click="openPointActionPicker">
<span class="picker-text" v-text="getPointActionText(formModel.pointAction)" />
</t-form-item>
<div class="source-inputs"> <div class="source-inputs">
<t-input v-model.trim="formModel.minText" type="number" align="right" borderless placeholder="最小值" /> <t-input v-model.trim="formModel.minText" type="number" align="right" borderless placeholder="最小值" />
<span>至</span> <span>至</span>
<t-input v-model.trim="formModel.maxText" type="number" align="right" borderless placeholder="最大值" /> <t-input v-model.trim="formModel.maxText" type="number" align="right" borderless placeholder="最大值" />
</div> </div>
<span class="tips">登记时只有输入在此范围内的正整数,系统才会按本规则发放积分</span> <span class="tips">登记时输入数量,系统会按选定动作执行积分变化</span>
</div> </div>
<template v-if="formModel.triggerType === 'CUSTOMER_EVENT_VALUE_TIER'" class="tier-condition"> <template v-if="formModel.triggerType === 'CUSTOMER_EVENT_VALUE_TIER'">
<t-form-item label="来源事件" required-mark arrow @click="openSourceEventPicker"> <t-form-item label="来源事件" required-mark arrow @click="openSourceEventPicker">
<span class="picker-text" v-text="selectedSourceEventName" /> <span class="picker-text" v-text="selectedSourceEventName" />
</t-form-item> </t-form-item>
@@ -76,7 +82,7 @@
</div> </div>
</t-form-item> </t-form-item>
</card-container> </card-container>
<card-container tips="命中规则并发放积分后不再匹配其他规则"> <card-container tips="命中规则并执行积分动作后不再匹配后续规则">
<t-form-item label="优先级" required-mark> <t-form-item label="优先级" required-mark>
<t-input v-model.trim="formModel.priorityText" type="number" align="right" borderless placeholder="数值越小越优先" /> <t-input v-model.trim="formModel.priorityText" type="number" align="right" borderless placeholder="数值越小越优先" />
</t-form-item> </t-form-item>
@@ -162,6 +168,15 @@
@confirm="confirmModePicker" @confirm="confirmModePicker"
/> />
</t-popup> </t-popup>
<t-popup v-model="pointActionPickerVisible" placement="bottom">
<t-picker
v-model="pointActionPickerDraft"
:columns="pointActionPickerColumns"
title="积分动作"
@cancel="pointActionPickerVisible = false"
@confirm="confirmPointActionPicker"
/>
</t-popup>
<t-popup v-model="beginAtPickerVisible" placement="bottom"> <t-popup v-model="beginAtPickerVisible" placement="bottom">
<t-date-time-picker <t-date-time-picker
v-model="beginAtDraftText" v-model="beginAtDraftText"
@@ -194,7 +209,7 @@ import PointAPI from "@/api/PointAPI";
import PointRuleTierEditor from "@/pages/point/component/PointRuleTierEditor.vue"; import PointRuleTierEditor from "@/pages/point/component/PointRuleTierEditor.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import type { PointRule, PointRuleMode, PointRuleStatus, PointRuleTier, PointRuleTierForm, PointRuleTriggerType } from "@/types/Point"; import type { PointAction, PointRule, PointRuleMode, PointRuleStatus, PointRuleTier, PointRuleTierForm, PointRuleTriggerType } from "@/types/Point";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { getTierMinValue, isLastTier, resolveNonNegativeInteger } from "@/utils/pointRule"; import { getTierMinValue, isLastTier, resolveNonNegativeInteger } from "@/utils/pointRule";
@@ -230,6 +245,7 @@ const formModel = reactive({
status: "DRAFT" as PointRuleStatus, status: "DRAFT" as PointRuleStatus,
triggerType: "EVERY_TIME" as PointRuleTriggerType, triggerType: "EVERY_TIME" as PointRuleTriggerType,
pointMode: "FIXED" as PointRuleMode, pointMode: "FIXED" as PointRuleMode,
pointAction: "EARN" as PointAction,
pointValueText: "", pointValueText: "",
thresholdText: "", thresholdText: "",
minText: "", minText: "",
@@ -276,10 +292,12 @@ const selectedSourceEventName = computed(() => sourceEventList.value.find(item =
let tierKey = 0; let tierKey = 0;
function createTierForm(tier?: PointRuleTier, isLast = false): PointRuleTierForm { function createTierForm(tier?: PointRuleTier, isLast = false): PointRuleTierForm {
const pointValue = tier?.pointValue;
return { return {
key: tierKey++, key: tierKey++,
maxText: !isLast && tier?.max !== undefined && tier.max !== null ? String(tier.max) : "", maxText: !isLast && tier?.max !== undefined && tier.max !== null ? String(tier.max) : "",
pointValueText: tier ? String(tier.pointValue) : "0" pointAction: pointValue !== undefined && pointValue < 0 ? "CONSUME" : "EARN",
pointValueText: pointValue === undefined ? "0" : String(Math.abs(pointValue))
}; };
} }
@@ -401,6 +419,13 @@ const triggerPickerColumns = [triggerOptions];
const modePickerVisible = ref(false); const modePickerVisible = ref(false);
const modePickerDraft = ref<PickerValue[]>([]); const modePickerDraft = ref<PickerValue[]>([]);
const modePickerColumns = computed(() => [modeOptions.value]); const modePickerColumns = computed(() => [modeOptions.value]);
const pointActionOptions: Array<{ label: string; value: PointAction }> = [
{ label: "发放积分", value: "EARN" },
{ label: "消耗积分", value: "CONSUME" }
];
const pointActionPickerVisible = ref(false);
const pointActionPickerDraft = ref<PickerValue[]>([]);
const pointActionPickerColumns = [pointActionOptions];
function openStatusPicker(): void { function openStatusPicker(): void {
statusPickerDraft.value = [formModel.status]; statusPickerDraft.value = [formModel.status];
@@ -453,6 +478,22 @@ function confirmModePicker(value: Array<string | number>): void {
modePickerVisible.value = false; modePickerVisible.value = false;
} }
function openPointActionPicker(): void {
if (formModel.pointMode === "TIER") {
return;
}
pointActionPickerDraft.value = [formModel.pointAction];
pointActionPickerVisible.value = true;
}
function confirmPointActionPicker(value: Array<string | number>): void {
const action = value[0];
if (action === "EARN" || action === "CONSUME") {
formModel.pointAction = action;
}
pointActionPickerVisible.value = false;
}
function getStatusText(status: PointRuleStatus): string { function getStatusText(status: PointRuleStatus): string {
return statusOptions.find(item => item.value === status)?.label || "草稿"; return statusOptions.find(item => item.value === status)?.label || "草稿";
} }
@@ -465,6 +506,10 @@ function getModeText(pointMode: PointRuleMode): string {
return modeOptions.value.find(item => item.value === pointMode)?.label || "固定积分"; return modeOptions.value.find(item => item.value === pointMode)?.label || "固定积分";
} }
function getPointActionText(action: PointAction): string {
return action === "CONSUME" ? "消耗积分" : "发放积分";
}
// ---------- 生效时间 ---------- // ---------- 生效时间 ----------
const minimumDateTimeText = "1970-01-01 00:00"; const minimumDateTimeText = "1970-01-01 00:00";
@@ -511,6 +556,11 @@ function resolvePositiveInteger(value: string): number | undefined {
return Number.isSafeInteger(numberValue) && 0 < numberValue ? numberValue : undefined; return Number.isSafeInteger(numberValue) && 0 < numberValue ? numberValue : undefined;
} }
function resolveSignedPointValue(value: string, action: PointAction): number | undefined {
const pointValue = resolvePositiveInteger(value);
return pointValue === undefined ? undefined : action === "CONSUME" ? -pointValue : pointValue;
}
function resolveTimestamp(value: string): number | undefined { function resolveTimestamp(value: string): number | undefined {
if (!value.trim()) { if (!value.trim()) {
return undefined; return undefined;
@@ -532,7 +582,7 @@ function buildCondition(): PointRule["conditionJson"] {
tiers: formModel.tiers.map((tier, index) => { tiers: formModel.tiers.map((tier, index) => {
const result: PointRuleTier = { const result: PointRuleTier = {
min: getTierMinValue(formModel.tiers, index) || 0, min: getTierMinValue(formModel.tiers, index) || 0,
pointValue: resolveNonNegativeInteger(tier.pointValueText) || 0 pointValue: (tier.pointAction === "CONSUME" ? -1 : 1) * (resolveNonNegativeInteger(tier.pointValueText) || 0)
}; };
if (!isLastTier(formModel.tiers, index)) { if (!isLastTier(formModel.tiers, index)) {
result.max = resolveNonNegativeInteger(tier.maxText); result.max = resolveNonNegativeInteger(tier.maxText);
@@ -543,8 +593,8 @@ function buildCondition(): PointRule["conditionJson"] {
} }
if (formModel.pointMode === "SOURCE_VALUE") { if (formModel.pointMode === "SOURCE_VALUE") {
return { return {
min: resolvePositiveInteger(formModel.minText), min: resolveSignedPointValue(formModel.minText, formModel.pointAction),
max: resolvePositiveInteger(formModel.maxText) max: resolveSignedPointValue(formModel.maxText, formModel.pointAction)
}; };
} }
if (formModel.triggerType === "FIRST_TIME") { if (formModel.triggerType === "FIRST_TIME") {
@@ -599,7 +649,7 @@ function validateForm(): boolean {
return false; return false;
} }
if (pointValue === undefined) { if (pointValue === undefined) {
Toast.warning(`第 ${i + 1} 档积分必须是大于等于 0 的整数`); Toast.warning(`第 ${i + 1} 档积分数量必须是大于等于 0 的整数`);
return false; return false;
} }
if (!isLastTier(formModel.tiers, i)) { if (!isLastTier(formModel.tiers, i)) {
@@ -611,7 +661,7 @@ function validateForm(): boolean {
} }
} }
} else if (formModel.pointMode === "FIXED" && !resolvePositiveInteger(formModel.pointValueText)) { } else if (formModel.pointMode === "FIXED" && !resolvePositiveInteger(formModel.pointValueText)) {
Toast.warning("固定积分必须是正整数"); Toast.warning("积分数量必须是正整数");
return false; return false;
} }
if (formModel.triggerType === "AFTER_COUNT_EVERY_TIME" && !resolvePositiveInteger(formModel.thresholdText)) { if (formModel.triggerType === "AFTER_COUNT_EVERY_TIME" && !resolvePositiveInteger(formModel.thresholdText)) {
@@ -622,7 +672,7 @@ function validateForm(): boolean {
const min = resolvePositiveInteger(formModel.minText); const min = resolvePositiveInteger(formModel.minText);
const max = resolvePositiveInteger(formModel.maxText); const max = resolvePositiveInteger(formModel.maxText);
if (!min || !max || max < min) { if (!min || !max || max < min) {
Toast.warning("自定义积分范围必须是正整数,且最大值不能小于最小值"); Toast.warning("积分数量范围必须是正整数,且最大值不能小于最小值");
return false; return false;
} }
} }
@@ -654,7 +704,7 @@ async function saveRule(): Promise<void> {
status: formModel.status, status: formModel.status,
triggerType: formModel.triggerType, triggerType: formModel.triggerType,
pointMode: formModel.pointMode, pointMode: formModel.pointMode,
pointValue: formModel.pointMode === "FIXED" ? resolvePositiveInteger(formModel.pointValueText) : null, pointValue: formModel.pointMode === "FIXED" ? resolveSignedPointValue(formModel.pointValueText, formModel.pointAction) : null,
conditionJson: buildCondition(), conditionJson: buildCondition(),
beginAt: resolveTimestamp(formModel.beginAtText), beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText), endAt: resolveTimestamp(formModel.endAtText),
@@ -727,10 +777,13 @@ async function fillForm(): Promise<void> {
status: current.status === "ACTIVE" ? "ACTIVE" : current.status === "INACTIVE" ? "INACTIVE" : "DRAFT", status: current.status === "ACTIVE" ? "ACTIVE" : current.status === "INACTIVE" ? "INACTIVE" : "DRAFT",
triggerType: current.triggerType || "EVERY_TIME", triggerType: current.triggerType || "EVERY_TIME",
pointMode: current.pointMode || "FIXED", pointMode: current.pointMode || "FIXED",
pointValueText: current.pointValue === undefined || current.pointValue === null ? "" : String(current.pointValue), pointAction: current.pointValue !== undefined && current.pointValue !== null
? current.pointValue < 0 ? "CONSUME" : "EARN"
: current.conditionJson?.min !== undefined && current.conditionJson.min < 0 ? "CONSUME" : "EARN",
pointValueText: current.pointValue === undefined || current.pointValue === null ? "" : String(Math.abs(current.pointValue)),
thresholdText: current.conditionJson?.threshold === undefined ? "" : String(current.conditionJson.threshold), thresholdText: current.conditionJson?.threshold === undefined ? "" : String(current.conditionJson.threshold),
minText: current.conditionJson?.min === undefined ? "" : String(current.conditionJson.min), minText: current.conditionJson?.min === undefined ? "" : String(Math.abs(current.conditionJson.min)),
maxText: current.conditionJson?.max === undefined ? "" : String(current.conditionJson.max), maxText: current.conditionJson?.max === undefined ? "" : String(Math.abs(current.conditionJson.max)),
sourceEventId: current.conditionJson?.sourceEventId || "", sourceEventId: current.conditionJson?.sourceEventId || "",
windowAmountText: current.conditionJson?.window?.amount === undefined ? "1" : String(current.conditionJson.window.amount), windowAmountText: current.conditionJson?.window?.amount === undefined ? "1" : String(current.conditionJson.window.amount),
tiers: current.conditionJson?.tiers?.length tiers: current.conditionJson?.tiers?.length
+10 -3
View File
@@ -34,7 +34,7 @@
@click="toEdit(item)" @click="toEdit(item)"
> >
<template #left-icon> <template #left-icon>
<div class="point"> <div class="point" :class="{ 'consume': item.pointValue && item.pointValue < 0 }">
<t-icon name="money" /> <t-icon name="money" />
<span v-text="getPointText(item)" /> <span v-text="getPointText(item)" />
</div> </div>
@@ -242,7 +242,10 @@ function getPointText(rule: PointRule): string {
if (rule.pointMode === "TIER") { if (rule.pointMode === "TIER") {
return "分档"; return "分档";
} }
return rule.pointValue === undefined || rule.pointValue === null ? "?" : String(rule.pointValue); if (rule.pointValue === undefined || rule.pointValue === null) {
return "?";
}
return `${0 < rule.pointValue ? "+" : "-"}${Math.abs(rule.pointValue)}`;
} }
function getStatusText(status?: PointRuleStatus): string { function getStatusText(status?: PointRuleStatus): string {
@@ -323,11 +326,15 @@ onMounted(async () => {
.item { .item {
.point { .point {
width: 3rem; width: 4rem;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
&.consume {
color: var(--td-error-color);
}
} }
.title { .title {
@@ -50,23 +50,35 @@
placeholder="请输入最大值" placeholder="请输入最大值"
/> />
</t-form-item> </t-form-item>
<t-form-item label="积分" required-mark> <t-form-item label="积分动作" arrow @click="openActionPicker">
<span class="picker-text" v-text="getPointActionText(tierEditorDraft.pointAction)" />
</t-form-item>
<t-form-item label="积分数量" required-mark>
<t-input <t-input
v-model.trim="tierEditorDraft.pointValueText" v-model.trim="tierEditorDraft.pointValueText"
type="number" type="number"
align="right" align="right"
borderless borderless
placeholder="请输入积分,可填写 0" placeholder="请输入数量,可填写 0"
/> />
</t-form-item> </t-form-item>
<div class="tips">积分为 0 时规则仍会命中,但系统不会创建积分流水。</div> <div class="tips">积分为 0 时规则仍会命中,但系统不会创建积分流水。</div>
</t-form> </t-form>
</div> </div>
</bottom-popup> </bottom-popup>
<t-popup v-model="actionPickerVisible" placement="bottom">
<t-picker
v-model="actionPickerDraft"
:columns="actionPickerColumns"
title="积分动作"
@cancel="actionPickerVisible = false"
@confirm="confirmActionPicker"
/>
</t-popup>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { PointRuleTierForm } from "@/types/Point"; import type { PointAction, PointRuleTierForm } from "@/types/Point";
import { getTierMinText, getTierMinValue, isLastTier, resolveNonNegativeInteger } from "@/utils/pointRule"; import { getTierMinText, getTierMinValue, isLastTier, resolveNonNegativeInteger } from "@/utils/pointRule";
import { Toast } from "tdesign-mobile-vue"; import { Toast } from "tdesign-mobile-vue";
@@ -80,8 +92,16 @@ const tierEditorIndex = ref(-1);
const tierEditorAdding = ref(false); const tierEditorAdding = ref(false);
const tierEditorDraft = reactive({ const tierEditorDraft = reactive({
maxText: "", maxText: "",
pointAction: "EARN" as PointAction,
pointValueText: "" pointValueText: ""
}); });
const actionOptions: Array<{ label: string; value: PointAction }> = [
{ label: "发放积分", value: "EARN" },
{ label: "消耗积分", value: "CONSUME" }
];
const actionPickerVisible = ref(false);
const actionPickerDraft = ref<Array<string | number>>([]);
const actionPickerColumns = [actionOptions];
const tierEditorTitle = computed(() => { const tierEditorTitle = computed(() => {
if (tierEditorIndex.value < 0) { if (tierEditorIndex.value < 0) {
return "编辑分档"; return "编辑分档";
@@ -107,7 +127,7 @@ function getTierPointText(tier: PointRuleTierForm): string {
if (pointValue === undefined) { if (pointValue === undefined) {
return "待填写"; return "待填写";
} }
return `+${pointValue} 积分`; return `${tier.pointAction === "CONSUME" ? "消耗" : "发放"} ${pointValue} 分`;
} }
function openTierEditor(index: number, adding = false): void { function openTierEditor(index: number, adding = false): void {
@@ -118,6 +138,7 @@ function openTierEditor(index: number, adding = false): void {
tierEditorIndex.value = index; tierEditorIndex.value = index;
tierEditorAdding.value = adding; tierEditorAdding.value = adding;
tierEditorDraft.maxText = adding ? "" : tier.maxText; tierEditorDraft.maxText = adding ? "" : tier.maxText;
tierEditorDraft.pointAction = tier.pointAction;
tierEditorDraft.pointValueText = tier.pointValueText; tierEditorDraft.pointValueText = tier.pointValueText;
tierEditorVisible.value = true; tierEditorVisible.value = true;
} }
@@ -140,7 +161,7 @@ function confirmTierEditor(): void {
return; return;
} }
if (pointValue === undefined) { if (pointValue === undefined) {
Toast.warning("积分必须是大于等于 0 的整数,允许填写 0"); Toast.warning("积分数量必须是大于等于 0 的整数,允许填写 0");
return; return;
} }
const shouldAddTier = tierEditorAdding.value && isLastTier(tiers.value, index); const shouldAddTier = tierEditorAdding.value && isLastTier(tiers.value, index);
@@ -155,6 +176,7 @@ function confirmTierEditor(): void {
tier.maxText = ""; tier.maxText = "";
} }
tier.pointValueText = String(pointValue); tier.pointValueText = String(pointValue);
tier.pointAction = tierEditorDraft.pointAction;
if (shouldAddTier) { if (shouldAddTier) {
tiers.value.push(createTierForm()); tiers.value.push(createTierForm());
} }
@@ -181,11 +203,29 @@ function removeTier(index: number): void {
tiers.value.splice(index, 1); tiers.value.splice(index, 1);
} }
function openActionPicker(): void {
actionPickerDraft.value = [tierEditorDraft.pointAction];
actionPickerVisible.value = true;
}
function confirmActionPicker(value: Array<string | number>): void {
const action = value[0];
if (action === "EARN" || action === "CONSUME") {
tierEditorDraft.pointAction = action;
}
actionPickerVisible.value = false;
}
function getPointActionText(action: PointAction): string {
return action === "CONSUME" ? "消耗积分" : "发放积分";
}
function createTierForm(): PointRuleTierForm { function createTierForm(): PointRuleTierForm {
const key = tiers.value.reduce((max, tier) => Math.max(max, tier.key), -1) + 1; const key = tiers.value.reduce((max, tier) => Math.max(max, tier.key), -1) + 1;
return { return {
key, key,
maxText: "", maxText: "",
pointAction: "EARN",
pointValueText: "0" pointValueText: "0"
}; };
} }
+2
View File
@@ -6,6 +6,7 @@ export type PointRuleTriggerType = "EVERY_TIME" | "FIRST_TIME" | "AFTER_COUNT_EV
export type PointRuleMode = "FIXED" | "SOURCE_VALUE" | "TIER"; export type PointRuleMode = "FIXED" | "SOURCE_VALUE" | "TIER";
export type PointLedgerChangeType = "EARN" | "REVOKE" | "ADJUST"; export type PointLedgerChangeType = "EARN" | "REVOKE" | "ADJUST";
export type PointLedgerDirection = "INCREASE" | "DECREASE"; export type PointLedgerDirection = "INCREASE" | "DECREASE";
export type PointAction = "EARN" | "CONSUME";
export type PointRuleCondition = { export type PointRuleCondition = {
min?: number; min?: number;
@@ -32,6 +33,7 @@ export type PointRuleTier = {
export type PointRuleTierForm = { export type PointRuleTierForm = {
key: number; key: number;
maxText: string; maxText: string;
pointAction: PointAction;
pointValueText: string; pointValueText: string;
}; };
+26 -6
View File
@@ -1,5 +1,10 @@
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
import type { PointRule } from "@/types/Point"; import type { PointAction, PointRule } from "@/types/Point";
export type SourceValueConfig = {
range: [number, number];
action: PointAction;
};
/** /**
* 查询一组事件可用的自定义积分范围,并返回所有规则范围的交集。 * 查询一组事件可用的自定义积分范围,并返回所有规则范围的交集。
@@ -8,6 +13,16 @@ import type { PointRule } from "@/types/Point";
* @returns 可用范围;没有完整有效的 SOURCE_VALUE 规则时返回 undefined * @returns 可用范围;没有完整有效的 SOURCE_VALUE 规则时返回 undefined
*/ */
export async function loadSourceValueRange(eventIdList: string[]): Promise<[number, number] | undefined> { export async function loadSourceValueRange(eventIdList: string[]): Promise<[number, number] | undefined> {
return (await loadSourceValueConfig(eventIdList))?.range;
}
/**
* 查询登记时自定义积分的数量范围和积分动作。
*
* @param eventIdList 事件 ID 列表
* @returns 所有事件规则的共同配置
*/
export async function loadSourceValueConfig(eventIdList: string[]): Promise<SourceValueConfig | undefined> {
if (!eventIdList.length) { if (!eventIdList.length) {
return undefined; return undefined;
} }
@@ -34,16 +49,21 @@ export async function loadSourceValueRange(eventIdList: string[]): Promise<[numb
&& range.max !== undefined && range.max !== undefined
&& Number.isSafeInteger(range.min) && Number.isSafeInteger(range.min)
&& Number.isSafeInteger(range.max) && Number.isSafeInteger(range.max)
&& 0 < range.min && range.min !== 0
&& 0 < range.max && range.max !== 0
&& (range.min < 0) === (range.max < 0)
)); ));
if (rangeList.length !== ruleList.length) { if (rangeList.length !== ruleList.length) {
return undefined; return undefined;
} }
const min = Math.max(...rangeList.map(range => range.min), 1); const action = rangeList[0].min < 0 ? "CONSUME" : "EARN";
const max = Math.min(...rangeList.map(range => range.max)); if (rangeList.some(range => (range.min < 0 ? "CONSUME" : "EARN") !== action)) {
return min <= max ? [min, max] : undefined; return undefined;
}
const min = Math.max(...rangeList.map(range => action === "CONSUME" ? Math.abs(range.max) : range.min), 1);
const max = Math.min(...rangeList.map(range => action === "CONSUME" ? Math.abs(range.min) : range.max));
return min <= max ? { range: [min, max], action } : undefined;
} }
function isSourceValueRule(rule: PointRule): boolean { function isSourceValueRule(rule: PointRule): boolean {