add PointLedgerDetail.vue

This commit is contained in:
Timi
2026-08-16 00:13:34 +08:00
parent 466c2e7ab7
commit 371fee4c4f
6 changed files with 452 additions and 17 deletions
+394
View File
@@ -0,0 +1,394 @@
<template>
<div v-can="permissionCodes.point.ledgerRead" class="point-ledger-detail">
<customer-profile-card
v-if="customer"
class="customer-profile"
:customer="customer"
@click="toCustomerDetail"
/>
<template v-if="ledger">
<t-cell-group title="积分流水" theme="card">
<t-cell title="流水类型">
<template #note>
<t-tag
:theme="getDeltaTheme(ledger.pointDelta)"
:content="getChangeTypeText(ledger.changeType)"
/>
</template>
</t-cell>
<t-cell title="积分变化" :note="getPointDeltaText(ledger.pointDelta)" />
<t-cell title="变化前余额" :note="getPointText(ledger.balanceBefore)" />
<t-cell title="变化后余额" :note="getPointText(ledger.balanceAfter)" />
<t-cell title="发生时间">
<template #note>
<pass-time-label :timestamp="ledger.occurredAt" underline />
</template>
</t-cell>
<t-cell title="流水备注" :note="Text.display(ledger.remark)" />
</t-cell-group>
<t-cell-group title="触发事件" theme="card">
<template v-if="triggerEvent">
<t-cell title="事件名称" :note="triggerEvent.name || '未命名事件'" />
<t-cell title="事件类型" :note="getEventValueTypeText(triggerEvent)" />
<t-cell title="事件说明" :note="Text.display(triggerEvent.description)" />
</template>
<div v-else class="empty" v-text="getEventEmptyText()" />
</t-cell-group>
<t-cell-group title="事件记录" theme="card">
<template v-if="eventRecord">
<t-cell title="登记事件" :note="eventRecord.event?.name || triggerEvent?.name || '未知事件'" />
<t-cell title="登记时间">
<template #note>
<pass-time-label :timestamp="eventRecord.registeredAt" underline />
</template>
</t-cell>
<t-cell v-if="hasEventNumberValue" title="事件数值" :note="getEventNumberValueText()" />
<t-cell v-if="hasSubmittedPointValue" title="提交积分值" :note="getPointText(eventRecord.pointValue)" />
<t-cell v-if="hasEarnedPointValue" title="记录获得积分" :note="getPointText(eventRecord.earnedPointValue)" />
<t-cell title="登记人" :note="Text.display(eventRecord.operator?.nick || eventRecord.operator?.name)" />
<t-cell title="记录备注" :note="Text.display(eventRecord.remark)" />
</template>
<div v-else class="empty" v-text="getRecordEmptyText()" />
</t-cell-group>
<t-cell-group title="命中规则" theme="card">
<template v-if="hitRule">
<t-cell title="规则名称" :note="hitRule.name || '未命名规则'" />
<t-cell title="触发类型" :note="getTriggerTypeText(hitRule.triggerType)" />
<t-cell title="积分模式" :note="getPointModeText(hitRule.pointMode)" />
<t-cell title="规则积分" :note="getRulePointText(hitRule)" />
<t-cell title="匹配条件" :note="getRuleConditionText(hitRule)" />
<t-cell title="规则状态" :note="getRuleStatusText(hitRule.status)" />
<t-cell title="规则优先级" :note="getPointText(hitRule.priority)" />
<t-cell title="命中后停止匹配" :note="hitRule.exclusive ? '是' : '否'" />
<t-cell title="规则备注" :note="Text.display(hitRule.remark)" />
</template>
<div v-else class="empty" v-text="getRuleEmptyText()" />
</t-cell-group>
</template>
<t-empty v-else :description="isLoading ? '正在加载积分流水' : '积分流水快照不存在,请从积分流水列表进入'" />
</div>
</template>
<script setup lang="ts">
import { PassTimeLabel, Text, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import PointAPI from "@/api/PointAPI";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import type { Customer } from "@/types/Customer";
import type { EventItem } from "@/types/EventItem";
import type { EventRecord } from "@/types/EventRecord";
import type { PointLedger, PointRule, PointRuleMode, PointRuleStatus, PointRuleTriggerType } from "@/types/Point";
import { formatEventNumberValue, getEventValueUnitText } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
type SourcePayloadItem = {
key: string;
value: string;
};
defineOptions({
name: "PointLedgerDetail"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
// ---------- 权限 ----------
const canReadCustomer = computed(() => userStore.hasPermissions(permissionCodes.customer.read));
const canReadEvent = computed(() => userStore.hasPermissions(permissionCodes.event.read));
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
const canReadRule = computed(() => userStore.hasPermissions(permissionCodes.pointRule.read));
// ---------- 流水与关联数据 ----------
const ledger = ref<PointLedger>();
const customer = ref<Customer>();
const triggerEvent = ref<EventItem>();
const sourceEvent = ref<EventItem>();
const eventRecord = ref<EventRecord>();
const hitRule = ref<PointRule>();
const isLoading = ref(false);
const hasEventNumberValue = computed(() => eventRecord.value?.numberValue !== undefined && eventRecord.value?.numberValue !== null);
const hasSubmittedPointValue = computed(() => eventRecord.value?.pointValue !== undefined && eventRecord.value?.pointValue !== null);
const hasEarnedPointValue = computed(() => eventRecord.value?.earnedPointValue !== undefined && eventRecord.value?.earnedPointValue !== null);
const sourcePayloadList = computed<SourcePayloadItem[]>(() => {
const payload = ledger.value?.sourcePayload;
if (!payload) {
return [];
}
return Object.entries(payload).map(([key, value]) => ({
key,
value: formatSourceValue(value)
}));
});
function getQueryText(key: string): string {
const value = route.query[key];
return Array.isArray(value) ? String(value[0] || "") : String(value || "");
}
function parseLedgerSnapshot(): PointLedger | undefined {
const snapshotText = getQueryText("snapshot");
if (!snapshotText) {
return undefined;
}
try {
const snapshot = JSON.parse(snapshotText) as PointLedger;
if (!snapshot || typeof snapshot !== "object") {
return undefined;
}
return {
...snapshot,
id: String(route.params.id || snapshot.id || "")
};
} catch (error) {
console.warn("积分流水快照解析失败", error);
return undefined;
}
}
async function loadOptional<T>(loader: () => Promise<T>, message: string): Promise<T | undefined> {
try {
return await loader();
} catch (error) {
console.warn(message, error);
return undefined;
}
}
async function loadPageData(): Promise<void> {
ledger.value = parseLedgerSnapshot();
customer.value = undefined;
triggerEvent.value = undefined;
sourceEvent.value = undefined;
eventRecord.value = undefined;
hitRule.value = undefined;
if (!ledger.value) {
return;
}
isLoading.value = true;
try {
if (ledger.value.eventRecordId && canReadEventRecord.value) {
eventRecord.value = await loadOptional(() => EventRecordAPI.detail(ledger.value!.eventRecordId!), "积分流水关联事件记录加载失败");
}
const eventId = eventRecord.value?.eventId || ledger.value.eventId;
const eventResult = eventRecord.value?.event
? Promise.resolve(eventRecord.value.event)
: eventId && canReadEvent.value
? loadOptional(() => EventAPI.detail(eventId), "积分流水触发事件加载失败")
: Promise.resolve(undefined);
const ruleResult = ledger.value.ruleId && canReadRule.value
? loadOptional(() => PointAPI.ruleDetail(ledger.value!.ruleId!), "积分流水命中规则加载失败")
: Promise.resolve(undefined);
const customerResult = !eventRecord.value?.customer && ledger.value.customerId && canReadCustomer.value
? loadOptional(() => CustomerAPI.detail(ledger.value!.customerId!), "积分流水客户加载失败")
: Promise.resolve(undefined);
[triggerEvent.value, hitRule.value, customer.value] = await Promise.all([eventResult, ruleResult, customerResult]);
if (eventRecord.value?.customer) {
customer.value = eventRecord.value.customer;
}
const sourceEventId = hitRule.value?.conditionJson?.sourceEventId;
if (sourceEventId && sourceEventId !== eventId && canReadEvent.value) {
sourceEvent.value = await loadOptional(() => EventAPI.detail(sourceEventId), "积分规则来源事件加载失败");
}
} finally {
isLoading.value = false;
}
}
// ---------- 展示 ----------
function getChangeTypeText(changeType?: PointLedger["changeType"]): string {
return ({
EARN: "获得积分",
REVOKE: "撤销积分",
ADJUST: "人工调整"
} as Record<string, string>)[changeType || ""] || "未知流水";
}
function getDeltaTheme(pointDelta?: number): "success" | "danger" {
return pointDelta !== undefined && 0 < pointDelta ? "success" : "danger";
}
function getPointDeltaText(pointDelta?: number): string {
if (pointDelta === undefined || pointDelta === null) {
return "-";
}
return `${0 < pointDelta ? "+" : ""}${pointDelta} 分`;
}
function getPointText(value?: number | null): string {
return value === undefined || value === null ? "-" : `${value} 分`;
}
function getEventValueTypeText(event: EventItem): string {
if (event.valueType === "NUMBER") {
return `数值事件(${getEventValueUnitText(event.valueUnit)})`;
}
return "普通事件";
}
function getEventNumberValueText(): string {
return formatEventNumberValue(eventRecord.value?.event || triggerEvent.value, eventRecord.value?.numberValue);
}
function getTriggerTypeText(triggerType?: PointRuleTriggerType): string {
return ({
EVERY_TIME: "每次登记",
FIRST_TIME: "首次登记",
AFTER_COUNT_EVERY_TIME: "达到阈值后每次登记",
SOURCE_VALUE: "登记时自定义积分",
CUSTOMER_EVENT_VALUE_TIER: "按来源事件累计分档"
} as Record<string, string>)[triggerType || ""] || "未知触发类型";
}
function getPointModeText(pointMode?: PointRuleMode): string {
return ({
FIXED: "固定积分",
SOURCE_VALUE: "使用登记时填写的积分",
TIER: "分档积分"
} as Record<string, string>)[pointMode || ""] || "未知积分模式";
}
function getRulePointText(rule: PointRule): string {
if (rule.pointMode === "TIER") {
return rule.conditionJson?.tiers?.length ? `${rule.conditionJson.tiers.length} 档` : "分档积分";
}
return getPointText(rule.pointValue);
}
function getRuleConditionText(rule: PointRule): string {
const condition = rule.conditionJson;
if (!condition) {
return "无额外条件";
}
const textList: string[] = [];
if (condition.min !== undefined || condition.max !== undefined) {
textList.push(`数值范围 ${condition.min ?? "不限"}~${condition.max ?? "不限"}`);
}
if (condition.threshold !== undefined) {
textList.push(`累计达到 ${condition.threshold} 次`);
}
if (condition.includeCurrent !== undefined) {
textList.push(condition.includeCurrent ? "包含本次登记" : "不含本次登记");
}
if (condition.window) {
textList.push(`累计窗口 ${condition.window.amount} ${condition.window.unit === "YEAR" ? "年" : condition.window.unit}`);
}
if (condition.sourceEventId) {
textList.push(`来源事件:${sourceEvent.value?.name || sourceEvent.value?.id || condition.sourceEventId}`);
}
if (condition.tiers?.length) {
textList.push(condition.tiers.map(tier => `${tier.min}~${tier.max ?? "以上"}:${tier.pointValue} 分`).join(","));
}
return textList.join(";") || "无额外条件";
}
function getRuleStatusText(status?: PointRuleStatus): string {
return ({
DRAFT: "草稿",
ACTIVE: "启用",
INACTIVE: "停用",
DELETED: "已删除"
} as Record<string, string>)[status || ""] || "未知状态";
}
function getEventEmptyText(): string {
if (ledger.value?.changeType === "ADJUST") {
return "该流水为人工调整,未关联触发事件";
}
return canReadEvent.value ? "未找到触发事件" : "暂无触发事件查看权限";
}
function getRecordEmptyText(): string {
if (ledger.value?.changeType === "ADJUST") {
return "该流水为人工调整,未关联事件记录";
}
return canReadEventRecord.value ? "未找到事件记录" : "暂无事件记录查看权限";
}
function getRuleEmptyText(): string {
if (ledger.value?.changeType === "ADJUST") {
return "该流水为人工调整,未命中积分规则";
}
return canReadRule.value ? "未找到命中规则" : "暂无命中规则查看权限";
}
function formatSourceValue(value: unknown): string {
if (value === null || value === undefined) {
return "-";
}
if (typeof value === "string") {
return value || "-";
}
const text = JSON.stringify(value);
return text === undefined ? String(value) : text;
}
// ---------- 导航 ----------
async function toCustomerDetail(): Promise<void> {
if (customer.value?.id) {
await router.push(`/management/customers/${customer.value.id}`);
}
}
watch(() => [route.params.id, route.query.snapshot], async () => {
await loadPageData();
}, { immediate: true });
</script>
<style scoped lang="less">
.point-ledger-detail {
gap: 1rem;
min-height: 100vh;
padding: calc(var(--app-nav-height) + 1rem) 0 1rem;
display: flex;
flex-direction: column;
.customer-profile,
.payload-panel {
margin: 0 1rem;
}
.empty {
padding: 1.5rem 1rem;
color: #52607a;
font-size: .875rem;
text-align: center;
}
.payload-panel {
.title {
margin: 0 0 .5rem;
font-size: 1rem;
}
}
}
:global(html.theme-dark) {
.point-ledger-detail {
.empty {
color: #94a3b8;
}
.payload-panel .title {
color: #e2e8f0;
}
}
}
</style>
+31 -16
View File
@@ -83,9 +83,9 @@
v-for="item in pageResult.list" v-for="item in pageResult.list"
:key="item.id" :key="item.id"
class="item" class="item"
:arrow="!!item.eventRecordId" :arrow="!!item.id"
:hover="!!item.eventRecordId" :hover="!!item.id"
@click="toRecord(item)" @click="toDetail(item)"
> >
<template #leftIcon> <template #leftIcon>
<t-image <t-image
@@ -410,16 +410,6 @@ function buildListOptions(): { allStores?: boolean; storeId?: string } | undefin
// ---------- 展示与导航 ---------- // ---------- 展示与导航 ----------
// function getLedgerTitle(item: PointLedger): string {
// if (item.changeType === "EARN") {
// return "登记事件获得积分";
// }
// if (item.changeType === "REVOKE") {
// return "删除登记记录撤销积分";
// }
// return "人工调整积分";
// }
function getLedgerDescription(item: PointLedger): string { function getLedgerDescription(item: PointLedger): string {
const parts = [`余额 ${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`]; const parts = [`余额 ${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`];
if (item.remark) { if (item.remark) {
@@ -441,10 +431,35 @@ function getLedgerTheme(item: PointLedger): string {
return item.pointDelta && 0 < item.pointDelta ? "increase" : "decrease"; return item.pointDelta && 0 < item.pointDelta ? "increase" : "decrease";
} }
async function toRecord(item: PointLedger): Promise<void> { async function toDetail(item: PointLedger): Promise<void> {
if (item.eventRecordId) { if (!item.id) {
await router.push(`/management/registration-event-records/${item.eventRecordId}`); return;
} }
const snapshot: PointLedger = {
id: item.id,
accountId: item.accountId,
storeId: item.storeId,
customerId: item.customerId,
changeType: item.changeType,
direction: item.direction,
pointDelta: item.pointDelta,
balanceBefore: item.balanceBefore,
balanceAfter: item.balanceAfter,
ruleId: item.ruleId,
eventId: item.eventId,
eventRecordId: item.eventRecordId,
sourcePayload: item.sourcePayload,
idempotencyKey: item.idempotencyKey,
remark: item.remark,
operatorUserId: item.operatorUserId,
occurredAt: item.occurredAt
};
await router.push({
name: "PointLedgerDetail",
params: { id: item.id },
query: { snapshot: JSON.stringify(snapshot) }
});
} }
async function toAdjust(): Promise<void> { async function toAdjust(): Promise<void> {
+1
View File
@@ -227,6 +227,7 @@ onMounted(loadTags);
.name { .name {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
line-height: 1;
text-align: center; text-align: center;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
+15
View File
@@ -1,6 +1,7 @@
import type { RouteRecordRaw } from "vue-router"; import type { RouteRecordRaw } from "vue-router";
import PointAccount from "@/pages/point/PointAccount.vue"; import PointAccount from "@/pages/point/PointAccount.vue";
import PointAdjust from "@/pages/point/PointAdjust.vue"; import PointAdjust from "@/pages/point/PointAdjust.vue";
import PointLedgerDetail from "@/pages/point/PointLedgerDetail.vue";
import PointLedgerList from "@/pages/point/PointLedgerList.vue"; import PointLedgerList from "@/pages/point/PointLedgerList.vue";
import PointRuleEdit from "@/pages/point/PointRuleEdit.vue"; import PointRuleEdit from "@/pages/point/PointRuleEdit.vue";
import PointRuleList from "@/pages/point/PointRuleList.vue"; import PointRuleList from "@/pages/point/PointRuleList.vue";
@@ -34,6 +35,20 @@ const pointRoutes: RouteRecordRaw[] = [
darkBodyBackground: "#10161f" darkBodyBackground: "#10161f"
} }
}, },
{
path: "/management/points/ledger/:id",
name: "PointLedgerDetail",
component: PointLedgerDetail,
meta: {
depth: 4,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "积分流水详情",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
}
},
{ {
path: "/management/points/adjust", path: "/management/points/adjust",
name: "PointAdjust", name: "PointAdjust",
+10
View File
@@ -40,6 +40,16 @@ html.theme-dark body {
.t-form__label { .t-form__label {
color: #555; color: #555;
flex: none;
min-width: unset;
white-space: nowrap;
text-overflow: unset;
}
.t-cell__title {
min-width: unset;
white-space: nowrap;
text-overflow: unset;
} }
.load-state { .load-state {
+1 -1
View File
@@ -246,7 +246,7 @@ function renderInvitePoster(options: InvitePosterOptions): HTMLCanvasElement {
context.fillText("糕店客户管理系统", 600, 1550); context.fillText("糕店客户管理系统", 600, 1550);
context.fillStyle = "#64748B"; context.fillStyle = "#64748B";
context.font = "400 40px Microsoft YaHei, sans-serif"; context.font = "400 40px Microsoft YaHei, sans-serif";
context.fillText("客户档案 | 事件登记 | 积分激励 | 门店协同 | AI 分析", 600, 1645); context.fillText("客户档案 | 事件登记 | 积分激励 | 门店协同", 600, 1645);
context.fillStyle = "#94A3B8"; context.fillStyle = "#94A3B8";
context.font = "400 40px Microsoft YaHei, sans-serif"; context.font = "400 40px Microsoft YaHei, sans-serif";
context.fillText("本邀请仅限本人使用,请勿转发", 600, 1760); context.fillText("本邀请仅限本人使用,请勿转发", 600, 1760);