update point ledger list style

This commit is contained in:
Timi
2026-08-13 00:46:11 +08:00
parent 206d2cbc81
commit 53c60d02cd
3 changed files with 275 additions and 150 deletions
+3 -2
View File
@@ -346,14 +346,15 @@ onMounted(async () => {
.point-account { .point-account {
gap: 1rem; gap: 1rem;
display: flex; display: flex;
padding: var(--app-nav-height) 0 calc(6rem + env(safe-area-inset-bottom, 0)); padding: calc(var(--app-nav-height) + 4rem) 0 calc(6rem + env(safe-area-inset-bottom, 0));
flex-direction: column; flex-direction: column;
> .header { > .header {
top: var(--app-nav-height); top: var(--app-nav-height);
width: 100%;
z-index: 10; z-index: 10;
padding: .5rem 1rem; padding: .5rem 1rem;
position: sticky; position: fixed;
.buttons { .buttons {
gap: 1rem; gap: 1rem;
+270 -148
View File
@@ -1,42 +1,126 @@
<template> <template>
<div v-can="permissionCodes.point.ledgerRead" class="point-ledger-list"> <div v-can="permissionCodes.point.ledgerRead" class="point-ledger-list">
<div class="filters"> <div class="header bg-white">
<t-button <div class="buttons">
class="customer-filter" <t-button
variant="outline" class="selector"
:content="customerFilterText" size="small"
@click="selectorVisible = true" theme="primary"
/> variant="outline"
<t-dropdown-menu> @click="selectorVisible = true"
<t-dropdown-item >
v-if="isGlobalManager" <template #icon>
v-model="selectedStoreId" <t-icon name="user-search" />
:label="selectedStoreName" </template>
:options="storeOptions" <span v-text="customerFilterText" />
@change="selectStore" </t-button>
<t-button size="small" theme="primary" @click="toggleScanner">
<template #icon>
<t-icon name="scan" />
</template>
<span v-text="scannerVisible ? '关闭扫码' : '扫码'" />
</t-button>
</div>
<div v-if="scannerVisible" class="line scanner-wrapper">
<code-scanner
visible
:status-text="scanStatus"
active-text="正在识别客户编码,请将二维码移入取景框"
@decode="handleDecodedText"
@status-change="handleScanStatusChange"
/> />
<t-dropdown-item </div>
v-model="changeTypeFilter" <div class="filters">
label="流水类型" <t-dropdown-menu>
:options="changeTypeOptions" <t-dropdown-item
@change="selectChangeType" v-if="isGlobalManager"
v-model="selectedStoreId"
:label="selectedStoreName"
:options="storeOptions"
@change="selectStore"
/>
<t-dropdown-item
v-model="changeTypeFilter"
label="流水类型"
:options="changeTypeOptions"
@change="selectChangeType"
/>
<t-dropdown-item
v-model="dateFilter"
:label="dateFilterLabel"
:options="dateFilterOptions"
@change="selectDateFilter"
/>
</t-dropdown-menu>
</div>
<div v-if="isCustomDate" class="date-range">
<t-button
size="small"
variant="outline"
:content="dateRange[0] || '开始时间'"
@click="startDateVisible = true"
/> />
<t-dropdown-item <span>至</span>
v-model="dateFilter" <t-button
:label="dateFilterLabel" size="small"
:options="dateFilterOptions" variant="outline"
@change="selectDateFilter" :content="dateRange[1] || '结束时间'"
@click="endDateVisible = true"
/> />
</t-dropdown-menu> </div>
</div> </div>
<div v-if="isCustomDate" class="date-range"> <card-container v-if="customer" class="customer">
<t-button variant="outline" :content="dateRange[0] || '开始时间'" @click="startDateVisible = true" /> <customer-profile-card :customer="customer" />
<span>至</span> </card-container>
<t-button variant="outline" :content="dateRange[1] || '结束时间'" @click="endDateVisible = true" /> <t-pull-down-refresh
</div> v-model="isRefreshing"
<section v-if="customer" class="customer-summary"> :refresh-timeout="30000"
<div class="customer-name" v-text="customer.name || customer.code || '未命名客户'" /> @refresh="reload"
<div class="customer-id" v-text="`客户 ID:${customer.id}`" /> @scrolltolower="load"
>
<t-cell-group v-if="pageResult.list.length" class="list" theme="card">
<t-cell
v-for="item in pageResult.list"
:key="item.id"
class="item"
:arrow="!!item.eventRecordId"
:hover="!!item.eventRecordId"
@click="toRecord(item)"
>
<template #leftIcon>
<t-image
v-if="getCustomerPhotoURL(item.customer!)"
:src="getCustomerPhotoURL(item.customer!)"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="(item.customer?.name || item.customer?.code || '客').slice(0, 1)" />
</template>
<template #title>
<div class="title">
<t-tag
class="type-icon"
:theme="item.pointDelta && 0 < item.pointDelta ? 'success' : 'danger'"
:content="getLedgerShortType(item)"
/>
<div class="name" v-text="item.customer?.name" />
</div>
</template>
<template #description>
<div class="description">
<div v-text="`交易时间:${Time.toPassedDateTime(item.occurredAt)}`" />
<div class="remark" v-text="getLedgerDescription(item)" />
</div>
</template>
<template #note>
<div class="delta" :class="getLedgerTheme(item)" v-text="getLedgerDelta(item)" />
</template>
</t-cell>
</t-cell-group>
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
<section v-if="customer" class="actions">
<t-button <t-button
v-can="permissionCodes.point.adjust" v-can="permissionCodes.point.adjust"
theme="primary" theme="primary"
@@ -46,35 +130,6 @@
人工调整 人工调整
</t-button> </t-button>
</section> </section>
<t-pull-down-refresh
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reload"
@scrolltolower="load"
>
<t-cell-group v-if="pageResult.list.length" class="ledger-list" theme="card">
<t-cell
v-for="item in pageResult.list"
:key="item.id"
class="ledger-item"
:title="getLedgerTitle(item)"
:description="getLedgerDescription(item)"
:note="getLedgerTime(item)"
:arrow="!!item.eventRecordId"
:hover="!!item.eventRecordId"
@click="toRecord(item)"
>
<template #leftIcon>
<div class="type-icon" :class="getLedgerTheme(item)" v-text="getLedgerShortType(item)" />
</template>
<template #extra>
<div class="delta" :class="getLedgerTheme(item)" v-text="getLedgerDelta(item)" />
</template>
</t-cell>
</t-cell-group>
<div v-else class="empty" v-text="isLoading ? '正在加载积分流水' : '暂无积分流水'" />
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
<customer-selector <customer-selector
v-model="selectedCustomerId" v-model="selectedCustomerId"
v-model:visible="selectorVisible" v-model:visible="selectorVisible"
@@ -108,10 +163,13 @@ import { Toast, type DropdownValue } from "tdesign-mobile-vue";
import { Time, type PageResult, useUserStore } from "timi-web"; import { Time, type PageResult, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI"; import PointAPI from "@/api/PointAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { PointLedger, PointLedgerChangeType, PointLedgerPage } from "@/types/Point"; import type { PointLedger, PointLedgerChangeType, PointLedgerPage } from "@/types/Point";
import { getCustomerPhotoURL } from "@/utils/customer";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
type DateFilter = "all" | "today" | "last7" | "last30" | "custom"; type DateFilter = "all" | "today" | "last7" | "last30" | "custom";
@@ -125,6 +183,7 @@ const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore(); const currentStoreStore = useCurrentStoreStore();
const settingStore = useSettingStore();
// ---------- 筛选 ---------- // ---------- 筛选 ----------
@@ -176,6 +235,26 @@ async function handleCustomerChange(value: string | string[]): Promise<void> {
await reload(); await reload();
} }
async function loadCustomerByCode(code: string): Promise<void> {
const normalizedCode = code.trim();
if (!normalizedCode) {
return;
}
try {
const matched = await CustomerAPI.findByCode(normalizedCode);
if (!matched?.id) {
Toast.warning("未找到客户");
return;
}
selectedCustomerId.value = matched.id;
customer.value = matched;
await reload();
} catch {
Toast.error("客户信息加载失败");
}
}
async function selectStore(value: DropdownValue): Promise<void> { async function selectStore(value: DropdownValue): Promise<void> {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return; return;
@@ -215,6 +294,36 @@ async function confirmDateRange(): Promise<void> {
await reload(); await reload();
} }
// ---------- 扫码 ----------
const scannerVisible = ref(false);
const scanStatus = ref("点击扫码开始识别");
let latestDecodedText = "";
async function toggleScanner(): Promise<void> {
scannerVisible.value = !scannerVisible.value;
if (scannerVisible.value) {
latestDecodedText = "";
scanStatus.value = "正在启动扫码预览";
}
}
async function handleDecodedText(rawText: string): Promise<void> {
const normalizedText = rawText.trim();
if (!normalizedText || normalizedText === latestDecodedText) {
return;
}
latestDecodedText = normalizedText;
await settingStore.playScanSuccessSound();
scanStatus.value = `已识别 ${normalizedText},正在加载积分流水`;
scannerVisible.value = false;
await loadCustomerByCode(normalizedText);
}
function handleScanStatusChange(value: string): void {
scanStatus.value = value;
}
// ---------- 分页 ---------- // ---------- 分页 ----------
const page = reactive<PointLedgerPage>({ const page = reactive<PointLedgerPage>({
@@ -301,34 +410,27 @@ function buildListOptions(): { allStores?: boolean; storeId?: string } | undefin
// ---------- 展示与导航 ---------- // ---------- 展示与导航 ----------
function getLedgerTitle(item: PointLedger): string { // function getLedgerTitle(item: PointLedger): string {
if (item.changeType === "EARN") { // if (item.changeType === "EARN") {
return "登记事件获得积分"; // return "登记事件获得积分";
} // }
if (item.changeType === "REVOKE") { // if (item.changeType === "REVOKE") {
return "删除登记记录撤销积分"; // return "删除登记记录撤销积分";
} // }
return "人工调整积分"; // return "人工调整积分";
} // }
function getLedgerDescription(item: PointLedger): string { function getLedgerDescription(item: PointLedger): string {
const parts = [`余额 ${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`]; const parts = [`余额 ${item.balanceBefore ?? 0} → ${item.balanceAfter ?? 0}`];
if (item.remark) { if (item.remark) {
parts.push(item.remark); parts.push(item.remark);
} }
if (item.eventRecordId) {
parts.push(`记录 ${item.eventRecordId}`);
}
return parts.join("|"); return parts.join("|");
} }
function getLedgerTime(item: PointLedger): string {
return item.occurredAt ? Time.toPassedDateTime(item.occurredAt) : "未知时间";
}
function getLedgerDelta(item: PointLedger): string { function getLedgerDelta(item: PointLedger): string {
const value = item.pointDelta ?? 0; const value = item.pointDelta ?? 0;
return `${0 < value ? "+" : ""}${value} 分`; return `${0 < value ? "+" : ""}${value}`;
} }
function getLedgerShortType(item: PointLedger): string { function getLedgerShortType(item: PointLedger): string {
@@ -421,80 +523,81 @@ onMounted(async () => {
<style scoped lang="less"> <style scoped lang="less">
.point-ledger-list { .point-ledger-list {
padding: calc(var(--app-nav-height) + 1rem) 0 1rem; gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 7rem) 0 1rem;
flex-direction: column;
.filters { .header {
gap: .75rem; top: var(--app-nav-height);
padding: 0 1rem .75rem; width: 100%;
display: flex; z-index: 10;
flex-direction: column; padding: .5rem 1rem 0;
position: fixed;
.customer-filter { .buttons {
width: 100%; gap: 1rem;
display: flex;
.selector {
flex: 1;
min-width: 0;
}
}
.filters {
margin: .5rem -1rem 0;
}
.scanner-wrapper {
margin-top: 1rem;
} }
:deep(.t-dropdown-menu) { :deep(.t-dropdown-menu) {
margin: 0 -1rem; background: transparent;
}
.date-range {
gap: .5rem;
padding: .5rem 0 .75rem;
display: flex;
align-items: center;
.t-button {
flex: 1;
min-width: 0;
}
span {
color: #52607a;
}
} }
} }
.date-range { .list {
gap: .5rem;
padding: 0 1rem .75rem;
display: flex;
align-items: center;
.t-button { .item {
flex: 1;
min-width: 0;
}
span { .avatar {
color: #52607a; width: 3.5rem;
} height: 3.5rem;
} }
.customer-summary { .title {
gap: .5rem; gap: .5rem;
margin: 0 1rem 1rem;
padding: .875rem 1rem;
display: grid;
background: rgba(255, 255, 255, .92);
box-shadow: var(--td-shadow-1);
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
border-radius: 1rem;
.customer-name {
font-weight: 700;
}
.customer-id {
color: #52607a;
font-size: .75rem;
}
.t-button {
grid-column: 2;
grid-row: 1 / 3;
}
}
.ledger-list {
margin: 0 1rem;
.ledger-item {
--td-cell-horizontal-padding: .75rem;
.type-icon {
width: 2rem;
height: 2rem;
display: flex; display: flex;
border-radius: .75rem;
align-items: center; .type-icon {
font-size: .875rem; display: flex;
font-weight: 700; font-size: 1rem;
justify-content: center; font-weight: 700;
border-radius: .5rem;
}
}
.description {
font-size: .75rem;
line-height: 1.25;
} }
.delta { .delta {
@@ -503,26 +606,36 @@ onMounted(async () => {
.increase { .increase {
color: #059669; color: #059669;
background: #d1fae5;
} }
.decrease { .decrease {
color: #dc2626; color: #dc2626;
background: #fee2e2;
} }
} }
} }
.empty { .actions {
color: #52607a; left: 1rem;
padding: 2rem 1rem; right: 1rem;
text-align: center; gap: .75rem;
z-index: 20;
bottom: calc(.75rem + env(safe-area-inset-bottom, 0));
display: flex;
position: fixed;
.t-button {
flex: 1;
}
} }
} }
:global(html.theme-dark) { :global(html.theme-dark) {
.point-ledger-list { .point-ledger-list {
.date-range span, .selector {
background: #10161f;
}
.header .date-range span,
.customer-id, .customer-id,
.empty { .empty {
color: #94a3b8; color: #94a3b8;
@@ -538,6 +651,15 @@ onMounted(async () => {
background: rgba(6, 78, 59, .56); background: rgba(6, 78, 59, .56);
} }
.name {
color: #e2e8f0;
}
.code,
.remark {
color: #94a3b8;
}
.decrease { .decrease {
color: #fca5a5; color: #fca5a5;
background: rgba(127, 29, 29, .56); background: rgba(127, 29, 29, .56);
+2
View File
@@ -1,4 +1,5 @@
import type { BasePage, Model } from "timi-web"; import type { BasePage, Model } from "timi-web";
import type { Customer } from "@/types/Customer";
export type PointRuleStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED"; export type PointRuleStatus = "DRAFT" | "ACTIVE" | "INACTIVE" | "DELETED";
export type PointRuleTriggerType = "EVERY_TIME" | "FIRST_TIME" | "AFTER_COUNT_EVERY_TIME" | "SOURCE_VALUE"; export type PointRuleTriggerType = "EVERY_TIME" | "FIRST_TIME" | "AFTER_COUNT_EVERY_TIME" | "SOURCE_VALUE";
@@ -41,6 +42,7 @@ export type PointLedger = {
remark?: string; remark?: string;
operatorUserId?: string; operatorUserId?: string;
occurredAt?: number; occurredAt?: number;
customer?: Customer;
} & Model; } & Model;
export type PointLedgerPage = { export type PointLedgerPage = {