add ConsumptionRecordList.vue

This commit is contained in:
Timi
2026-09-07 17:40:20 +08:00
parent 4227c64197
commit c884d87997
6 changed files with 393 additions and 8 deletions
+11
View File
@@ -0,0 +1,11 @@
import { axios } from "timi-web-api";
import type { ConsumptionRecordPage, ConsumptionRecordPageResult } from "@/types/ConsumptionRecord";
import { withCurrentStoreId } from "@/utils/store";
const BASE_URI = "/gao/customer/consumption-record";
async function list(page: ConsumptionRecordPage): Promise<ConsumptionRecordPageResult> {
return await axios.post(`${BASE_URI}/list`, withCurrentStoreId(page));
}
export default { list };
+8 -8
View File
@@ -112,7 +112,7 @@ import type { PhotoItem } from "@/types/Photo";
import type { PointLedgerChangeType } from "@/types/Point";
import { getAttachmentReadURL } from "@/utils/attachment";
import { buildEventOptionSelectionMap } from "@/utils/eventOption";
import { getEventBizTypeText, isPackagePurchaseEvent } from "@/utils/event";
import { getEventBizTypeText, isRetiredEvent } from "@/utils/event";
import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue";
import { Message } from "@/utils/message";
@@ -135,10 +135,10 @@ const isVerifying = ref(false);
const isDeleting = ref(false);
const verifyDialogVisible = ref(false);
const isPackagePurchaseRecord = computed(() => isPackagePurchaseEvent(record.value?.event));
const canUpdateRecord = computed(() => !!record.value && !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.update));
const canVerifyRecord = computed(() => !!record.value && !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.verify));
const canDeleteRecord = computed(() => !!record.value && !isPackagePurchaseRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.delete));
const isRetiredRecord = computed(() => isRetiredEvent(record.value?.event));
const canUpdateRecord = computed(() => !!record.value && !isRetiredRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.update));
const canVerifyRecord = computed(() => !!record.value && !isRetiredRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.verify));
const canDeleteRecord = computed(() => !!record.value && !isRetiredRecord.value && userStore.hasPermissions(permissionCodes.eventRecord.delete));
const canReadEvent = computed(() => userStore.hasPermissions(permissionCodes.event.read));
const recordPhotoItemList = computed(() => (record.value?.attachmentList || [])
.filter(item => !!item.id)
@@ -152,7 +152,7 @@ const recordPhotoList = computed(() => recordPhotoItemList.value.map(item => ite
async function submitPhotoItems(photoItems: PhotoItem[]): Promise<void> {
const currentRecord = record.value;
if (!currentRecord?.id || isPackagePurchaseRecord.value) {
if (!currentRecord?.id || isRetiredRecord.value) {
throw new Error("登记记录不存在");
}
@@ -263,7 +263,7 @@ async function confirmVerifyRecord(): Promise<void> {
async function submitVerifyRecord(record: EventRecord, attachmentList: Attachment[]): Promise<void> {
const id = record.id;
if (!id || isPackagePurchaseRecord.value) {
if (!id || isRetiredRecord.value) {
throw new Error("登记事件记录不存在");
}
@@ -316,7 +316,7 @@ function confirmRemoveRecord(): void {
}
async function removeRecord(id: string): Promise<void> {
if (isDeleting.value || isPackagePurchaseRecord.value) {
if (isDeleting.value || isRetiredRecord.value) {
return;
}
@@ -0,0 +1,323 @@
<template>
<div v-can="permissionCodes.salesOrder.read" class="consumption-record-list">
<div class="filters">
<t-input
:value="customerDisplayText"
class="customer-input"
readonly
borderless
placeholder="选择或扫码客户"
@click="customerSelectorVisible = true"
>
<template #suffix-icon>
<t-button size="small" variant="text" :content="scannerVisible ? '关闭扫码' : '扫码'" @click.stop="toggleScanner" />
</template>
</t-input>
<t-dropdown-menu>
<t-dropdown-item v-model="sourceType" label="消费来源" :options="sourceTypeOptions" @change="reloadRecords" />
<t-dropdown-item v-model="status" label="消费状态" :options="statusOptions" @change="reloadRecords" />
</t-dropdown-menu>
<code-scanner
v-if="scannerVisible"
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRecords"
@scrolltolower="loadRecords"
>
<div class="list-wrap">
<t-cell-group class="summary" theme="card">
<t-cell title="记录数" :note="`共 ${total} 条`" />
<t-cell title="消费金额合计" :note="`¥${formatPackageAmount(amountTotal)}`" />
</t-cell-group>
<t-cell-group v-if="recordList.length" theme="card">
<t-cell
v-for="(item, index) in recordList"
:key="item.id"
v-entrance-transition="index * 30"
:title="getRecordTitle(item)"
:note="getRecordNote(item)"
:arrow="canOpenSource(item)"
@click="openSource(item)"
>
<template #description>
<div class="description">
<span v-text="getCustomerText(item)" />
<span v-text="getEffectiveAtText(item)" />
</div>
</template>
<template #rightIcon>
<div class="right">
<t-tag :content="getSourceText(item.sourceType)" size="small" theme="primary" variant="light-outline" />
<t-tag :content="getStatusText(item.status)" size="small" :theme="item.status === 'ACTIVE' ? 'success' : 'danger'" variant="light-outline" />
<strong v-text="`¥${formatPackageAmount(item.amount)}`" />
</div>
</template>
</t-cell>
</t-cell-group>
<div v-else class="center-tips" v-text="isLoading ? '正在加载消费记录' : '暂无消费记录'" />
<div v-if="recordList.length" class="center-tips" v-text="loadStateText" />
</div>
</t-pull-down-refresh>
<customer-selector
v-model="customerId"
v-model:visible="customerSelectorVisible"
@change="handleCustomerChange"
/>
</div>
</template>
<script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue";
import { Time } from "timi-web";
import { useUserStore } from "timi-web-api";
import ConsumptionRecordAPI from "@/api/ConsumptionRecordAPI";
import CustomerAPI from "@/api/CustomerAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { ConsumptionRecord, ConsumptionRecordPage, ConsumptionRecordSourceType, ConsumptionRecordStatus } from "@/types/ConsumptionRecord";
import { getErrorMessage } from "@/utils/error";
import { Message } from "@/utils/message";
import { formatPackageAmount } from "@/utils/package";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "ConsumptionRecordList" });
const pageSize = 20;
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const sourceType = ref<ConsumptionRecordSourceType | "ALL">("ALL");
const status = ref<ConsumptionRecordStatus | "ALL">("ALL");
const customerId = ref("");
const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false);
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别。");
let latestDecodedText = "";
const recordList = ref<ConsumptionRecord[]>([]);
const page = ref(0);
const total = ref(0);
const amountTotal = ref(0);
const isLoading = ref(false);
const isRefreshing = ref(false);
const isNoMore = ref(false);
const sourceTypeOptions = [{ label: "全部来源", value: "ALL" }, { label: "销售订单", value: "SALES_ORDER" }, { label: "历史消费", value: "LEGACY_EVENT" }];
const statusOptions = [{ label: "全部状态", value: "ALL" }, { label: "有效", value: "ACTIVE" }, { label: "已失效", value: "REVOKED" }];
const canReadEventRecord = computed(() => userStore.hasPermissions(permissionCodes.eventRecord.read));
const loadStateText = computed(() => isLoading.value ? "正在加载更多" : isNoMore.value ? "没有更多记录" : "上拉加载更多");
const customerDisplayText = computed(() => selectedCustomer.value?.name || selectedCustomer.value?.code || "");
function applyRouteQuery(): void {
const getText = (value: unknown): string => Array.isArray(value) ? String(value[0] || "") : String(value || "");
const routeSourceType = getText(route.query.sourceType);
const routeStatus = getText(route.query.status);
customerId.value = getText(route.query.customerId);
sourceType.value = ["ALL", "LEGACY_EVENT", "SALES_ORDER"].includes(routeSourceType) ? routeSourceType as ConsumptionRecordSourceType | "ALL" : "ALL";
status.value = ["ALL", "ACTIVE", "REVOKED"].includes(routeStatus) ? routeStatus as ConsumptionRecordStatus | "ALL" : "ALL";
}
async function handleCustomerChange(value: string): Promise<void> {
customerId.value = value;
selectedCustomer.value = value ? await CustomerAPI.detail(value) : undefined;
await reloadRecords();
}
async function loadSelectedCustomer(): Promise<void> {
selectedCustomer.value = customerId.value ? await CustomerAPI.detail(customerId.value) : undefined;
}
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
scanStatus.value = "正在打开相机。";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const code = rawText.trim();
if (!code || code === latestDecodedText) {
return;
}
latestDecodedText = code;
try {
const customer = await CustomerAPI.findByCode(code);
if (!customer?.id) {
scanStatus.value = `客户 ${code} 不存在。`;
return;
}
customerId.value = customer.id;
selectedCustomer.value = customer;
scanStatus.value = `已识别 ${customer.name || customer.code || code}。`;
scannerVisible.value = false;
await reloadRecords();
} catch (error) {
scanStatus.value = getErrorMessage(error, "客户查询失败,请重试。");
}
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
function buildPage(): ConsumptionRecordPage {
return {
customerId: customerId.value || undefined,
sourceType: sourceType.value === "ALL" ? undefined : sourceType.value,
status: status.value === "ALL" ? undefined : status.value,
index: page.value,
size: pageSize
};
}
async function reloadRecords(value?: DropdownValue): Promise<void> {
if (value && Array.isArray(value)) {
return;
}
isRefreshing.value = true;
page.value = 0;
total.value = 0;
amountTotal.value = 0;
recordList.value = [];
isNoMore.value = false;
try {
await loadRecords();
} catch (error) {
Message.error(getErrorMessage(error, "消费记录加载失败"));
} finally {
isRefreshing.value = false;
}
}
async function loadRecords(): Promise<void> {
if (isLoading.value || isNoMore.value) {
return;
}
isLoading.value = true;
try {
const result = await ConsumptionRecordAPI.list(buildPage());
const list = result.list || [];
recordList.value = recordList.value.concat(list);
total.value = result.total || 0;
amountTotal.value = result.amountTotal || 0;
page.value += 1;
isNoMore.value = list.length < pageSize || total.value <= recordList.value.length;
} finally {
isLoading.value = false;
}
}
function getRecordTitle(record: ConsumptionRecord): string {
if (record.sourceType === "LEGACY_EVENT") {
return record.sourcePayload?.eventName || "历史消费事件";
}
return record.orderType === "PACKAGE" ? "套餐订单消费" : "产品订单消费";
}
function getRecordNote(record: ConsumptionRecord): string {
if (record.status === "REVOKED") {
return record.revokeReason || "消费事实已失效";
}
return record.sourceType === "LEGACY_EVENT" ? "查看原始登记记录" : "查看销售订单";
}
function getCustomerText(record: ConsumptionRecord): string {
return record.customer?.name || record.customer?.code || record.customerId || "未知客户";
}
function getEffectiveAtText(record: ConsumptionRecord): string {
return record.effectiveAt ? Time.toDateTime(record.effectiveAt) : "未知生效时间";
}
function getSourceText(value?: ConsumptionRecordSourceType): string {
return value === "LEGACY_EVENT" ? "历史消费" : "销售订单";
}
function getStatusText(value?: ConsumptionRecordStatus): string {
return value === "REVOKED" ? "已失效" : "有效";
}
function canOpenSource(record: ConsumptionRecord): boolean {
return record.sourceType === "SALES_ORDER" ? !!record.salesOrderId : canReadEventRecord.value && !!record.sourceId;
}
async function openSource(record: ConsumptionRecord): Promise<void> {
if (!canOpenSource(record)) {
return;
}
if (record.sourceType === "SALES_ORDER" && record.salesOrderId) {
await router.push({ name: "SalesOrderDetail", params: { id: record.salesOrderId } });
return;
}
if (record.sourceType === "LEGACY_EVENT" && record.sourceId) {
await router.push({ name: "EventRecordDetail", params: { id: record.sourceId } });
}
}
onMounted(async () => {
navBarStore.clearRight();
applyRouteQuery();
await loadSelectedCustomer();
await reloadRecords();
});
</script>
<style scoped lang="less">
.consumption-record-list {
min-height: 100vh;
padding: var(--app-nav-height) 0 1rem;
.filters {
top: var(--app-nav-height);
z-index: 1;
position: sticky;
background: var(--td-bg-color-page);
box-shadow: var(--td-shadow-1);
.customer-input {
--td-input-vertical-padding: .5rem 1rem;
margin: .5rem 1rem;
background: var(--td-bg-color-container);
border: 1px solid var(--td-component-border);
border-radius: 1rem;
}
}
.list-wrap {
padding: 1rem 0;
.description {
gap: .5rem;
display: flex;
color: var(--td-text-color-secondary);
}
.summary {
margin-bottom: 1rem;
}
.right {
gap: .35rem;
display: flex;
align-items: flex-end;
flex-direction: column;
strong {
color: var(--td-error-color);
}
}
}
}
</style>
+6
View File
@@ -99,6 +99,12 @@ const menuGroupList = computed<MenuGroup[]>(() => [
path: "/management/order-fulfillments",
title: "统一履约",
visible: userStore.hasPermissions(permissionCodes.orderFulfillment.read)
},
{
icon: "money",
path: "/management/consumption-records",
title: "消费记录",
visible: userStore.hasPermissions(permissionCodes.salesOrder.read)
}
]
},
+2
View File
@@ -9,6 +9,7 @@ import SalesOrderCancel from "@/pages/salesOrder/SalesOrderCancel.vue";
import FundRecordList from "@/pages/salesOrder/FundRecordList.vue";
import FundRecordDetail from "@/pages/salesOrder/FundRecordDetail.vue";
import FundRecordRevoke from "@/pages/salesOrder/FundRecordRevoke.vue";
import ConsumptionRecordList from "@/pages/salesOrder/ConsumptionRecordList.vue";
function meta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw["meta"] {
return { depth, navBarVisible: true, navBarCanBack: true, navBarTitle: title, tabBarVisible: false, bodyBackground: background, darkBodyBackground: "#10161f" };
@@ -16,6 +17,7 @@ function meta(title: string, depth = 3, background = "#F5F5F8"): RouteRecordRaw[
export default [
{ path: "/management/sales-orders", name: "SalesOrderList", component: SalesOrderList, meta: meta("销售订单") },
{ path: "/management/consumption-records", name: "ConsumptionRecordList", component: ConsumptionRecordList, meta: meta("消费记录") },
{
path: "/management/sales-orders/create",
name: "SalesOrderCreate",
+43
View File
@@ -0,0 +1,43 @@
import type { Model, PageResult } from "timi-web-api";
import type { Customer } from "@/types/Customer";
import type { GaoSalesOrderType } from "@/types/SalesOrder";
export type ConsumptionRecordSourceType = "LEGACY_EVENT" | "SALES_ORDER";
export type ConsumptionRecordStatus = "ACTIVE" | "REVOKED";
export type ConsumptionRecord = {
storeId?: string;
customerId?: string;
sourceType?: ConsumptionRecordSourceType;
sourceId?: string;
salesOrderId?: string;
orderType?: GaoSalesOrderType;
amount?: number;
effectiveAt?: number;
status?: ConsumptionRecordStatus;
revokedAt?: number;
revokeReason?: string | null;
sourcePayload?: {
eventId?: string;
eventName?: string;
eventRecordId?: string;
migrationBatch?: string;
registeredAt?: number;
};
customer?: Customer;
} & Model;
export type ConsumptionRecordPage = {
storeId?: string;
customerId?: string;
sourceType?: ConsumptionRecordSourceType;
status?: ConsumptionRecordStatus;
beginAt?: number;
endAt?: number;
index: number;
size: number;
};
export type ConsumptionRecordPageResult = PageResult<ConsumptionRecord> & {
amountTotal?: number;
};