add PointLedgerDetail.vue
This commit is contained in:
@@ -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>
|
||||
@@ -83,9 +83,9 @@
|
||||
v-for="item in pageResult.list"
|
||||
:key="item.id"
|
||||
class="item"
|
||||
:arrow="!!item.eventRecordId"
|
||||
:hover="!!item.eventRecordId"
|
||||
@click="toRecord(item)"
|
||||
:arrow="!!item.id"
|
||||
:hover="!!item.id"
|
||||
@click="toDetail(item)"
|
||||
>
|
||||
<template #leftIcon>
|
||||
<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 {
|
||||
const parts = [`余额 ${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`];
|
||||
if (item.remark) {
|
||||
@@ -441,10 +431,35 @@ function getLedgerTheme(item: PointLedger): string {
|
||||
return item.pointDelta && 0 < item.pointDelta ? "increase" : "decrease";
|
||||
}
|
||||
|
||||
async function toRecord(item: PointLedger): Promise<void> {
|
||||
if (item.eventRecordId) {
|
||||
await router.push(`/management/registration-event-records/${item.eventRecordId}`);
|
||||
async function toDetail(item: PointLedger): Promise<void> {
|
||||
if (!item.id) {
|
||||
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> {
|
||||
|
||||
@@ -227,6 +227,7 @@ onMounted(loadTags);
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user