update point rule groovy script

This commit is contained in:
Timi
2026-09-09 01:24:42 +08:00
parent 74127fc20f
commit 07205f869b
8 changed files with 281 additions and 138 deletions
+8 -1
View File
@@ -6,7 +6,8 @@ import type {
PointAdjustRequest,
PointLedger,
PointLedgerPage,
PointRule
PointRule,
GroovyScript
} from "@/types/Point";
import { getCurrentStoreId, withCurrentStoreId, withCurrentStorePage } from "@/utils/store";
@@ -94,6 +95,11 @@ async function ruleDetail(id: string): Promise<PointRule> {
});
}
/** 查询积分规则可选的 Groovy 脚本。 */
async function ruleScriptList(): Promise<GroovyScript[]> {
return await axios.post(`${BASE_URI}/rule/script/list`);
}
async function ruleCreate(rule: PointRule): Promise<string> {
return await axios.post(`${BASE_URI}/rule/create`, withCurrentStoreId(rule));
}
@@ -120,6 +126,7 @@ export default {
adjust,
ruleList,
ruleDetail,
ruleScriptList,
ruleCreate,
ruleUpdate,
ruleSort,
+4 -61
View File
@@ -22,9 +22,6 @@
borderless
/>
</t-form-item>
<t-form-item v-if="isGlobalManager" label="门店" :arrow="isCreateMode" @click="openStorePicker">
<div class="picker-text" v-text="selectedStoreName" />
</t-form-item>
<t-form-item label="业务类型" :arrow="false">
<div class="picker-text" v-text="getBizTypeText(formModel.bizType)" />
</t-form-item>
@@ -171,15 +168,6 @@
@confirm="confirmLimitPicker"
/>
</t-popup>
<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="statusPickerVisible" placement="bottom">
<t-picker
v-model="statusPickerDraft"
@@ -311,46 +299,6 @@ function getBizTypeText(value: EventBizType): string {
return getEventBizTypeText(value);
}
// ---------- 门店选择 ----------
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 selectedStoreName = computed(() => {
const store = currentStoreStore.storeList.find(item => item.id === formModel.storeId);
return store?.name || store?.code || "请选择门店";
});
function openStorePicker(): void {
if (!isCreateMode.value) {
return;
}
if (!currentStoreStore.storeList.length) {
Message.warning("暂无可选门店");
return;
}
storePickerDraft.value = [formModel.storeId];
storePickerVisible.value = true;
}
async function confirmStorePicker(): Promise<void> {
const storeId = String(storePickerDraft.value[0] || "");
if (!storeId) {
return;
}
formModel.storeId = storeId;
storePickerVisible.value = false;
}
// ---------- 颜色 ----------
const colorPickerVisible = ref(false);
@@ -600,7 +548,7 @@ function resolveTimestamp(valueText: string): number | undefined {
function validateForm(): boolean {
if (!formModel.storeId) {
Message.warning("请选择门店");
Message.warning("当前门店不可用");
return false;
}
if (!formModel.name.trim()) {
@@ -729,15 +677,10 @@ async function saveEventItem(): Promise<void> {
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;
} else {
await currentStoreStore.loadLoginStore();
}
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
formModel.storeId = currentStoreStore.currentStoreId;
}
async function fillForm(): Promise<void> {
+1 -4
View File
@@ -328,10 +328,7 @@ async function loadData(): Promise<void> {
}
async function toCreate(): Promise<void> {
await router.push({
path: "/management/registration-events/create",
query: currentStoreStore.currentStoreId ? { storeId: currentStoreStore.currentStoreId } : undefined
});
await router.push("/management/registration-events/create");
}
async function toSort(): Promise<void> {
+10
View File
@@ -64,6 +64,7 @@
<t-cell title="规则名称" :note="hitRule.name || '未命名规则'" />
<t-cell title="规则模式" :note="hitRule.ruleMode === 'GROOVY' ? '脚本规则' : '简单规则'" />
<t-cell title="规则积分" :note="getRulePointText(hitRule)" />
<t-cell v-if="hitRule.ruleMode === 'GROOVY'" title="执行脚本" :note="getRuleScriptText(hitRule)" />
<t-cell title="规则状态" :note="getRuleStatusText(hitRule.status)" />
<t-cell title="规则优先级" :note="getPointText(hitRule.priority)" />
<t-cell title="命中后停止匹配" :note="hitRule.exclusive ? '是' : '否'" />
@@ -254,6 +255,15 @@ function getRulePointText(rule: PointRule): string {
return `${0 < rule.pointValue ? "发放" : "消耗"} ${Math.abs(rule.pointValue)} 分`;
}
function getRuleScriptText(rule: PointRule): string {
const script = rule.groovyScript;
if (!script) {
return rule.groovyScriptId || "未绑定脚本";
}
const version = script.latest?.version;
return `${script.name || script.code || "未命名脚本"}${version === undefined ? "" : `(v${version})`}`;
}
function getRuleStatusText(status?: PointRuleStatus): string {
return ({
DRAFT: "草稿",
+56 -2
View File
@@ -34,7 +34,27 @@
<t-cell-group v-if="rule" title="积分配置" theme="card" v-entrance-transition="140">
<t-cell title="规则模式" :note="rule.ruleMode === 'GROOVY' ? '脚本规则' : '简单规则'" />
<t-cell v-if="rule.ruleMode !== 'GROOVY'" title="固定积分" :note="formatPointValue(rule.pointValue)" />
<t-cell v-else title="执行脚本" :note="rule.groovyScriptId || '未绑定脚本'" />
<template v-if="rule?.ruleMode === 'GROOVY'">
<t-cell title="执行脚本" :note="scriptName" />
<t-cell class="empty-cell-title-margin" title="脚本说明">
<template #description>
<markdown-view :content="rule.groovyScript?.description || '暂无脚本说明'" />
</template>
</t-cell>
<t-cell class="empty-cell-title-margin" title="脚本代码">
<template #description>
<code-editor
v-if="scriptContent"
:value="scriptContent"
language="groovy"
readonly
line-numbers
class="script-editor"
/>
<div v-else class="empty-script" v-text="'暂无脚本代码'" />
</template>
</t-cell>
</template>
</t-cell-group>
<t-cell-group v-if="rule" title="其他配置" theme="card" v-entrance-transition="280">
<t-cell title="规则优先级" :note="formatNumber(rule.priority)" />
@@ -60,7 +80,8 @@
<script setup lang="ts">
import { DialogPlugin } from "tdesign-mobile-vue";
import { Text, Time } from "timi-web";
import { CodeEditor } from "timi-tdesign-mobile";
import { MarkdownView, Text, Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
import PointAPI from "@/api/PointAPI";
@@ -112,6 +133,22 @@ const rulePointText = computed(() => {
}
return formatPointValue(rule.value?.pointValue);
});
const scriptName = computed(() => rule.value?.groovyScript?.name || rule.value?.groovyScriptId || "未绑定脚本");
const scriptStatusText = computed(() => {
const script = rule.value?.groovyScript;
if (!script) {
return "脚本不存在";
}
if (script.enabled && script.publishedVersionId) {
return "已启用并已发布";
}
return script.enabled ? "已启用,未发布" : "已停用";
});
const scriptVersionText = computed(() => {
const version = rule.value?.groovyScript?.latest?.version;
return version === undefined || version === null ? "未创建版本" : String(version);
});
const scriptContent = computed(() => rule.value?.groovyScript?.latest?.content || "");
async function loadStoreScope(): Promise<void> {
if (isGlobalManager.value) {
@@ -349,5 +386,22 @@ onUnmounted(() => {
color: var(--td-text-color-secondary);
}
}
.script-section {
.script-editor {
height: 24rem;
min-height: 16rem;
background: var(--td-bg-color-page);
border-radius: .5rem;
}
.empty-script {
padding: 1rem;
color: var(--td-text-color-placeholder);
background: var(--td-bg-color-page);
border-radius: .5rem;
}
}
}
</style>
+172 -55
View File
@@ -2,9 +2,6 @@
<div v-can="pageAccess" class="point-rule-edit">
<t-form class="form" label-align="left" label-width="7rem">
<card-container v-entrance-transition>
<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="openTriggerSourcePicker">
<span class="picker-text" v-text="getTriggerSourceText(formModel.triggerSourceType)" />
</t-form-item>
@@ -23,6 +20,15 @@
</t-form-item>
</card-container>
<card-container v-entrance-transition="70">
<t-form-item label="规则模式" arrow @click="openRuleModePicker">
<span class="picker-text" v-text="getRuleModeText(formModel.ruleMode)" />
</t-form-item>
<template v-if="formModel.ruleMode === 'GROOVY'">
<t-form-item label="执行脚本" required-mark arrow @click="openScriptPicker">
<span class="picker-text" v-text="selectedScriptText" />
</t-form-item>
</template>
<template v-else>
<t-form-item label="积分动作" arrow @click="openPointActionPicker">
<span class="picker-text" v-text="getPointActionText(formModel.pointAction)" />
</t-form-item>
@@ -35,6 +41,7 @@
placeholder="请输入数量"
/>
</t-form-item>
</template>
</card-container>
<card-container v-entrance-transition="140">
<t-form-item label="生效开始" arrow @click="openBeginAtPicker">
@@ -57,15 +64,6 @@
</card-container>
</t-form>
</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"
@@ -102,6 +100,51 @@
@confirm="confirmPointActionPicker"
/>
</t-popup>
<t-popup v-model="ruleModePickerVisible" placement="bottom">
<t-picker
v-model="ruleModePickerDraft"
:columns="ruleModePickerColumns"
title="规则模式"
@cancel="ruleModePickerVisible = false"
@confirm="confirmRuleModePicker"
/>
</t-popup>
<bottom-popup
v-model="scriptPickerVisible"
title="选择执行脚本"
height="min(80vh, 48rem)"
@left-button-click="cancelScriptPicker"
@right-button-click="confirmScriptPicker"
>
<t-collapse
v-if="availableScriptList.length"
v-model="expandedScriptIdList"
class="script-collapse"
:expand-mutex="true"
>
<t-collapse-panel
v-for="script in availableScriptList"
:key="getScriptId(script)"
:value="getScriptId(script)"
class="script-panel"
placement="bottom"
>
<template #header>
<t-checkbox
:model-value="scriptPickerDraftId === getScriptId(script)"
:label="getScriptName(script)"
borderless
@click.stop
@change="selectScript(getScriptId(script))"
/>
</template>
<div class="script-description">
<markdown-view :content="script.description || '暂无脚本说明'" />
</div>
</t-collapse-panel>
</t-collapse>
<t-empty v-else description="暂无可选积分脚本" />
</bottom-popup>
<t-popup v-model="beginAtPickerVisible" placement="bottom">
<t-date-time-picker
v-model="beginAtDraftText"
@@ -127,7 +170,8 @@
</template>
<script setup lang="ts">
import { Time } from "timi-web";
import { BottomPopup } from "timi-tdesign-mobile";
import { MarkdownView, Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import EventAPI from "@/api/EventAPI";
import PointAPI from "@/api/PointAPI";
@@ -135,8 +179,10 @@ import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { EventItem } from "@/types/EventItem";
import type {
GroovyScript,
PointAction,
PointRule,
PointRuleMode,
PointRuleStatus,
PointRuleTriggerSourceType
} from "@/types/Point";
@@ -170,30 +216,24 @@ const isSaving = ref(false);
const formModel = reactive({
id: "",
storeId: "",
ruleMode: "SIMPLE" as PointRuleMode,
triggerSourceType: "EVENT_RECORD" as PointRuleTriggerSourceType,
triggerRefId: "",
name: "",
status: "DRAFT" as PointRuleStatus,
pointAction: "EARN" as PointAction,
pointValueText: "",
groovyScriptId: "",
beginAtText: "",
endAtText: "",
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.triggerRefId)?.name || "请选择触发事件");
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(() => [
@@ -202,28 +242,15 @@ const eventPickerColumns = computed(() => [
.filter(item => item.value)
]);
function openStorePicker(): void {
if (!isCreateMode.value || !storePickerColumns.value[0]?.length) {
return;
const scriptList = ref<GroovyScript[]>([]);
const selectedScript = computed(() => scriptList.value.find(item => item.id === formModel.groovyScriptId));
const availableScriptList = computed(() => scriptList.value.filter(item => item.id));
const selectedScriptText = computed(() => {
if (!selectedScript.value) {
return "请选择执行脚本";
}
storePickerDraft.value = [formModel.storeId];
storePickerVisible.value = true;
}
async function confirmStorePicker(value: Array<string | number>): Promise<void> {
const storeId = String(value[0] || "");
if (storeId && storeId !== formModel.storeId) {
formModel.storeId = storeId;
formModel.triggerRefId = "";
try {
await loadEventList();
} catch {
eventList.value = [];
Message.error("触发事件加载失败");
}
}
storePickerVisible.value = false;
}
return selectedScript.value.name || selectedScript.value.code || "未命名脚本";
});
function openEventPicker(): void {
if (!eventPickerColumns.value[0]?.length) {
@@ -245,14 +272,10 @@ function confirmEventPicker(value: Array<string | number>): void {
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;
} else {
await currentStoreStore.loadLoginStore();
}
const store = await currentStoreStore.loadLoginStore();
formModel.storeId = store?.id || "";
formModel.storeId = currentStoreStore.currentStoreId;
}
async function loadEventList(): Promise<void> {
@@ -265,6 +288,10 @@ async function loadEventList(): Promise<void> {
}
}
async function loadScriptList(): Promise<void> {
scriptList.value = await PointAPI.ruleScriptList();
}
// ---------- 规则选项 ----------
const statusOptions: Array<{ label: string; value: PointRuleStatus }> = [
@@ -286,9 +313,27 @@ const pointActionOptions: Array<{ label: string; value: PointAction }> = [
{ label: "发放积分", value: "EARN" },
{ label: "消耗积分", value: "CONSUME" }
];
const ruleModeOptions: Array<{ label: string; value: PointRuleMode }> = [
{ label: "简单规则", value: "SIMPLE" },
{ label: "脚本规则", value: "GROOVY" }
];
const pointActionPickerVisible = ref(false);
const pointActionPickerDraft = ref<PickerValue[]>([]);
const pointActionPickerColumns = [pointActionOptions];
const ruleModePickerVisible = ref(false);
const ruleModePickerDraft = ref<PickerValue[]>([]);
const ruleModePickerColumns = [ruleModeOptions];
const scriptPickerVisible = ref(false);
const scriptPickerDraftId = ref("");
const expandedScriptIdList = ref<Array<string | number>>([]);
function getScriptId(script: GroovyScript): string {
return script.id || "";
}
function getScriptName(script: GroovyScript): string {
return script.name || script.code || "未命名脚本";
}
function openStatusPicker(): void {
statusPickerDraft.value = [formModel.status];
@@ -332,6 +377,52 @@ function confirmPointActionPicker(value: Array<string | number>): void {
pointActionPickerVisible.value = false;
}
function openRuleModePicker(): void {
ruleModePickerDraft.value = [formModel.ruleMode];
ruleModePickerVisible.value = true;
}
function confirmRuleModePicker(value: Array<string | number>): void {
const ruleMode = value[0];
if (ruleMode !== "SIMPLE" && ruleMode !== "GROOVY") {
return;
}
formModel.ruleMode = ruleMode;
if (ruleMode === "SIMPLE") {
formModel.groovyScriptId = "";
} else {
formModel.pointValueText = "";
}
ruleModePickerVisible.value = false;
}
function openScriptPicker(): void {
if (!availableScriptList.value.length) {
Message.warning("暂无可选积分脚本");
return;
}
scriptPickerDraftId.value = formModel.groovyScriptId;
expandedScriptIdList.value = formModel.groovyScriptId ? [formModel.groovyScriptId] : [];
scriptPickerVisible.value = true;
}
function selectScript(scriptId: string): void {
if (scriptId && availableScriptList.value.some(item => item.id === scriptId)) {
scriptPickerDraftId.value = scriptId;
}
}
function cancelScriptPicker(): void {
scriptPickerVisible.value = false;
}
function confirmScriptPicker(): void {
if (scriptPickerDraftId.value && availableScriptList.value.some(item => item.id === scriptPickerDraftId.value)) {
formModel.groovyScriptId = scriptPickerDraftId.value;
}
scriptPickerVisible.value = false;
}
function getStatusText(status: PointRuleStatus): string {
return statusOptions.find(item => item.value === status)?.label || "草稿";
}
@@ -344,6 +435,10 @@ function getPointActionText(action: PointAction): string {
return action === "CONSUME" ? "消耗积分" : "发放积分";
}
function getRuleModeText(ruleMode: PointRuleMode): string {
return ruleMode === "GROOVY" ? "脚本规则" : "简单规则";
}
// ---------- 生效时间 ----------
const minimumDateTimeText = "1970-01-01 00:00";
@@ -405,7 +500,7 @@ function resolveTimestamp(value: string): number | undefined {
function validateForm(): boolean {
if (!formModel.storeId) {
Message.warning("请选择门店");
Message.warning("当前门店不可用");
return false;
}
if (!formModel.name.trim()) {
@@ -416,7 +511,12 @@ function validateForm(): boolean {
Message.warning("请选择触发事件");
return false;
}
if (!resolvePositiveInteger(formModel.pointValueText)) {
if (formModel.ruleMode === "GROOVY") {
if (!formModel.groovyScriptId) {
Message.warning("请选择执行脚本");
return false;
}
} else if (!resolvePositiveInteger(formModel.pointValueText)) {
Message.warning("积分数量必须是正整数");
return false;
}
@@ -438,14 +538,14 @@ async function saveRule(): Promise<void> {
const payload: PointRule = {
id: formModel.id || undefined,
storeId: formModel.storeId,
ruleMode: "SIMPLE",
ruleMode: formModel.ruleMode,
triggerSourceType: formModel.triggerSourceType,
triggerRefType: formModel.triggerSourceType === "EVENT_RECORD" ? "EVENT" : undefined,
triggerRefId: formModel.triggerSourceType === "EVENT_RECORD" ? formModel.triggerRefId : undefined,
name: formModel.name.trim(),
status: formModel.status,
pointValue: resolvePointValue() ?? null,
groovyScriptId: undefined,
pointValue: formModel.ruleMode === "SIMPLE" ? resolvePointValue() ?? null : null,
groovyScriptId: formModel.ruleMode === "GROOVY" ? formModel.groovyScriptId : undefined,
beginAt: resolveTimestamp(formModel.beginAtText),
endAt: resolveTimestamp(formModel.endAtText),
exclusive: formModel.exclusive,
@@ -469,17 +569,23 @@ async function saveRule(): Promise<void> {
async function fillForm(): Promise<void> {
await prepareStoreScope();
await loadScriptList();
if (!isCreateMode.value) {
const current = await PointAPI.ruleDetail(routeId.value);
if (current.groovyScript?.id && !scriptList.value.some(item => item.id === current.groovyScript?.id)) {
scriptList.value.push(current.groovyScript);
}
Object.assign(formModel, {
id: current.id || "",
storeId: current.storeId || formModel.storeId,
ruleMode: current.ruleMode || "SIMPLE",
triggerSourceType: current.triggerSourceType || "EVENT_RECORD",
triggerRefId: current.triggerRefId || "",
name: current.name || "",
status: current.status === "ACTIVE" ? "ACTIVE" : current.status === "INACTIVE" ? "INACTIVE" : "DRAFT",
pointAction: current.pointValue !== undefined && current.pointValue !== null && current.pointValue < 0 ? "CONSUME" : "EARN",
pointValueText: current.pointValue === undefined || current.pointValue === null ? "" : String(Math.abs(current.pointValue)),
groovyScriptId: current.groovyScriptId || "",
beginAtText: current.beginAt ? Time.toDateTime(current.beginAt) : "",
endAtText: current.endAt ? Time.toDateTime(current.endAt) : "",
exclusive: current.exclusive ?? false,
@@ -543,6 +649,13 @@ onUnmounted(() => navBarStore.clearRight());
}
}
.script-collapse {
:deep(.t-cell) {
padding: 0 10px;
}
}
:global(html.theme-dark) {
.point-rule-edit {
.form {
@@ -554,6 +667,10 @@ onUnmounted(() => navBarStore.clearRight());
color: #94a3b8;
}
}
.script-description {
color: #94a3b8;
}
}
}
</style>
+1 -4
View File
@@ -264,10 +264,7 @@ function getStatusTheme(status?: PointRuleStatus): "default" | "success" | "warn
}
async function toCreate(): Promise<void> {
await router.push({
name: "PointRuleCreate",
query: currentStoreStore.currentStoreId ? { storeId: currentStoreStore.currentStoreId } : undefined
});
await router.push({ name: "PointRuleCreate" });
}
async function toSort(): Promise<void> {
+18
View File
@@ -12,6 +12,23 @@ export type PointRuleTriggerSourceType = "EVENT_RECORD" | "SALES_ORDER_PAID";
/** 触发对象筛选类型 */
export type PointRuleTriggerRefType = "EVENT";
export type GroovyScript = {
bizType?: "GAO_POINT_RULE";
code?: string;
name?: string;
description?: string;
enabled?: boolean;
publishedVersionId?: string;
latest?: GroovyScriptVersion | null;
} & Model;
export type GroovyScriptVersion = {
scriptId?: string;
version?: number;
content?: string;
checksum?: string;
} & Model;
export type PointLedgerChangeType = "EARN" | "CONSUME" | "REVOKE" | "ADJUST";
export type PointLedgerDirection = "INCREASE" | "DECREASE";
export type PointAction = Extract<PointLedgerChangeType, "EARN" | "CONSUME">;
@@ -81,6 +98,7 @@ export type PointRule = {
priority?: number;
exclusive?: boolean;
remark?: string;
groovyScript?: GroovyScript | null;
} & Model;
export type PointAdjustRequest = {