Files
gao-customer-h5/src/pages/salesOrder/ConsumptionRecordList.vue
T
2026-09-09 14:57:41 +08:00

623 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div v-can="permissionCodes.salesOrder.read" class="consumption-record-list">
<div class="filters">
<t-input
class="keyword"
:value="customerDisplayText"
readonly
borderless
placeholder="选择或扫码客户"
@click="customerSelectorVisible = true"
>
<template #suffix-icon>
<div class="filter-actions">
<t-button
v-if="customerId"
shape="circle"
theme="danger"
variant="text"
size="extra-small"
aria-label="清除客户选择"
@click.stop="clearCustomer"
>
<t-icon name="close" size="1rem" />
</t-button>
<t-button
size="extra-small"
theme="primary"
variant="outline"
:content="scannerVisible ? '关闭扫码' : '扫码'"
@click.stop="toggleScanner"
/>
</div>
</template>
</t-input>
<t-dropdown-menu class="menus">
<t-dropdown-item
v-model="dateFilterType"
:label="dateFilterLabel"
:options="dateOptions"
@change="selectDateFilter"
/>
<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>
</div>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reloadRecords"
@scrolltolower="loadRecords"
>
<div class="list-wrap">
<div v-if="scannerVisible" class="scanner-wrap">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将条码或二维码移入取景框。"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/>
</div>
<t-cell-group class="summary" theme="card">
<t-cell
v-if="dateFilterType !== 'all'"
title="开始时间"
:note="dateRange[0] || '-'"
arrow
@click="openStartTime"
/>
<t-cell
v-if="dateFilterType !== 'all'"
title="结束时间"
:note="dateRange[1] || '-'"
arrow
@click="openEndTime"
/>
<t-cell title="记录数" :note="`共 ${total} 条`" />
<t-cell title="消费金额合计" :note="`¥${formatPackageAmount(amountTotal)}`" />
</t-cell-group>
<t-cell-group v-if="recordList.length" class="empty-cell-title-margin" theme="card">
<t-cell
class="item"
v-for="(item, index) in recordList"
:key="item.id"
:arrow="canOpenSource(item)"
v-entrance-transition="index * 30"
@click="openSource(item)"
>
<template #leftIcon>
<t-image
v-if="getCustomerPhotoURL(item.customer, 64)"
:src="getCustomerPhotoURL(item.customer, 64)"
:alt="getCustomerText(item)"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="getCustomerText(item).slice(0, 1)" />
</template>
<template #title>
<div class="title">
<span class="customer" v-text="getCustomerText(item)" />
<t-tag
class="value"
size="small"
:theme="item.sourceType === 'LEGACY_EVENT' ? 'default' : 'primary'"
variant="light"
:content="getRecordTitle(item)"
/>
</div>
</template>
<template #description>
<div class="description">
<t-tag
size="small"
:theme="item.status === 'ACTIVE' ? 'success' : 'danger'"
variant="light-outline"
:content="getStatusText(item.status)"
/>
<pass-time-label class="time" :timestamp="item.effectiveAt" underline />
</div>
</template>
<template #note>
<strong class="red" v-text="`¥${formatPackageAmount(item.amount)}`" />
</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"
/>
<t-popup v-model="startVisible" placement="bottom">
<t-date-time-picker
v-if="startVisible"
v-model="startDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="开始时间"
:default-value="dateRange[0]"
@cancel="cancelStartTime"
@confirm="confirmStartTime"
/>
</t-popup>
<t-popup v-model="endVisible" placement="bottom">
<t-date-time-picker
v-if="endVisible"
v-model="endDraft"
format="YYYY-MM-DD HH:mm"
:mode="['date', 'minute']"
title="结束时间"
:default-value="dateRange[1]"
@cancel="cancelEndTime"
@confirm="confirmEndTime"
/>
</t-popup>
</div>
</template>
<script setup lang="ts">
import type { DropdownValue } from "tdesign-mobile-vue";
import { PassTimeLabel, 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 { getCustomerPhotoURL } from "@/utils/customer";
import { Message } from "@/utils/message";
import { formatPackageAmount } from "@/utils/package";
import { permissionCodes } from "@/utils/permission";
defineOptions({ name: "ConsumptionRecordList" });
type DateFilterType = "today" | "yesterday" | "lastWeek" | "thisMonth" | "lastMonth" | "all" | "custom";
const pageSize = 20;
const route = useRoute();
const router = useRouter();
const navBarStore = useNavBarStore();
const userStore = useUserStore();
const dateOptions: Array<{ label: string; value: DateFilterType }> = [
{ label: "全部时间", value: "all" },
{ label: "今天", value: "today" },
{ label: "昨天", value: "yesterday" },
{ label: "上周", value: "lastWeek" },
{ label: "本月", value: "thisMonth" },
{ label: "上个月", value: "lastMonth" },
{ label: "自定义范围", value: "custom" }
];
const sourceType = ref<ConsumptionRecordSourceType | "ALL">("ALL");
const status = ref<ConsumptionRecordStatus | "ALL">("ALL");
const customerId = ref("");
const selectedCustomer = ref<Customer>();
const customerSelectorVisible = ref(false);
const dateFilterType = ref<DateFilterType>("all");
const dateRange = ref<[string, string]>(buildPresetRange("all"));
const startDraft = ref("");
const endDraft = ref("");
const startVisible = ref(false);
const endVisible = 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 || "");
const dateFilterLabel = computed(() => dateOptions.find(item => item.value === dateFilterType.value)?.label || "全部时间");
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);
const routeDateType = getText(route.query.dateType);
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";
dateFilterType.value = dateOptions.some(item => item.value === routeDateType) ? routeDateType as DateFilterType : "all";
dateRange.value = parseDateRange(dateFilterType.value, getText);
}
async function handleCustomerChange(value: string): Promise<void> {
customerId.value = value;
selectedCustomer.value = value ? await CustomerAPI.detail(value) : undefined;
await reloadRecords();
}
async function clearCustomer(): Promise<void> {
customerId.value = "";
selectedCustomer.value = undefined;
await reloadRecords();
}
async function selectDateFilter(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) {
return;
}
dateFilterType.value = String(value) as DateFilterType;
if (dateFilterType.value !== "custom") {
dateRange.value = buildPresetRange(dateFilterType.value);
await reloadRecords();
}
}
function openStartTime(): void {
startDraft.value = dateRange.value[0];
startVisible.value = true;
}
function openEndTime(): void {
endDraft.value = dateRange.value[1];
endVisible.value = true;
}
function cancelStartTime(): void {
startVisible.value = false;
startDraft.value = dateRange.value[0];
}
function cancelEndTime(): void {
endVisible.value = false;
endDraft.value = dateRange.value[1];
}
async function confirmStartTime(value?: string | number): Promise<void> {
startVisible.value = false;
dateRange.value = [String(value || startDraft.value), dateRange.value[1]];
await confirmCustomRange();
}
async function confirmEndTime(value?: string | number): Promise<void> {
endVisible.value = false;
dateRange.value = [dateRange.value[0], String(value || endDraft.value)];
await confirmCustomRange();
}
async function confirmCustomRange(): Promise<void> {
if (dateRange.value[0] && dateRange.value[1] && dateRange.value[1] < dateRange.value[0]) {
dateRange.value = [dateRange.value[1], dateRange.value[0]];
}
dateFilterType.value = "custom";
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 {
const [beginAt, endAt] = buildUnixRange();
return {
customerId: customerId.value || undefined,
sourceType: sourceType.value === "ALL" ? undefined : sourceType.value,
status: status.value === "ALL" ? undefined : status.value,
beginAt,
endAt,
index: page.value,
size: pageSize
};
}
function parseDateRange(type: DateFilterType, getText: (value: unknown) => string): [string, string] {
if (type !== "custom") {
return buildPresetRange(type);
}
return [getText(route.query.beginAt), getText(route.query.endAt)];
}
function buildPresetRange(type: Exclude<DateFilterType, "custom">): [string, string] {
if (type === "all") {
return ["", ""];
}
if (type === "lastWeek") {
const current = new Date();
current.setHours(0, 0, 0, 0);
const day = current.getDay() || 7;
const thisWeekMonday = new Date(current);
thisWeekMonday.setDate(current.getDate() - day + 1);
const lastWeekMonday = new Date(thisWeekMonday);
lastWeekMonday.setDate(thisWeekMonday.getDate() - 7);
const lastWeekSunday = new Date(thisWeekMonday);
lastWeekSunday.setDate(thisWeekMonday.getDate() - 1);
return [`${Time.formatDate(lastWeekMonday)} 00:00`, `${Time.formatDate(lastWeekSunday)} 23:59`];
}
if (type === "thisMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth(), 1);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(current)} 23:59`];
}
if (type === "lastMonth") {
const current = new Date();
const begin = new Date(current.getFullYear(), current.getMonth() - 1, 1);
const end = new Date(current.getFullYear(), current.getMonth(), 0);
return [`${Time.formatDate(begin)} 00:00`, `${Time.formatDate(end)} 23:59`];
}
const offsetMap: Record<Exclude<DateFilterType, "all" | "custom" | "lastWeek" | "thisMonth" | "lastMonth">, number> = {
today: 0,
yesterday: 1
};
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() - offsetMap[type]);
return [`${Time.formatDate(date)} 00:00`, `${Time.formatDate(date)} 23:59`];
}
function buildUnixRange(): [number | undefined, number | undefined] {
if (dateFilterType.value === "all") {
return [undefined, undefined];
}
return [
Time.toTimestamp(parseDateTime(dateRange.value[0], false)),
Time.toTimestamp(parseDateTime(dateRange.value[1], true))
];
}
function parseDateTime(value: string, isEnd: boolean): Date {
if (!value) {
return isEnd ? new Date() : new Date(0);
}
const [dateText, timeText = isEnd ? "23:59" : "00:00"] = value.split(" ");
const [year, month, day] = dateText.split("-").map(Number);
const [hour, minute] = timeText.split(":").map(Number);
return new Date(year, month - 1, day, hour || 0, minute || 0, isEnd ? 59 : 0, 0);
}
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 getCustomerText(record: ConsumptionRecord): string {
return record.customer?.name || record.customer?.code || record.customerId || "未知客户";
}
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);
left: 0;
right: 0;
z-index: 10;
display: flex;
padding: .75rem 1rem;
position: sticky;
background: #F5F5F8;
box-shadow: var(--td-shadow-1);
flex-direction: column;
.keyword {
--td-input-vertical-padding: 8px 14px;
border: 1px solid var(--td-component-border);
z-index: 1;
background: var(--td-bg-color-container);
border-radius: 1rem;
}
.filter-actions {
gap: .25rem;
display: flex;
align-items: center;
}
.menus {
--td-spacer-1: 6px;
width: 100%;
overflow: hidden;
margin-top: -1rem;
padding-top: .8rem;
background: #fff;
border: 1px solid rgba(148, 163, 184, .18);
border-radius: 0 0 1rem 1rem;
&:after {
display: none;
}
:deep(.t-dropdown-menu__item) {
min-width: 0;
}
:deep(.t-dropdown-menu__title) {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.list-wrap {
gap: 1rem;
display: flex;
padding-top: 1rem;
flex-direction: column;
.scanner-wrap {
margin: 0 1rem;
}
.item {
padding: 10px;
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title {
gap: .5rem;
display: flex;
align-items: center;
.customer {
font-size: .875rem;
}
}
.description {
gap: .5rem;
display: flex;
align-items: center;
.time {
font-size: .75rem;
}
}
}
}
}
:deep(.consumption-record-list .avatar .t-image__img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
:global(html.theme-dark) .consumption-record-list {
.fallback {
background: #115e59;
}
}
</style>