Files
gao-customer-h5/src/pages/point/PointRuleEdit.vue
T
2026-08-11 22:54:57 +08:00

696 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="pageAccess" class="point-rule-edit">
<t-form class="form" label-align="left" label-width="6rem">
<t-form-item v-if="isGlobalManager" label="门店" required-mark :arrow="isCreateMode" @click="openStorePicker">
<span class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="登记事件" required-mark arrow @click="openEventPicker">
<span class="picker-text" v-text="selectedEventName" />
</t-form-item>
<t-form-item label="规则名称" required-mark>
<t-input v-model.trim="formModel.name" borderless placeholder="例如:每次登记加 10 分" />
</t-form-item>
<t-form-item label="规则状态" arrow @click="openStatusPicker">
<span class="picker-text" v-text="getStatusText(formModel.status)" />
</t-form-item>
<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">
<span class="picker-text" v-text="getModeText(formModel.pointMode)" />
</t-form-item>
<t-form-item v-if="formModel.pointMode === 'FIXED'" label="固定积分" required-mark>
<t-input
v-model.trim="formModel.pointValueText"
type="number"
align="right"
borderless
placeholder="请输入正整数"
/>
</t-form-item>
<t-form-item v-if="formModel.triggerType === 'AFTER_COUNT_EVERY_TIME'" label="触发阈值" required-mark>
<t-input
v-model.trim="formModel.thresholdText"
type="number"
align="right"
borderless
placeholder="累计登记达到几次"
/>
</t-form-item>
<div v-if="formModel.pointMode === 'SOURCE_VALUE'" class="source-condition">
<div class="source-title">自定义积分范围</div>
<div class="source-inputs">
<t-input v-model.trim="formModel.minText" type="number" align="right" borderless placeholder="最小值" />
<span>至</span>
<t-input v-model.trim="formModel.maxText" type="number" align="right" borderless placeholder="最大值" />
</div>
<p>登记时只有输入在此范围内的正整数,后端才会按本规则发放积分。</p>
</div>
<t-form-item label="生效开始" arrow @click="openBeginAtPicker">
<div class="time-value">
<span class="picker-text" v-text="formModel.beginAtText || '不限'" />
<t-button v-if="formModel.beginAtText" size="extra-small" content="不限" @click.stop="clearBeginAt" />
</div>
</t-form-item>
<t-form-item label="生效结束" arrow @click="openEndAtPicker">
<div class="time-value">
<span class="picker-text" v-text="formModel.endAtText || '不限'" />
<t-button v-if="formModel.endAtText" size="extra-small" content="不限" @click.stop="clearEndAt" />
</div>
</t-form-item>
<t-form-item label="优先级" required-mark>
<t-input v-model.trim="formModel.priorityText" type="number" align="right" borderless placeholder="数值越小越优先" />
</t-form-item>
<t-form-item label="独占匹配" content-align="right">
<t-switch v-model="formModel.exclusive" />
</t-form-item>
<t-form-item label="规则备注">
<t-textarea v-model.trim="formModel.remark" :autosize="{ minRows: 3, maxRows: 5 }" placeholder="规则说明" />
</t-form-item>
</t-form>
<div class="bottom-actions">
<t-button
v-if="!isCreateMode"
v-can="permissionCodes.pointRule.delete"
class="delete"
theme="danger"
variant="outline"
:loading="isRemoving"
@click="confirmRemove"
>
删除
</t-button>
<t-button
v-can="savePermissionCode"
class="save"
theme="primary"
:loading="isSaving"
@click="saveRule"
>
保存
</t-button>
</div>
</div>
<t-popup v-model="storePickerVisible" placement="bottom">
<t-picker
v-model="storePickerDraft"
:columns="storePickerColumns"
title="选择门店"
@cancel="storePickerVisible = false"
@confirm="confirmStorePicker"
/>
</t-popup>
<t-popup v-model="eventPickerVisible" placement="bottom">
<t-picker
v-model="eventPickerDraft"
:columns="eventPickerColumns"
title="选择登记事件"
@cancel="eventPickerVisible = false"
@confirm="confirmEventPicker"
/>
</t-popup>
<t-popup v-model="statusPickerVisible" placement="bottom">
<t-picker
v-model="statusPickerDraft"
:columns="statusPickerColumns"
title="规则状态"
@cancel="statusPickerVisible = false"
@confirm="confirmStatusPicker"
/>
</t-popup>
<t-popup v-model="triggerPickerVisible" placement="bottom">
<t-picker
v-model="triggerPickerDraft"
:columns="triggerPickerColumns"
title="触发类型"
@cancel="triggerPickerVisible = false"
@confirm="confirmTriggerPicker"
/>
</t-popup>
<t-popup v-model="modePickerVisible" placement="bottom">
<t-picker
v-model="modePickerDraft"
:columns="modePickerColumns"
title="积分值模式"
@cancel="modePickerVisible = false"
@confirm="confirmModePicker"
/>
</t-popup>
<t-popup v-model="beginAtPickerVisible" placement="bottom">
<t-date-time-picker
v-model="beginAtDraftText"
:start="minimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="生效开始"
@cancel="beginAtPickerVisible = false"
@confirm="confirmBeginAt"
/>
</t-popup>
<t-popup v-model="endAtPickerVisible" placement="bottom">
<t-date-time-picker
v-model="endAtDraftText"
:start="endAtMinimumDateTimeText"
:mode="['date', 'minute']"
format="YYYY-MM-DD HH:mm"
title="生效结束"
@cancel="endAtPickerVisible = false"
@confirm="confirmEndAt"
/>
</t-popup>
</template>
<script setup lang="ts">
import { DialogPlugin, Toast } from "tdesign-mobile-vue";
import { Time, useUserStore } from "timi-web";
import EventAPI from "@/api/EventAPI";
import PointAPI from "@/api/PointAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { EventItem } from "@/types/EventItem";
import type { PointRule, PointRuleMode, PointRuleStatus, PointRuleTriggerType } from "@/types/Point";
import { getErrorMessage } from "@/utils/error";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type PickerValue = string | number;
defineOptions({
name: "PointRuleEdit"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const routeId = computed(() => String(route.params.id || ""));
const isCreateMode = computed(() => !routeId.value);
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
const savePermissionCode = computed(() => isCreateMode.value ? permissionCodes.pointRule.create : permissionCodes.pointRule.update);
const pageAccess = computed(() => ({
value: [permissionCodes.pointRule.read, savePermissionCode.value],
mode: "and" as const
}));
// ---------- 基础表单 ----------
const isSaving = ref(false);
const isRemoving = ref(false);
const formModel = reactive({
id: "",
storeId: "",
eventId: "",
name: "",
status: "DRAFT" as PointRuleStatus,
triggerType: "EVERY_TIME" as PointRuleTriggerType,
pointMode: "FIXED" as PointRuleMode,
pointValueText: "",
thresholdText: "",
minText: "",
maxText: "",
beginAtText: "",
endAtText: "",
priorityText: "0",
exclusive: false,
remark: ""
});
// ---------- 门店与事件 ----------
const eventList = ref<EventItem[]>([]);
const selectedStoreName = computed(() => currentStoreStore.storeList.find(item => item.id === formModel.storeId)?.name || "请选择门店");
const selectedEventName = computed(() => eventList.value.find(item => item.id === formModel.eventId)?.name || (formModel.eventId ? `事件 ${formModel.eventId}` : "请选择登记事件"));
const storePickerVisible = ref(false);
const storePickerDraft = ref<PickerValue[]>([]);
const storePickerColumns = computed(() => [
currentStoreStore.storeList
.map(item => ({ label: item.name || item.code || "未命名门店", value: item.id || "" }))
.filter(item => item.value)
]);
const eventPickerVisible = ref(false);
const eventPickerDraft = ref<PickerValue[]>([]);
const eventPickerColumns = computed(() => [
eventList.value
.map(item => ({ label: item.name || "未命名事件", value: item.id || "" }))
.filter(item => item.value)
]);
function openStorePicker(): void {
if (!isCreateMode.value || !storePickerColumns.value[0]?.length) {
return;
}
storePickerDraft.value = [formModel.storeId];
storePickerVisible.value = true;
}
async function confirmStorePicker(value: Array<string | number>): Promise<void> {
const storeId = String(value[0] || "");
if (storeId) {
formModel.storeId = storeId;
formModel.eventId = "";
try {
await loadEventList();
} catch {
eventList.value = [];
Toast.error("登记事件加载失败");
}
}
storePickerVisible.value = false;
}
function openEventPicker(): void {
if (!eventPickerColumns.value[0]?.length) {
Toast.warning("暂无可选登记事件");
return;
}
eventPickerDraft.value = [formModel.eventId];
eventPickerVisible.value = true;
}
function confirmEventPicker(value: Array<string | number>): void {
const eventId = String(value[0] || "");
if (eventId) {
formModel.eventId = eventId;
}
eventPickerVisible.value = false;
}
async function prepareStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
const queryStoreId = String(route.query.storeId || "");
formModel.storeId = currentStoreStore.storeList.some(item => item.id === queryStoreId)
? queryStoreId
: currentStoreStore.currentStoreId;
return;
}
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
}
async function loadEventList(): Promise<void> {
const options = isGlobalManager.value ? { storeId: formModel.storeId } : undefined;
const result = await EventAPI.list({ index: 0, size: 1000 }, options);
eventList.value = result.list || [];
if (formModel.eventId && !eventList.value.some(item => item.id === formModel.eventId)) {
eventList.value.push(await EventAPI.detail(formModel.eventId));
}
}
// ---------- 规则选项 ----------
const statusOptions: Array<{ label: string; value: PointRuleStatus }> = [
{ label: "草稿", value: "DRAFT" },
{ label: "启用", value: "ACTIVE" },
{ label: "停用", value: "INACTIVE" }
];
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" }
];
const statusPickerVisible = ref(false);
const statusPickerDraft = ref<PickerValue[]>([]);
const statusPickerColumns = [statusOptions];
const triggerPickerVisible = ref(false);
const triggerPickerDraft = ref<PickerValue[]>([]);
const triggerPickerColumns = [triggerOptions];
const modePickerVisible = ref(false);
const modePickerDraft = ref<PickerValue[]>([]);
const modePickerColumns = [modeOptions];
function openStatusPicker(): void {
statusPickerDraft.value = [formModel.status];
statusPickerVisible.value = true;
}
function confirmStatusPicker(value: Array<string | number>): void {
const status = value[0];
if (typeof status === "string" && statusOptions.some(item => item.value === status)) {
formModel.status = status as PointRuleStatus;
}
statusPickerVisible.value = false;
}
function openTriggerPicker(): void {
triggerPickerDraft.value = [formModel.triggerType];
triggerPickerVisible.value = true;
}
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";
}
triggerPickerVisible.value = false;
}
function openModePicker(): void {
modePickerDraft.value = [formModel.pointMode];
modePickerVisible.value = true;
}
function confirmModePicker(value: Array<string | number>): void {
const pointMode = value[0];
if (pointMode === "FIXED" || pointMode === "SOURCE_VALUE") {
formModel.pointMode = pointMode;
formModel.triggerType = pointMode === "SOURCE_VALUE" ? "SOURCE_VALUE" : formModel.triggerType === "SOURCE_VALUE" ? "EVERY_TIME" : formModel.triggerType;
}
modePickerVisible.value = false;
}
function getStatusText(status: PointRuleStatus): string {
return statusOptions.find(item => item.value === status)?.label || "草稿";
}
function getTriggerText(triggerType: PointRuleTriggerType): string {
return triggerOptions.find(item => item.value === triggerType)?.label || "每次登记";
}
function getModeText(pointMode: PointRuleMode): string {
return modeOptions.find(item => item.value === pointMode)?.label || "固定积分";
}
// ---------- 生效时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
const beginAtPickerVisible = ref(false);
const endAtPickerVisible = ref(false);
const beginAtDraftText = ref("");
const endAtDraftText = ref("");
const endAtMinimumDateTimeText = computed(() => formModel.beginAtText || minimumDateTimeText);
function openBeginAtPicker(): void {
beginAtDraftText.value = formModel.beginAtText || Time.formatDateTime(new Date());
beginAtPickerVisible.value = true;
}
function openEndAtPicker(): void {
endAtDraftText.value = formModel.endAtText || Time.formatDateTime(new Date());
endAtPickerVisible.value = true;
}
function confirmBeginAt(value: string | number): void {
formModel.beginAtText = String(value);
beginAtPickerVisible.value = false;
}
function confirmEndAt(value: string | number): void {
formModel.endAtText = String(value);
endAtPickerVisible.value = false;
}
function clearBeginAt(): void {
formModel.beginAtText = "";
beginAtPickerVisible.value = false;
}
function clearEndAt(): void {
formModel.endAtText = "";
endAtPickerVisible.value = false;
}
// ---------- 保存 ----------
function resolvePositiveInteger(value: string): number | undefined {
const numberValue = Number(value);
return Number.isSafeInteger(numberValue) && 0 < numberValue ? numberValue : undefined;
}
function resolveTimestamp(value: string): number | undefined {
if (!value.trim()) {
return undefined;
}
const timestamp = new Date(value.replace(" ", "T")).getTime();
return Number.isFinite(timestamp) ? timestamp : undefined;
}
function buildCondition(): PointRule["conditionJson"] {
if (formModel.pointMode === "SOURCE_VALUE") {
return {
min: resolvePositiveInteger(formModel.minText),
max: resolvePositiveInteger(formModel.maxText)
};
}
if (formModel.triggerType === "FIRST_TIME") {
return { scope: "CUSTOMER_EVENT" };
}
if (formModel.triggerType === "AFTER_COUNT_EVERY_TIME") {
return {
threshold: resolvePositiveInteger(formModel.thresholdText),
includeCurrent: true,
scope: "CUSTOMER_EVENT"
};
}
return {};
}
function validateForm(): boolean {
if (!formModel.storeId) {
Toast.warning("请选择门店");
return false;
}
if (!formModel.eventId) {
Toast.warning("请选择登记事件");
return false;
}
if (!formModel.name.trim()) {
Toast.warning("请输入规则名称");
return false;
}
if (formModel.pointMode === "FIXED" && !resolvePositiveInteger(formModel.pointValueText)) {
Toast.warning("固定积分必须是正整数");
return false;
}
if (formModel.triggerType === "AFTER_COUNT_EVERY_TIME" && !resolvePositiveInteger(formModel.thresholdText)) {
Toast.warning("请输入有效的触发阈值");
return false;
}
if (formModel.pointMode === "SOURCE_VALUE") {
const min = resolvePositiveInteger(formModel.minText);
const max = resolvePositiveInteger(formModel.maxText);
if (!min || !max || max < min) {
Toast.warning("自定义积分范围必须是正整数,且最大值不能小于最小值");
return false;
}
}
const beginAt = resolveTimestamp(formModel.beginAtText);
const endAt = resolveTimestamp(formModel.endAtText);
if (beginAt !== undefined && endAt !== undefined && endAt < beginAt) {
Toast.warning("生效结束时间不能早于开始时间");
return false;
}
const priority = Number(formModel.priorityText);
if (!Number.isSafeInteger(priority) || 0 > priority) {
Toast.warning("优先级必须是大于等于 0 的整数");
return false;
}
return true;
}
async function saveRule(): Promise<void> {
if (!validateForm()) {
return;
}
isSaving.value = true;
try {
const payload: PointRule = {
id: formModel.id || undefined,
storeId: formModel.storeId,
eventId: formModel.eventId,
name: formModel.name.trim(),
status: formModel.status,
triggerType: formModel.triggerType,
pointMode: formModel.pointMode,
pointValue: formModel.pointMode === "FIXED" ? resolvePositiveInteger(formModel.pointValueText) : null,
conditionJson: buildCondition(),
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
priority: Number(formModel.priorityText),
exclusive: formModel.exclusive,
remark: formModel.remark.trim()
};
if (isCreateMode.value) {
await PointAPI.ruleCreate(payload);
} else {
await PointAPI.ruleUpdate(payload);
}
Toast.success("积分规则已保存");
await router.replace("/management/points/rules");
} catch (error) {
Toast.error(getErrorMessage(error, "积分规则保存失败"));
} finally {
isSaving.value = false;
}
}
// ---------- 删除 ----------
async function confirmRemove(): Promise<void> {
if (!formModel.id || isRemoving.value) {
return;
}
const dialog = DialogPlugin.confirm({
title: "删除积分规则",
content: `确定删除规则 ${formModel.name || "未命名规则"} 吗?删除只影响后续登记,不会改变历史流水。`,
cancelBtn: "取消",
confirmBtn: "删除",
onCancel() {
dialog.destroy();
},
async onConfirm() {
dialog.destroy();
await removeRule();
}
});
}
async function removeRule(): Promise<void> {
if (!formModel.id) {
return;
}
isRemoving.value = true;
try {
await PointAPI.ruleRemove(formModel.id);
Toast.success("积分规则已删除");
await router.replace("/management/points/rules");
} catch (error) {
Toast.error(getErrorMessage(error, "积分规则删除失败"));
} finally {
isRemoving.value = false;
}
}
// ---------- 页面数据 ----------
async function fillForm(): Promise<void> {
await prepareStoreScope();
if (!isCreateMode.value) {
const current = await PointAPI.ruleDetail(routeId.value);
Object.assign(formModel, {
id: current.id || "",
storeId: current.storeId || formModel.storeId,
eventId: current.eventId || "",
name: current.name || "",
status: current.status === "ACTIVE" ? "ACTIVE" : current.status === "INACTIVE" ? "INACTIVE" : "DRAFT",
triggerType: current.triggerType || "EVERY_TIME",
pointMode: current.pointMode || "FIXED",
pointValueText: current.pointValue === undefined || current.pointValue === null ? "" : String(current.pointValue),
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),
beginAtText: current.beginAt ? Time.toDateTime(current.beginAt) : "",
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
priorityText: String(current.priority ?? 0),
exclusive: current.exclusive ?? false,
remark: current.remark || ""
});
}
await loadEventList();
}
onMounted(async () => {
try {
await fillForm();
} catch (error) {
Toast.error(getErrorMessage(error, "积分规则加载失败"));
}
});
</script>
<style scoped lang="less">
.point-rule-edit {
padding: calc(var(--app-nav-height) + 1rem) 0 5rem;
.form {
margin: 0 1rem;
padding: .5rem 0;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
border-radius: 1.4rem;
.picker-text {
color: #52607a;
width: 100%;
text-align: right;
}
.time-value {
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
}
.source-condition {
padding: .75rem 1rem 1rem;
.source-title {
font-weight: 700;
}
.source-inputs {
gap: .5rem;
display: flex;
align-items: center;
margin-top: .5rem;
.t-input {
flex: 1;
border: 1px solid var(--td-component-border);
border-radius: .75rem;
}
}
p {
color: #52607a;
margin: .5rem 0 0;
font-size: .75rem;
line-height: 1.5;
}
}
}
.bottom-actions {
gap: .75rem;
left: 1rem;
right: 1rem;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
display: flex;
position: fixed;
.delete {
flex: 1;
}
.save {
flex: 2;
}
}
}
:global(html.theme-dark) {
.point-rule-edit {
.form {
background: rgba(15, 23, 42, .88);
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
.picker-text,
.source-condition p {
color: #94a3b8;
}
.source-inputs .t-input {
background: rgba(15, 23, 42, .72);
border-color: rgba(148, 163, 184, .16);
}
}
}
}
</style>