add ConsumptionRecordList.vue
This commit is contained in:
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user