update point tier rule

This commit is contained in:
Timi
2026-08-14 18:26:11 +08:00
parent 3b5ff75772
commit a43694daa3
16 changed files with 940 additions and 32 deletions
+106 -4
View File
@@ -1,6 +1,6 @@
<template>
<div v-can="pageAccess" class="registration-event-edit">
<t-form class="form" label-align="left">
<t-form class="form" label-align="left" label-width="7rem">
<t-form-item label="颜色">
<t-button
size="extra-small"
@@ -33,6 +33,15 @@
borderless
/>
</t-form-item>
<t-form-item label="数值类型" arrow @click="openValueTypePicker">
<div class="picker-text" v-text="getValueTypeText(formModel.valueType)" />
</t-form-item>
<t-form-item v-if="isNumberValueType(formModel.valueType)" label="数值单位" required-mark arrow @click="openValueUnitPicker">
<div class="picker-text" v-text="getValueUnitText(formModel.valueUnit)" />
</t-form-item>
<t-form-item v-if="isNumberValueType(formModel.valueType)" label="登记必填数值" content-align="right">
<t-switch v-model="formModel.requireValue" />
</t-form-item>
<t-form-item label="起始时间" arrow @click="openBeginAtPicker">
<div class="begin-at">
<div class="picker-text" v-text="formModel.beginAtText || '不限'" />
@@ -161,6 +170,24 @@
@confirm="confirmStatusPicker"
/>
</t-popup>
<t-popup v-model="valueTypePickerVisible" placement="bottom">
<t-picker
v-model="valueTypePickerDraft"
:columns="valueTypePickerColumns"
title="数值类型"
@cancel="valueTypePickerVisible = false"
@confirm="confirmValueType"
/>
</t-popup>
<t-popup v-model="valueUnitPickerVisible" placement="bottom">
<t-picker
v-model="valueUnitPickerDraft"
:columns="valueUnitPickerColumns"
title="数值单位"
@cancel="valueUnitPickerVisible = false"
@confirm="confirmValueUnit"
/>
</t-popup>
<t-popup v-model="beginAtPickerVisible" placement="bottom">
<div class="picker-popup">
<t-date-time-picker
@@ -198,7 +225,8 @@ import type { ColorObject } from "tdesign-mobile-vue";
import { useUserStore, type Role } from "timi-web";
import EventAPI from "@/api/EventAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { EventItem, EventStatus, EventLimitType } from "@/types/EventItem";
import type { EventItem, EventStatus, EventLimitType, EventValueType, EventValueUnit } from "@/types/EventItem";
import { getErrorMessage } from "@/utils/error";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
@@ -239,6 +267,9 @@ const formModel = reactive({
name: "",
description: "",
color: "#0f766e",
valueType: "NONE" as EventValueType,
valueUnit: null as EventValueUnit | null,
requireValue: false,
status: "ACTIVE" as EventStatus,
requirePhoto: false,
limitType: "NONE" as EventLimitType,
@@ -400,6 +431,71 @@ function getStatusText(value: EventStatus): string {
return statusOptions.find(option => option.value === value)?.label || "可登记";
}
// ---------- 数值定义 ----------
const valueTypeOptions: Array<{ label: string; value: EventValueType }> = [
{ label: "不携带数值", value: "NONE" },
{ label: "携带数值", value: "NUMBER" }
];
const valueUnitOptions: Array<{ label: string; value: EventValueUnit }> = [
{ label: "金额(元)", value: "CENT" },
{ label: "次数", value: "COUNT" }
];
const valueTypePickerVisible = ref(false);
const valueTypePickerDraft = ref<PickerValue[]>([]);
const valueTypePickerColumns = [valueTypeOptions];
const valueUnitPickerVisible = ref(false);
const valueUnitPickerDraft = ref<PickerValue[]>([]);
const valueUnitPickerColumns = [valueUnitOptions];
function isNumberValueType(value: EventValueType): boolean {
return value === "NUMBER";
}
function getValueTypeText(value: EventValueType): string {
return valueTypeOptions.find(option => option.value === value)?.label || "不携带数值";
}
function getValueUnitText(value: EventValueUnit | null): string {
return valueUnitOptions.find(option => option.value === value)?.label || "请选择单位";
}
function openValueTypePicker(): void {
valueTypePickerDraft.value = [formModel.valueType];
valueTypePickerVisible.value = true;
}
function confirmValueType(value: Array<string | number>): void {
const valueType = value[0];
if (valueType !== "NONE" && valueType !== "NUMBER") {
return;
}
formModel.valueType = valueType;
if (valueType === "NONE") {
formModel.valueUnit = null;
formModel.requireValue = false;
} else if (!formModel.valueUnit) {
formModel.valueUnit = "CENT";
}
valueTypePickerVisible.value = false;
}
function openValueUnitPicker(): void {
if (!isNumberValueType(formModel.valueType)) {
return;
}
valueUnitPickerDraft.value = [formModel.valueUnit || "CENT"];
valueUnitPickerVisible.value = true;
}
function confirmValueUnit(value: Array<string | number>): void {
const valueUnit = value[0];
if (valueUnit === "CENT" || valueUnit === "COUNT") {
formModel.valueUnit = valueUnit;
}
valueUnitPickerVisible.value = false;
}
// ---------- 次数限制 ----------
function openLimitPicker(): void {
@@ -539,6 +635,9 @@ async function saveEventItem(): Promise<void> {
name: formModel.name.trim(),
description: formModel.description,
color: formModel.color,
valueType: formModel.valueType,
valueUnit: isNumberValueType(formModel.valueType) ? formModel.valueUnit : null,
requireValue: isNumberValueType(formModel.valueType) && formModel.requireValue,
status: formModel.status,
requirePhoto: formModel.requirePhoto,
limitType: formModel.limitType,
@@ -554,8 +653,8 @@ async function saveEventItem(): Promise<void> {
}
Toast.success("登记事件已保存");
await router.replace("/management/registration-events");
} catch {
Toast.error("登记事件保存失败");
} catch (error) {
Toast.error(getErrorMessage(error, "登记事件保存失败"));
} finally {
isSaving.value = false;
}
@@ -629,6 +728,9 @@ async function fillForm(): Promise<void> {
name: current.name || "",
description: current.description || "",
color: current.color || "#2f88ff",
valueType: current.valueType || "NONE",
valueUnit: current.valueUnit || null,
requireValue: current.requireValue ?? false,
status: current.status === "DRAFT" ? "DRAFT" : current.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
requirePhoto: current.requirePhoto ?? false,
limitType: current.limitType || "NONE",
+10
View File
@@ -57,6 +57,7 @@
<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>
</template>
</t-cell>
@@ -81,6 +82,7 @@ import ManagedUserAPI from "@/api/ManagedUserAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem, EventLimitType, EventStatus } from "@/types/EventItem";
import { getEventValueUnitText } from "@/utils/eventValue";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
@@ -199,6 +201,14 @@ function getLimitText(item: EventItem): string {
return limitTextMap[item.limitType || "NONE"];
}
function getValueDefinitionText(item: EventItem): string {
if (item.valueType !== "NUMBER") {
return "不携带数值";
}
const unitText = getEventValueUnitText(item.valueUnit);
return `整数数值|${unitText}${item.requireValue ? "|登记必填" : "|登记可选"}`;
}
function resolveLimitValue(value?: number): number {
if (value !== undefined && Number.isFinite(value) && 0 < value) {
return Math.floor(value);
+8
View File
@@ -8,6 +8,9 @@
/>
<t-cell-group v-if="record" title="登记信息" theme="card">
<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.pointValue !== undefined && record.pointValue !== null" title="提交积分值" :note="`${record.pointValue} 分`" />
<t-cell v-if="record.earnedPointValue !== undefined && record.earnedPointValue !== null" title="本次获得积分" :note="`${record.earnedPointValue} 分`" />
<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.customer?.code)" />
@@ -33,6 +36,7 @@ import PhotoScroller from "@/components/PhotoScroller.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventRecord } from "@/types/EventRecord";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
defineOptions({
@@ -55,6 +59,10 @@ const recordPhotoList = computed(() => {
return url ? [url] : [];
});
function getNumberValueText(value: EventRecord): string {
return formatEventNumberValue(value.event, value.numberValue);
}
// ---------- 页面数据 ----------
async function loadRecord(): Promise<void> {
+78
View File
@@ -50,6 +50,23 @@
:disabled="isEditMode"
required-mark
/>
<div v-if="!isEditMode && selectedNumberEventList.length" class="event-value-list">
<t-form-item
v-for="item in selectedNumberEventList"
:key="item.id"
:label="`${item.name || '未命名事件'}数值`"
:required-mark="item.requireValue === true"
label-align="top"
>
<t-input
v-model.trim="numberValueTextMap[item.id || '']"
type="number"
align="right"
borderless
:placeholder="getNumberValuePlaceholder(item)"
/>
</t-form-item>
</div>
<div v-if="isEditMode" class="edit-point-tip">已产生积分的登记记录不会因修改客户、事件或时间而重算;如需修改这些事实,请删除后重新创建。</div>
<t-form-item v-if="showSourceValueInput" label="自定义积分" required-mark>
<t-input
@@ -182,6 +199,7 @@ import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
import { loadSourceValueRange } from "@/utils/point";
import { compressUploadImageList } from "@/utils/imageCompression";
@@ -372,6 +390,10 @@ function handleScanStatusChange(value: string): void {
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const numberValueTextMap = reactive<Record<string, string>>({});
const selectedNumberEventList = computed(() => selectedEventIdList.value
.map(eventId => eventList.value.find(item => item.id === eventId))
.filter((item): item is EventItem => item?.valueType === "NUMBER"));
const eventNameMap = computed(() => {
const map = new Map<string, string>();
@@ -391,6 +413,46 @@ function appendRecordEvent(record: EventRecord): void {
eventList.value = [record.event, ...eventList.value];
}
function getNumberValuePlaceholder(event: EventItem): string {
return getEventNumberValuePlaceholder(event);
}
function resolveNumberValue(eventId: string): number | undefined {
const valueText = numberValueTextMap[eventId] || "";
if (!valueText.trim()) {
return undefined;
}
const event = eventList.value.find(item => item.id === eventId);
return event ? resolveEventNumberValue(event, valueText) : undefined;
}
function validateNumberValueList(): boolean {
for (const eventId of selectedEventIdList.value) {
const event = eventList.value.find(item => item.id === eventId);
if (!event) {
continue;
}
const valueText = numberValueTextMap[eventId] || "";
if (event.valueType !== "NUMBER") {
if (valueText.trim()) {
Toast.warning(`${event.name || "当前事件"}不能提交数值`);
return false;
}
continue;
}
const numberValue = resolveNumberValue(eventId);
if (valueText.trim() && numberValue === undefined) {
Toast.warning(`${event.name || "当前事件"}数值格式不正确,请检查单位和小数位`);
return false;
}
if (event.requireValue && numberValue === undefined) {
Toast.warning(`请填写${event.name || "当前事件"}数值`);
return false;
}
}
return true;
}
// ---------- 积分规则联动 ----------
const pointValueText = ref("");
@@ -533,6 +595,7 @@ async function submitRecord(): Promise<void> {
}
const customerIdList = selectedCustomerList.value.map(customer => customer.id).filter((item): item is string => !!item);
// 多个事件按选择顺序逐条提交,避免把有累计积分顺序依赖的事件混入同一批次。
for (const eventId of selectedEventIdList.value) {
await EventRecordAPI.create({
eventId,
@@ -540,6 +603,7 @@ async function submitRecord(): Promise<void> {
remark: note.value || "客户现场登记",
registeredAt: buildRegisteredAt(),
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
numberValue: resolveNumberValue(eventId),
attachmentList: buildTempFileIdList().map(tempFileId => ({
tempFileId,
attachType: "PHOTO"
@@ -551,6 +615,9 @@ async function submitRecord(): Promise<void> {
note.value = "";
pointValueText.value = "";
photoItemList.value = [];
for (const eventId of Object.keys(numberValueTextMap)) {
delete numberValueTextMap[eventId];
}
await loadRecentRecordList();
await settingStore.playRegisterSuccessSound();
Toast.success("登记已提交");
@@ -569,6 +636,9 @@ function validateForm(): boolean {
Toast.warning("请至少选择一个登记事件");
return false;
}
if (!isEditMode.value && !validateNumberValueList()) {
return false;
}
if (showSourceValueInput.value) {
const pointValue = resolveSourceValue();
const range = sourceValueRange.value;
@@ -710,6 +780,14 @@ watch(customerCode, async (value) => {
margin-top: 1rem;
}
.event-value-list {
padding: 0 1rem;
.t-form-item {
padding: 0;
}
}
.time-actions {
gap: .5rem;
display: flex;
+73
View File
@@ -65,6 +65,23 @@
:event-list="eventList"
/>
</t-form-item>
<div v-if="selectedNumberEventList.length" class="event-value-list">
<t-form-item
v-for="item in selectedNumberEventList"
:key="item.id"
:label="`${item.name || '未命名事件'}数值`"
:required-mark="item.requireValue === true"
label-align="top"
>
<t-input
v-model.trim="numberValueTextMap[item.id || '']"
type="number"
align="right"
borderless
:placeholder="getNumberValuePlaceholder(item)"
/>
</t-form-item>
</div>
<div v-if="showSourceValueInput" class="source-value-panel">
<div class="source-value-title">自定义积分</div>
<t-input
@@ -130,6 +147,7 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import { getCustomerPhotoURL } from "@/utils/customer";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
import { loadSourceValueRange } from "@/utils/point";
import { compressUploadImage, compressUploadImageList } from "@/utils/imageCompression";
@@ -148,6 +166,10 @@ const userStore = useUserStore();
const eventList = ref<EventItem[]>([]);
const selectedEventIdList = ref<string[]>([]);
const numberValueTextMap = reactive<Record<string, string>>({});
const selectedNumberEventList = computed(() => selectedEventIdList.value
.map(eventId => eventList.value.find(item => item.id === eventId))
.filter((item): item is EventItem => item?.valueType === "NUMBER"));
const pointValueText = ref("");
const sourceValueRange = ref<[number, number]>();
const showSourceValueInput = computed(() => (
@@ -176,6 +198,46 @@ function resolveSourceValue(): number | undefined {
return Number.isSafeInteger(value) && 0 < value ? value : undefined;
}
function getNumberValuePlaceholder(event: EventItem): string {
return getEventNumberValuePlaceholder(event);
}
function resolveNumberValue(eventId: string): number | undefined {
const valueText = numberValueTextMap[eventId] || "";
if (!valueText.trim()) {
return undefined;
}
const event = eventList.value.find(item => item.id === eventId);
return event ? resolveEventNumberValue(event, valueText) : undefined;
}
function validateNumberValueList(): boolean {
for (const eventId of selectedEventIdList.value) {
const event = eventList.value.find(item => item.id === eventId);
if (!event) {
continue;
}
const valueText = numberValueTextMap[eventId] || "";
if (event.valueType !== "NUMBER") {
if (valueText.trim()) {
showToast(`${event.name || "当前事件"}不能提交数值`, "warning");
return false;
}
continue;
}
const numberValue = resolveNumberValue(eventId);
if (valueText.trim() && numberValue === undefined) {
showToast(`${event.name || "当前事件"}数值格式不正确,请检查单位和小数位`, "warning");
return false;
}
if (event.requireValue && numberValue === undefined) {
showToast(`请填写${event.name || "当前事件"}数值`, "warning");
return false;
}
}
return true;
}
const note = ref("");
const saving = ref(false);
const canSubmit = computed(() => !!inputCode.value && !!customerName.value.trim() && !customerPhotoUploading.value && !recordPhotoUploading.value);
@@ -508,6 +570,7 @@ async function saveRecord(): Promise<void> {
name: customerName.value.trim(),
eventRecordList: selectedEventIdList.value.map(eventId => ({
eventId,
numberValue: resolveNumberValue(eventId),
pointValue: showSourceValueInput.value ? resolveSourceValue() : undefined,
remark: note.value || "快速补录登记"
}))
@@ -556,6 +619,9 @@ function resetFormAfterSubmit(): void {
customerPhotoTempFileId.value = "";
recordPhotoItemList.value = [];
pointValueText.value = "";
for (const eventId of Object.keys(numberValueTextMap)) {
delete numberValueTextMap[eventId];
}
refreshInputTips();
}
@@ -569,6 +635,9 @@ function validateForm(): string {
showToast("请填写客户姓名", "warning");
return "";
}
if (!validateNumberValueList()) {
return "";
}
if (showSourceValueInput.value) {
const pointValue = resolveSourceValue();
const range = sourceValueRange.value;
@@ -628,6 +697,10 @@ watch(scanCode, (value) => {
width: 100%;
}
.event-value-list {
padding: 0 1rem;
}
.source-value-panel {
padding: .75rem 1rem 1rem;
+34
View File
@@ -17,6 +17,16 @@
v-if="matchedCustomer"
:customer="matchedCustomer"
/>
<div v-if="showNumberValueInput" class="number-value-panel">
<div class="number-value-title" v-text="`${selectedEvent?.name || '事件'}数值`" />
<t-input
v-model.trim="numberValueText"
type="number"
align="right"
borderless
:placeholder="numberValuePlaceholder"
/>
</div>
<div v-if="showSourceValueInput" class="source-value-panel">
<div class="source-value-title">自定义积分</div>
<t-input
@@ -89,6 +99,7 @@ import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord";
import { getEventNumberValuePlaceholder, resolveEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
import { getEventRecordPhotoURL } from "@/utils/eventRecord";
import { Storage, Time, useUserStore } from "timi-web";
@@ -108,6 +119,7 @@ const EventItemStorageKey = "quick-scan-event-id";
const eventSelectorVisible = ref(false);
const selectedEventId = ref("");
const eventList = ref<EventItem[]>([]);
const selectedEvent = computed(() => eventList.value.find(item => item.id === selectedEventId.value));
const selectedEventName = computed(() => eventList.value.find(item => item.id === selectedEventId.value)?.name || "请选择登记事件");
async function loadActions(): Promise<void> {
@@ -126,6 +138,7 @@ async function selectEvent(eventId?: string): Promise<void> {
return;
}
selectedEventId.value = eventId;
numberValueText.value = "";
Storage.setString(EventItemStorageKey, selectedEventId);
eventSelectorVisible.value = false;
await loadSourceValueRule();
@@ -192,8 +205,11 @@ async function toRecordDetail(id: string): Promise<void> {
const submitting = ref(false);
const pointValueText = ref("");
const numberValueText = ref("");
const sourceValueRange = ref<[number, number]>();
const canSubmitRegister = computed(() => !!matchedCustomer.value && !!selectedEventId.value);
const showNumberValueInput = computed(() => selectedEvent.value?.valueType === "NUMBER");
const numberValuePlaceholder = computed(() => selectedEvent.value ? getEventNumberValuePlaceholder(selectedEvent.value) : "请输入事件数值");
const showSourceValueInput = computed(() => (
userStore.hasPermissions(permissionCodes.point.sourceValue)
&& !!sourceValueRange.value
@@ -220,12 +236,25 @@ function resolveSourceValue(): number | undefined {
return Number.isSafeInteger(value) && 0 < value ? value : undefined;
}
function resolveNumberValue(): number | undefined {
return selectedEvent.value ? resolveEventNumberValue(selectedEvent.value, numberValueText.value) : undefined;
}
async function doSubmit(): Promise<void> {
const customer = matchedCustomer.value;
if (!customer) {
return;
}
const pointValue = resolveSourceValue();
const numberValue = resolveNumberValue();
if (showNumberValueInput.value && numberValueText.value.trim() && numberValue === undefined) {
Toast.warning("事件数值格式不正确,请检查单位和小数位");
return;
}
if (selectedEvent.value?.requireValue && numberValue === undefined) {
Toast.warning("请填写事件数值");
return;
}
const range = sourceValueRange.value;
if (showSourceValueInput.value && (!pointValue || !range || pointValue < range[0] || range[1] < pointValue)) {
Toast.warning(`自定义积分必须是 ${range?.[0] || 1}-${range?.[1] || 0} 的正整数`);
@@ -237,12 +266,14 @@ async function doSubmit(): Promise<void> {
customerId: customer.id,
eventId: selectedEventId.value,
pointValue: showSourceValueInput.value ? pointValue : undefined,
numberValue,
remark: "客户快速扫码登记"
});
scanStatus.value = `登记成功:${customer.name || customer.code || scanCode.value}。`;
scanCode.value = "";
matchedCustomer.value = undefined;
pointValueText.value = "";
numberValueText.value = "";
recentRecordList.value = [];
latestDecodedText = "";
scannerSessionKey.value += 1;
@@ -300,12 +331,14 @@ async function doSubmit(): Promise<void> {
}
}
.number-value-panel,
.source-value-panel {
padding: .75rem 1rem;
background: rgba(255, 255, 255, .94);
box-shadow: 0 .75rem 2rem rgba(15, 23, 42, .08);
border-radius: .875rem;
.number-value-title,
.source-value-title {
font-weight: 700;
}
@@ -405,6 +438,7 @@ async function doSubmit(): Promise<void> {
}
}
.number-value-panel,
.source-value-panel {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
+182 -14
View File
@@ -1,6 +1,6 @@
<template>
<div v-can="pageAccess" class="point-rule-edit">
<t-form class="form" label-align="left" label-width="6rem">
<t-form class="form" label-align="left" label-width="7rem">
<card-container>
<t-form-item v-if="isGlobalManager" label="门店" required-mark :arrow="isCreateMode" @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
@@ -22,7 +22,7 @@
<t-form-item label="触发类型" arrow @click="openTriggerPicker">
<span class="picker-text" v-text="getTriggerText(formModel.triggerType)" />
</t-form-item>
<t-form-item label="积分值模式" arrow @click="openModePicker">
<t-form-item label="积分值模式" :arrow="formModel.triggerType !== 'CUSTOMER_EVENT_VALUE_TIER'" @click="openModePicker">
<span class="picker-text" v-text="getModeText(formModel.pointMode)" />
</t-form-item>
<t-form-item v-if="formModel.pointMode === 'FIXED'" label="固定积分" required-mark>
@@ -52,6 +52,15 @@
</div>
<span class="tips">登记时只有输入在此范围内的正整数,系统才会按本规则发放积分</span>
</div>
<template v-if="formModel.triggerType === 'CUSTOMER_EVENT_VALUE_TIER'" class="tier-condition">
<t-form-item label="来源事件" required-mark arrow @click="openSourceEventPicker">
<span class="picker-text" v-text="selectedSourceEventName" />
</t-form-item>
<t-form-item label="累计窗口(年)" label-width="8rem">
<t-input v-model.trim="formModel.windowAmountText" type="number" align="right" borderless />
</t-form-item>
<point-rule-tier-editor v-model="formModel.tiers" />
</template>
</card-container>
<card-container>
<t-form-item label="生效开始" arrow @click="openBeginAtPicker">
@@ -117,6 +126,15 @@
@confirm="confirmEventPicker"
/>
</t-popup>
<t-popup v-model="sourceEventPickerVisible" placement="bottom">
<t-picker
v-model="sourceEventPickerDraft"
:columns="sourceEventPickerColumns"
title="选择来源事件"
@cancel="sourceEventPickerVisible = false"
@confirm="confirmSourceEventPicker"
/>
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker
v-model="statusPickerDraft"
@@ -173,11 +191,13 @@ import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import { Time, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import PointAPI from "@/api/PointAPI";
import PointRuleTierEditor from "@/pages/point/component/PointRuleTierEditor.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { EventItem } from "@/types/EventItem";
import type { PointRule, PointRuleMode, PointRuleStatus, PointRuleTriggerType } from "@/types/Point";
import type { PointRule, PointRuleMode, PointRuleStatus, PointRuleTier, PointRuleTierForm, PointRuleTriggerType } from "@/types/Point";
import { getErrorMessage } from "@/utils/error";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
import { getTierMinValue, isLastTier, resolveNonNegativeInteger } from "@/utils/pointRule";
type PickerValue = string | number;
@@ -214,6 +234,9 @@ const formModel = reactive({
thresholdText: "",
minText: "",
maxText: "",
sourceEventId: "",
windowAmountText: "1",
tiers: [] as PointRuleTierForm[],
beginAtText: "",
endAtText: "",
priorityText: "0",
@@ -240,6 +263,29 @@ const eventPickerColumns = computed(() => [
.map(item => ({ label: item.name || "未命名事件", value: item.id || "" }))
.filter(item => item.value)
]);
const sourceEventPickerVisible = ref(false);
const sourceEventPickerDraft = ref<PickerValue[]>([]);
const sourceEventList = computed(() => eventList.value.filter(item => item.valueType === "NUMBER" && item.id));
const sourceEventPickerColumns = computed(() => [
sourceEventList.value
.map(item => ({ label: item.name || "未命名事件", value: item.id || "" }))
.filter(item => item.value)
]);
const selectedSourceEventName = computed(() => sourceEventList.value.find(item => item.id === formModel.sourceEventId)?.name || (formModel.sourceEventId ? `事件 ${formModel.sourceEventId}` : "请选择来源事件"));
let tierKey = 0;
function createTierForm(tier?: PointRuleTier, isLast = false): PointRuleTierForm {
return {
key: tierKey++,
maxText: !isLast && tier?.max !== undefined && tier.max !== null ? String(tier.max) : "",
pointValueText: tier ? String(tier.pointValue) : "0"
};
}
if (!formModel.tiers.length) {
formModel.tiers.push(createTierForm(undefined, true));
}
function openStorePicker(): void {
if (!isCreateMode.value || !storePickerColumns.value[0]?.length) {
@@ -281,6 +327,26 @@ function confirmEventPicker(value: Array<string | number>): void {
eventPickerVisible.value = false;
}
function openSourceEventPicker(): void {
if (formModel.triggerType !== "CUSTOMER_EVENT_VALUE_TIER") {
return;
}
if (!sourceEventPickerColumns.value[0]?.length) {
Toast.warning("暂无可用的 NUMBER 来源事件");
return;
}
sourceEventPickerDraft.value = [formModel.sourceEventId];
sourceEventPickerVisible.value = true;
}
function confirmSourceEventPicker(value: Array<string | number>): void {
const sourceEventId = String(value[0] || "");
if (sourceEventId) {
formModel.sourceEventId = sourceEventId;
}
sourceEventPickerVisible.value = false;
}
async function prepareStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
@@ -314,12 +380,18 @@ const triggerOptions: Array<{ label: string; value: PointRuleTriggerType }> = [
{ label: "每次登记", value: "EVERY_TIME" },
{ label: "首次登记", value: "FIRST_TIME" },
{ label: "达到阈值后每次登记", value: "AFTER_COUNT_EVERY_TIME" },
{ label: "登记时自定义积分", value: "SOURCE_VALUE" }
];
const modeOptions: Array<{ label: string; value: PointRuleMode }> = [
{ label: "固定积分", value: "FIXED" },
{ label: "使用登记时填写的积分", value: "SOURCE_VALUE" }
{ label: "登记时自定义积分", value: "SOURCE_VALUE" },
{ label: "按来源事件累计分档", value: "CUSTOMER_EVENT_VALUE_TIER" }
];
const modeOptions = computed<Array<{ label: string; value: PointRuleMode }>>(() => {
if (formModel.triggerType === "CUSTOMER_EVENT_VALUE_TIER") {
return [{ label: "分档积分", value: "TIER" }];
}
if (formModel.triggerType === "SOURCE_VALUE") {
return [{ label: "使用登记时填写的积分", value: "SOURCE_VALUE" }];
}
return [{ label: "固定积分", value: "FIXED" }];
});
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<PickerValue[]>([]);
const statusPickerColumns = [statusOptions];
@@ -328,7 +400,7 @@ const triggerPickerDraft = ref<PickerValue[]>([]);
const triggerPickerColumns = [triggerOptions];
const modePickerVisible = ref(false);
const modePickerDraft = ref<PickerValue[]>([]);
const modePickerColumns = [modeOptions];
const modePickerColumns = computed(() => [modeOptions.value]);
function openStatusPicker(): void {
statusPickerDraft.value = [formModel.status];
@@ -352,21 +424,31 @@ function confirmTriggerPicker(value: Array<string | number>): void {
const triggerType = value[0];
if (typeof triggerType === "string" && triggerOptions.some(item => item.value === triggerType)) {
formModel.triggerType = triggerType as PointRuleTriggerType;
formModel.pointMode = triggerType === "SOURCE_VALUE" ? "SOURCE_VALUE" : "FIXED";
formModel.pointMode = triggerType === "SOURCE_VALUE" ? "SOURCE_VALUE" : triggerType === "CUSTOMER_EVENT_VALUE_TIER" ? "TIER" : "FIXED";
}
triggerPickerVisible.value = false;
}
function openModePicker(): void {
if (formModel.triggerType === "CUSTOMER_EVENT_VALUE_TIER") {
return;
}
modePickerDraft.value = [formModel.pointMode];
modePickerVisible.value = true;
}
function confirmModePicker(value: Array<string | number>): void {
const pointMode = value[0];
if (pointMode === "FIXED" || pointMode === "SOURCE_VALUE") {
if (pointMode === "FIXED" || pointMode === "SOURCE_VALUE" || pointMode === "TIER") {
if (!modeOptions.value.some(item => item.value === pointMode)) {
return;
}
formModel.pointMode = pointMode;
formModel.triggerType = pointMode === "SOURCE_VALUE" ? "SOURCE_VALUE" : formModel.triggerType === "SOURCE_VALUE" ? "EVERY_TIME" : formModel.triggerType;
formModel.triggerType = pointMode === "SOURCE_VALUE"
? "SOURCE_VALUE"
: pointMode === "TIER"
? "CUSTOMER_EVENT_VALUE_TIER"
: formModel.triggerType === "SOURCE_VALUE" || formModel.triggerType === "CUSTOMER_EVENT_VALUE_TIER" ? "EVERY_TIME" : formModel.triggerType;
}
modePickerVisible.value = false;
}
@@ -380,7 +462,7 @@ function getTriggerText(triggerType: PointRuleTriggerType): string {
}
function getModeText(pointMode: PointRuleMode): string {
return modeOptions.find(item => item.value === pointMode)?.label || "固定积分";
return modeOptions.value.find(item => item.value === pointMode)?.label || "固定积分";
}
// ---------- 生效时间 ----------
@@ -438,6 +520,27 @@ function resolveTimestamp(value: string): number | undefined {
}
function buildCondition(): PointRule["conditionJson"] {
if (formModel.triggerType === "CUSTOMER_EVENT_VALUE_TIER") {
return {
sourceEventId: formModel.sourceEventId,
sourceValueField: "numberValue",
window: {
type: "ROLLING",
amount: resolvePositiveInteger(formModel.windowAmountText) || 1,
unit: "YEAR"
},
tiers: formModel.tiers.map((tier, index) => {
const result: PointRuleTier = {
min: getTierMinValue(formModel.tiers, index) || 0,
pointValue: resolveNonNegativeInteger(tier.pointValueText) || 0
};
if (!isLastTier(formModel.tiers, index)) {
result.max = resolveNonNegativeInteger(tier.maxText);
}
return result;
})
};
}
if (formModel.pointMode === "SOURCE_VALUE") {
return {
min: resolvePositiveInteger(formModel.minText),
@@ -470,7 +573,44 @@ function validateForm(): boolean {
Toast.warning("请输入规则名称");
return false;
}
if (formModel.pointMode === "FIXED" && !resolvePositiveInteger(formModel.pointValueText)) {
if (formModel.triggerType === "CUSTOMER_EVENT_VALUE_TIER") {
if (formModel.pointMode !== "TIER") {
Toast.warning("分档规则必须使用分档积分模式");
return false;
}
if (!sourceEventList.value.some(item => item.id === formModel.sourceEventId)) {
Toast.warning("请选择 NUMBER 类型的来源事件");
return false;
}
if (!resolvePositiveInteger(formModel.windowAmountText)) {
Toast.warning("累计窗口必须是正整数");
return false;
}
if (!formModel.tiers.length || !(formModel.tiers.length < 100)) {
Toast.warning("分档数量必须是 1-100 档");
return false;
}
for (let i = 0; i < formModel.tiers.length; i++) {
const tier = formModel.tiers[i];
const min = getTierMinValue(formModel.tiers, i);
const pointValue = resolveNonNegativeInteger(tier.pointValueText);
if (min === undefined) {
Toast.warning(`第 ${i + 1} 档的上一档最大值无效`);
return false;
}
if (pointValue === undefined) {
Toast.warning(`第 ${i + 1} 档积分必须是大于等于 0 的整数`);
return false;
}
if (!isLastTier(formModel.tiers, i)) {
const max = resolveNonNegativeInteger(tier.maxText);
if (max === undefined || max < min) {
Toast.warning(`第 ${i + 1} 档最大值必须是不小于最小值的整数`);
return false;
}
}
}
} else if (formModel.pointMode === "FIXED" && !resolvePositiveInteger(formModel.pointValueText)) {
Toast.warning("固定积分必须是正整数");
return false;
}
@@ -591,6 +731,11 @@ async function fillForm(): Promise<void> {
thresholdText: current.conditionJson?.threshold === undefined ? "" : String(current.conditionJson.threshold),
minText: current.conditionJson?.min === undefined ? "" : String(current.conditionJson.min),
maxText: current.conditionJson?.max === undefined ? "" : String(current.conditionJson.max),
sourceEventId: current.conditionJson?.sourceEventId || "",
windowAmountText: current.conditionJson?.window?.amount === undefined ? "1" : String(current.conditionJson.window.amount),
tiers: current.conditionJson?.tiers?.length
? current.conditionJson.tiers.map((tier, index, list) => createTierForm(tier, index === list.length - 1))
: [createTierForm(undefined, true)],
beginAtText: current.beginAt ? Time.toDateTime(current.beginAt) : "",
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
priorityText: String(current.priority ?? 0),
@@ -664,6 +809,28 @@ onMounted(async () => {
}
}
.tier-condition {
padding: .75rem 1rem 1rem;
.window-row {
gap: .5rem;
display: flex;
align-items: center;
padding: .5rem 0;
.t-input {
flex: 1;
border: 1px solid var(--td-component-border);
padding: .25rem .5rem;
border-radius: .5rem;
}
}
.window-label {
font-weight: 700;
}
}
.tips {
color: #52607a;
font-size: .75rem;
@@ -704,6 +871,7 @@ onMounted(async () => {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
}
}
+10 -2
View File
@@ -36,7 +36,7 @@
<template #left-icon>
<div class="point">
<t-icon name="money" />
<span v-text="`${item.pointValue ? item.pointValue : '?'}`" />
<span v-text="getPointText(item)" />
</div>
</template>
<template #title>
@@ -232,11 +232,19 @@ function getTriggerText(rule: PointRule): string {
EVERY_TIME: "每次登记",
FIRST_TIME: "首次登记",
AFTER_COUNT_EVERY_TIME: `达到 ${rule.conditionJson?.threshold ?? 0} 次后每次登记`,
SOURCE_VALUE: "登记时自定义积分"
SOURCE_VALUE: "登记时自定义积分",
CUSTOMER_EVENT_VALUE_TIER: `按 ${rule.conditionJson?.window?.amount ?? 1} 年累计分档`
};
return map[rule.triggerType || ""] || "未知触发类型";
}
function getPointText(rule: PointRule): string {
if (rule.pointMode === "TIER") {
return "分档";
}
return rule.pointValue === undefined || rule.pointValue === null ? "?" : String(rule.pointValue);
}
function getStatusText(status?: PointRuleStatus): string {
return ({
DRAFT: "草稿",
@@ -0,0 +1,293 @@
<template>
<div class="tier-editor">
<div class="title">
<div>累计数值分档</div>
<div class="tips gray">首档从 0 开始,末档不设上限;积分为 0 不产生流水</div>
</div>
<div class="list">
<div
v-for="(tier, index) in tiers"
:key="tier.key"
class="card"
@click="openTierEditor(index)"
>
<div class="summary">
<span class="index" v-text="`第 ${index + 1} 档`" />
<strong v-text="getTierRangeText(index)" />
</div>
<div class="points text-bold" v-text="getTierPointText(tier)" />
<t-button
v-if="1 < tiers.length"
theme="danger"
variant="text"
shape="square"
@click.stop="removeTier(index)"
>
<t-icon name="close" />
</t-button>
</div>
</div>
<t-button class="add-tier" variant="outline" size="small" content="增加分档" @click="addTier" />
</div>
<bottom-popup
v-model="tierEditorVisible"
placement="bottom"
:title="tierEditorTitle"
@left-button-click="closeTierEditor"
@right-button-click="confirmTierEditor"
>
<div class="tier-editor-popup">
<t-form v-if="tierEditorIndex >= 0" class="form" label-align="left" label-width="10rem">
<t-form-item label="最小值" content-align="right">
<span class="gray" v-text="getTierMinText(tiers, tierEditorIndex)" />
</t-form-item>
<t-form-item v-if="!isLastTier(tiers, tierEditorIndex) || tierEditorAdding" :label="tierEditorAdding ? '当前档最大值' : '最大值'" required-mark>
<t-input
v-model.trim="tierEditorDraft.maxText"
type="number"
align="right"
borderless
placeholder="请输入最大值"
/>
</t-form-item>
<t-form-item label="积分" required-mark>
<t-input
v-model.trim="tierEditorDraft.pointValueText"
type="number"
align="right"
borderless
placeholder="请输入积分,可填写 0"
/>
</t-form-item>
<div class="tips">积分为 0 时规则仍会命中,但系统不会创建积分流水。</div>
</t-form>
</div>
</bottom-popup>
</template>
<script setup lang="ts">
import type { PointRuleTierForm } from "@/types/Point";
import { getTierMinText, getTierMinValue, isLastTier, resolveNonNegativeInteger } from "@/utils/pointRule";
import { Toast } from "tdesign-mobile-vue";
defineOptions({
name: "PointRuleTierEditor"
});
const tiers = defineModel<PointRuleTierForm[]>({ required: true });
const tierEditorVisible = ref(false);
const tierEditorIndex = ref(-1);
const tierEditorAdding = ref(false);
const tierEditorDraft = reactive({
maxText: "",
pointValueText: ""
});
const tierEditorTitle = computed(() => {
if (tierEditorIndex.value < 0) {
return "编辑分档";
}
return tierEditorAdding.value ? `编辑第 ${tierEditorIndex.value + 1} 档并新增下一档` : `编辑第 ${tierEditorIndex.value + 1} 档`;
});
function getTierRangeText(index: number): string {
const tier = tiers.value[index];
if (!tier) {
return "待填写";
}
const minText = getTierMinText(tiers.value, index);
if (isLastTier(tiers.value, index)) {
return `${minText} 及以上`;
}
const max = resolveNonNegativeInteger(tier.maxText);
return max === undefined ? `${minText} - 待填写` : `${minText}-${max}`;
}
function getTierPointText(tier: PointRuleTierForm): string {
const pointValue = resolveNonNegativeInteger(tier.pointValueText);
if (pointValue === undefined) {
return "待填写";
}
return `+${pointValue} 积分`;
}
function openTierEditor(index: number, adding = false): void {
const tier = tiers.value[index];
if (!tier) {
return;
}
tierEditorIndex.value = index;
tierEditorAdding.value = adding;
tierEditorDraft.maxText = adding ? "" : tier.maxText;
tierEditorDraft.pointValueText = tier.pointValueText;
tierEditorVisible.value = true;
}
function closeTierEditor(): void {
tierEditorAdding.value = false;
tierEditorVisible.value = false;
}
function confirmTierEditor(): void {
const index = tierEditorIndex.value;
const tier = tiers.value[index];
if (!tier) {
return;
}
const min = getTierMinValue(tiers.value, index);
const pointValue = resolveNonNegativeInteger(tierEditorDraft.pointValueText);
if (min === undefined) {
Toast.warning("请先修正上一档的最大值");
return;
}
if (pointValue === undefined) {
Toast.warning("积分必须是大于等于 0 的整数,允许填写 0");
return;
}
const shouldAddTier = tierEditorAdding.value && isLastTier(tiers.value, index);
if (!isLastTier(tiers.value, index) || shouldAddTier) {
const max = resolveNonNegativeInteger(tierEditorDraft.maxText);
if (max === undefined || max < min) {
Toast.warning("当前档最大值必须是不小于最小值的整数");
return;
}
tier.maxText = String(max);
} else {
tier.maxText = "";
}
tier.pointValueText = String(pointValue);
if (shouldAddTier) {
tiers.value.push(createTierForm());
}
closeTierEditor();
}
function addTier(): void {
if (!(tiers.value.length < 100)) {
Toast.warning("最多配置 100 档");
return;
}
const lastTierIndex = tiers.value.length - 1;
if (!(lastTierIndex < 0)) {
openTierEditor(lastTierIndex, true);
return;
}
tiers.value.push(createTierForm());
}
function removeTier(index: number): void {
if (!(1 < tiers.value.length)) {
return;
}
tiers.value.splice(index, 1);
}
function createTierForm(): PointRuleTierForm {
const key = tiers.value.reduce((max, tier) => Math.max(max, tier.key), -1) + 1;
return {
key,
maxText: "",
pointValueText: "0"
};
}
</script>
<style scoped lang="less">
.tier-editor {
padding: 1rem;
.tips {
font-size: .75rem;
margin-top: .25rem;
}
.list {
gap: .5rem;
display: grid;
margin-top: .5rem;
}
.card {
padding: .5rem .75rem;
display: flex;
align-items: center;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: .75rem;
.summary {
gap: .25rem;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
strong {
font-size: .9375rem;
}
.index {
color: var(--td-brand-color);
font-size: .75rem;
}
}
.points {
gap: .25rem;
color: var(--td-brand-color);
display: flex;
font-size: 1.5rem;
align-items: baseline;
white-space: nowrap;
}
}
.add-tier {
width: 100%;
margin-top: .75rem;
}
}
.tier-editor-popup {
.form {
padding: .5rem 0 1rem 0;
.tips {
color: #52607a;
padding: 0 1rem;
font-size: .75rem;
line-height: 1.5;
}
}
}
:global(html.theme-dark) {
.tier-editor {
.tips,
.points span {
color: #94a3b8;
}
.card {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.tier-editor-popup {
background: #0f172a;
:deep(.t-input) {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
.form {
.picker-text,
.tips {
color: #94a3b8;
}
}
}
}
}
</style>