update point tier rule
This commit is contained in:
@@ -227,6 +227,7 @@ function clearValue(): void {
|
||||
.mode {
|
||||
gap: .5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
|
||||
@@ -44,9 +44,9 @@ interface Props {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
leftButtonText: "",
|
||||
rightButtonText: "",
|
||||
height: "50vh",
|
||||
leftButtonText: "取消",
|
||||
rightButtonText: "确定",
|
||||
height: "auto",
|
||||
title: ""
|
||||
});
|
||||
|
||||
@@ -89,18 +89,18 @@ defineSlots<{
|
||||
<style scoped lang="less">
|
||||
.bottom-popup {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--td-bg-color-container, #FFF);
|
||||
padding-top: 1rem;
|
||||
background: var(--td-bg-color-container, #fff);
|
||||
border-radius: 1.25rem 1.25rem 0 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
|
||||
.header {
|
||||
flex: none;
|
||||
padding: 0 1rem;
|
||||
display: grid;
|
||||
grid-template-columns: 4rem 1fr 4rem;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
grid-template-columns: 4rem 1fr 4rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
@@ -122,9 +122,9 @@ defineSlots<{
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: .75rem 1rem env(safe-area-inset-bottom, 0);
|
||||
padding: .75rem 0 env(safe-area-inset-bottom, 0);
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -2,6 +2,9 @@ import type { Model } from "timi-web";
|
||||
|
||||
export type EventStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED";
|
||||
|
||||
export type EventValueType = "NONE" | "NUMBER";
|
||||
export type EventValueUnit = "CENT" | "COUNT";
|
||||
|
||||
export type EventLimitType = "NONE" | "DAILY_HALF_DAY" | "NATURAL_DAY" | "INTERVAL_HOUR" | "NATURAL_MONTH" | "TOTAL_LIMIT" | "CUSTOMER_TOTAL_LIMIT";
|
||||
|
||||
export type EventItem = {
|
||||
@@ -9,6 +12,9 @@ export type EventItem = {
|
||||
name: string;
|
||||
description?: string;
|
||||
color?: string;
|
||||
valueType?: EventValueType;
|
||||
valueUnit?: EventValueUnit | null;
|
||||
requireValue?: boolean;
|
||||
status?: EventStatus;
|
||||
requirePhoto?: boolean;
|
||||
limitType?: EventLimitType;
|
||||
|
||||
@@ -7,7 +7,9 @@ export type EventRecord = {
|
||||
customerId?: string;
|
||||
customerIdList?: string[];
|
||||
eventId?: string;
|
||||
pointValue?: number;
|
||||
pointValue?: number | null;
|
||||
numberValue?: number | null;
|
||||
earnedPointValue?: number | null;
|
||||
operatorUserId?: string;
|
||||
remark?: string;
|
||||
registeredAt?: number;
|
||||
|
||||
+22
-2
@@ -2,8 +2,8 @@ import type { BasePage, Model } from "timi-web";
|
||||
import type { Customer } from "@/types/Customer";
|
||||
|
||||
export type PointRuleStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED";
|
||||
export type PointRuleTriggerType = "EVERY_TIME" | "FIRST_TIME" | "AFTER_COUNT_EVERY_TIME" | "SOURCE_VALUE";
|
||||
export type PointRuleMode = "FIXED" | "SOURCE_VALUE";
|
||||
export type PointRuleTriggerType = "EVERY_TIME" | "FIRST_TIME" | "AFTER_COUNT_EVERY_TIME" | "SOURCE_VALUE" | "CUSTOMER_EVENT_VALUE_TIER";
|
||||
export type PointRuleMode = "FIXED" | "SOURCE_VALUE" | "TIER";
|
||||
export type PointLedgerChangeType = "EARN" | "REVOKE" | "ADJUST";
|
||||
export type PointLedgerDirection = "INCREASE" | "DECREASE";
|
||||
|
||||
@@ -13,6 +13,26 @@ export type PointRuleCondition = {
|
||||
threshold?: number;
|
||||
includeCurrent?: boolean;
|
||||
scope?: "CUSTOMER_EVENT";
|
||||
sourceEventId?: string;
|
||||
sourceValueField?: "numberValue";
|
||||
window?: {
|
||||
type: "ROLLING";
|
||||
amount: number;
|
||||
unit: "YEAR";
|
||||
};
|
||||
tiers?: PointRuleTier[];
|
||||
};
|
||||
|
||||
export type PointRuleTier = {
|
||||
min: number;
|
||||
max?: number | null;
|
||||
pointValue: number;
|
||||
};
|
||||
|
||||
export type PointRuleTierForm = {
|
||||
key: number;
|
||||
maxText: string;
|
||||
pointValueText: string;
|
||||
};
|
||||
|
||||
export type PointAccount = {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { EventItem } from "@/types/EventItem";
|
||||
|
||||
/**
|
||||
* 返回事件数值单位的前端文案。
|
||||
* 后端仍使用 CENT 表示金额分,前端统一按元展示和录入。
|
||||
*/
|
||||
export function getEventValueUnitText(valueUnit?: EventItem["valueUnit"]): string {
|
||||
if (valueUnit === "CENT") {
|
||||
return "元";
|
||||
}
|
||||
if (valueUnit === "COUNT") {
|
||||
return "次";
|
||||
}
|
||||
return "未配置单位";
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取事件数值输入提示。
|
||||
*/
|
||||
export function getEventNumberValuePlaceholder(event: EventItem): string {
|
||||
if (event.valueUnit === "CENT") {
|
||||
return event.requireValue ? "请输入金额(元),最多两位小数" : "请输入金额(元),可留空,最多两位小数";
|
||||
}
|
||||
return event.requireValue ? "请输入次数,必须是大于等于 0 的整数" : "请输入次数,可留空,必须是大于等于 0 的整数";
|
||||
}
|
||||
|
||||
/**
|
||||
* 将前端输入的事件数值转换为后端要求的整数。
|
||||
* 金额使用字符串拆分后转换为分,避免直接使用浮点数放大导致精度误差。
|
||||
*/
|
||||
export function resolveEventNumberValue(event: EventItem, valueText: string): number | undefined {
|
||||
const normalizedValue = valueText.trim();
|
||||
if (!normalizedValue) {
|
||||
return undefined;
|
||||
}
|
||||
if (event.valueUnit === "CENT") {
|
||||
return resolveCentValue(normalizedValue);
|
||||
}
|
||||
if (!/^\d+$/.test(normalizedValue)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(normalizedValue);
|
||||
return Number.isSafeInteger(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端返回的整数分转换为前端金额文案。
|
||||
*/
|
||||
export function formatEventNumberValue(event: EventItem | undefined, value?: number | null): string {
|
||||
if (value === undefined || value === null) {
|
||||
return "-";
|
||||
}
|
||||
if (event?.valueUnit !== "CENT") {
|
||||
return `${value}${event?.valueUnit === "COUNT" ? " 次" : ""}`;
|
||||
}
|
||||
|
||||
const centText = String(Math.trunc(value));
|
||||
const yuanText = centText.length < 3 ? "0" : centText.slice(0, -2);
|
||||
const decimalText = centText.slice(-2).padStart(2, "0").replace(/0+$/, "");
|
||||
return `${yuanText}${decimalText ? `.${decimalText}` : ""} 元`;
|
||||
}
|
||||
|
||||
function resolveCentValue(valueText: string): number | undefined {
|
||||
const match = /^(\d+)(?:\.(\d{0,2}))?$/.exec(valueText);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const integerText = match[1];
|
||||
const decimalText = (match[2] || "").padEnd(2, "0");
|
||||
const centValue = BigInt(integerText) * 100n + BigInt(decimalText || "0");
|
||||
if (centValue > BigInt(Number.MAX_SAFE_INTEGER)) {
|
||||
return undefined;
|
||||
}
|
||||
return Number(centValue);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PointRuleTierForm } from "@/types/Point";
|
||||
|
||||
export function resolveNonNegativeInteger(value: string): number | undefined {
|
||||
if (!value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const numberValue = Number(value);
|
||||
return Number.isSafeInteger(numberValue) && !(numberValue < 0) ? numberValue : undefined;
|
||||
}
|
||||
|
||||
export function getTierMinValue(tierList: PointRuleTierForm[], index: number): number | undefined {
|
||||
if (index === 0) {
|
||||
return 0;
|
||||
}
|
||||
const previousMax = resolveNonNegativeInteger(tierList[index - 1]?.maxText || "");
|
||||
if (previousMax === undefined || !Number.isSafeInteger(previousMax + 1)) {
|
||||
return undefined;
|
||||
}
|
||||
return previousMax + 1;
|
||||
}
|
||||
|
||||
export function getTierMinText(tierList: PointRuleTierForm[], index: number): string {
|
||||
const min = getTierMinValue(tierList, index);
|
||||
return min === undefined ? "待填写" : String(min);
|
||||
}
|
||||
|
||||
export function isLastTier(tierList: PointRuleTierForm[], index: number): boolean {
|
||||
return index === tierList.length - 1;
|
||||
}
|
||||
Reference in New Issue
Block a user