623 lines
18 KiB
Vue
623 lines
18 KiB
Vue
<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>
|