add point account list

This commit is contained in:
Timi
2026-08-22 16:42:32 +08:00
parent e237b71f13
commit 1a333508cd
12 changed files with 523 additions and 342 deletions
+7 -3
View File
@@ -378,7 +378,7 @@ async function loadPointAccount(): Promise<void> {
return;
}
try {
pointAccount.value = await PointAPI.accountDetail(customer.value.id, customer.value.storeId);
pointAccount.value = await PointAPI.detailByCustomerId(customer.value.id, customer.value.storeId);
} catch {
pointAccount.value = undefined;
}
@@ -388,10 +388,14 @@ async function toPointAccount(): Promise<void> {
if (!customer.value?.id) {
return;
}
if (!pointAccount.value?.id) {
await router.push({ name: "PointAccountList" });
return;
}
await router.push({
name: "PointAccount",
name: "PointAccountDetail",
params: { id: pointAccount.value.id },
query: {
customerId: customer.value.id,
storeId: customer.value.storeId || "",
transitionDepth: String(Number(route.meta.depth ?? 0) + 1)
}
+3 -19
View File
@@ -30,9 +30,9 @@
:key="item.id || item.code || index"
class="item"
>
<t-cell
<customer-cell
class="cell"
:title="item.name || item.code || '-'"
:customer="item"
arrow
hover
@click="toCustomerDetail(item)"
@@ -40,17 +40,7 @@
<template #description>
<telephone-link :value="item.telephone" placeholder="未填写电话" />
</template>
<template #leftIcon>
<t-image
v-if="getCustomerPhotoURL(item, 64)"
class="avatar"
:src="getCustomerPhotoURL(item, 64)"
:alt="item.name"
fit="cover"
/>
<div v-else class="avatar fallback" v-text="(item.name || '客').slice(0, 1)" />
</template>
</t-cell>
</customer-cell>
<template #right>
<div v-can="permissionCodes.customer.update" class="remove bg-red white" @click="removeInvitee(item)">删除</div>
</template>
@@ -105,7 +95,6 @@ import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission";
defineOptions({
@@ -322,11 +311,6 @@ watch(() => route.params.id, loadCustomer);
.cell {
padding: 6px 10px 6px 6px;
.avatar {
width: 3.25rem;
height: 3.25rem;
}
}
}
+3 -43
View File
@@ -38,36 +38,18 @@
@scrolltolower="fetch"
>
<t-cell-group class="items" theme="card">
<t-cell
<customer-cell
class="item"
v-for="(item, index) in pageResult.list"
:key="item.id || item.code || index"
:customer="item"
arrow
@click="router.push(`/management/customers/${item.id}`)"
>
<template #left-icon>
<t-image
v-if="getCustomerPhotoURL(item, 64)"
class="avatar"
:src="getCustomerPhotoURL(item, 64)"
:alt="item.name"
fit="cover"
/>
<div v-else class="avatar fallback" v-text="(item.name || item.code || '客').slice(0, 1)" />
</template>
<template #title>
<div class="header">
<div class="left">
<div class="text-bold" v-text="item.name" />
<gender-icon :value="item.gender" size="1.125rem" />
</div>
<span class="gray" v-text="item.code || ''" />
</div>
</template>
<template #description>
<telephone-link :value="item.telephone" />
</template>
</t-cell>
</customer-cell>
</t-cell-group>
<div v-if="!pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载客户' : '暂无客户数据'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
@@ -85,14 +67,12 @@
import { Message } from "@/utils/message";
import { type Page, type PageResult, Time, Toolkit, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import GenderIcon from "@/components/GenderIcon.vue";
import TagSelector from "@/components/TagSelector.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer";
import type { NavBarAction } from "@/types/NavBarAction";
import type { Tag } from "@/types/Tag";
import { getCustomerPhotoURL } from "@/utils/customer";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
@@ -304,26 +284,6 @@ async function doExport(): Promise<void> {
.item {
padding: 10px;
.avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: .5rem;
}
.header {
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.left {
gap: .5rem;
display: flex;
align-items: center;
}
}
}
}
}
+31 -95
View File
@@ -47,40 +47,25 @@
class="list"
theme="card"
>
<t-cell
<customer-cell
class="item"
:class="{ 'in-top3': index < 3}"
v-for="(item, index) in pageResult.list"
:key="item.customerId"
arrow
@click="toCustomerDetail(item.customerId || '')"
:customer="item.customer"
>
<template #left-icon>
<t-image
v-if="getCustomerPhotoURL(item.customer, 64)"
:src="getCustomerPhotoURL(item.customer, 64)"
:alt="getCustomerName(item)"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="getCustomerInitial(item)" />
</template>
<template #title>
<div class="title">
<div class="left">
<div
class="rank"
:class="{
<template #note>
<div
class="rank"
:class="{
top1: index === 0,
top2: index === 1,
top3: index === 2,
}"
v-text="index + 1"
/>
<div class="name" v-text="getCustomerName(item)" />
</div>
<t-tag size="small" variant="light-outline" :content="item.customer?.code" />
</div>
v-text="index + 1"
/>
</template>
<template #description>
<div class="description">
@@ -110,7 +95,7 @@
/>
</div>
</template>
</t-cell>
</customer-cell>
</t-cell-group>
<div v-else class="load-state" v-text="emptyStateText" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" />
@@ -145,7 +130,6 @@ import EventAPI from "@/api/EventAPI";
import EventRecordAPI from "@/api/EventRecordAPI";
import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord";
import type { EventItem } from "@/types/EventItem";
import { getCustomerPhotoURL } from "@/utils/customer";
import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission";
@@ -376,14 +360,6 @@ async function toCustomerDetail(customerId: string): Promise<void> {
// ---------- 格式化 ----------
function getCustomerName(item: CustomerEventRankView): string {
return item.customer?.name || "未知客户";
}
function getCustomerInitial(item: CustomerEventRankView): string {
return getCustomerName(item).slice(0, 1);
}
function getTotalNumberValueText(item: CustomerEventRankView): string {
return formatEventNumberValue(selectedNumberEvent.value, item.totalNumberValue);
}
@@ -543,73 +519,33 @@ onMounted(async () => {
.list {
.item {
--td-spacer-1: 8px;
--td-spacer-2: 4px;
padding: 10px;
&.in-top3 {
.title {
.name {
font-size: 1rem;
font-weight: 600;
}
}
}
.avatar {
width: 3.5rem;
height: 3.5rem;
border-radius: .5rem;
}
.title {
flex: 1 1 auto;
.rank {
flex: 0 0 1.5rem;
width: 1.5rem;
color: #52607a;
height: 1.5rem;
display: flex;
min-width: 0;
justify-content: space-between;
font-size: .76rem;
background: #F0F0F0;
font-weight: 700;
align-items: center;
border-radius: .5rem;
justify-content: center;
.left {
gap: .5rem;
display: flex;
&.top1 {
color: #5C3A00;
background: #FFD700;
}
.rank {
flex: 0 0 1.5rem;
width: 1.5rem;
color: #52607a;
height: 1.5rem;
display: flex;
font-size: .76rem;
background: #F0F0F0;
font-weight: 700;
align-items: center;
border-radius: .5rem;
justify-content: center;
&.top2 {
color: #374151;
background: #C0C0C0;
}
&.top1 {
color: #5C3A00;
background: #FFD700;
}
&.top2 {
color: #374151;
background: #C0C0C0;
}
&.top3 {
color: #FFF8F0;
background: #CD7F32;
}
}
.name {
color: #0F172A;
overflow: hidden;
font-size: .875rem;
white-space: nowrap;
text-overflow: ellipsis;
}
&.top3 {
color: #FFF8F0;
background: #CD7F32;
}
}
@@ -1,40 +1,9 @@
<template>
<div v-can="permissionCodes.point.accountRead" class="point-account">
<div class="header bg-white">
<div class="buttons">
<t-button
class="selector"
size="small"
theme="primary"
variant="outline"
@click="selectorVisible = true"
>
<template #icon>
<t-icon name="user-search" />
</template>
<span>选择客户</span>
</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"
/>
</div>
</div>
<card-container>
<div v-can="permissionCodes.point.accountRead" class="point-account-detail">
<card-container v-if="customer">
<customer-profile-card :customer="customer" />
</card-container>
<section v-if="customer && account" class="account-card">
<section v-if="account" class="account-card">
<div class="header">
<div>积分卡</div>
<div class="time dark-white" v-text="formatTimestamp(account.createdAt!)" />
@@ -46,7 +15,7 @@
<div class="id" v-text="account.id?.toUpperCase()" />
</section>
<t-cell-group v-if="customer && account" title="积分统计" theme="card">
<t-cell-group v-if="account" title="积分统计" theme="card">
<t-cell title="累计获得" :note="formatPoints(account.totalEarned)" />
<t-cell title="累计消耗" :note="formatPoints(account.totalConsumed)" />
<t-cell title="累计撤销" :note="formatPoints(account.totalRevoked)" />
@@ -72,17 +41,7 @@
</t-button>
</section>
<t-empty
v-if="customer && !account"
:description="isLoading ? '正在加载积分账户' : '该客户还没有积分流水,积分账户会在首次产生积分时自动创建。'"
/>
<t-empty v-if="!customer" description="请选择客户查看积分账户" />
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerChange"
/>
<t-empty v-if="!account" :description="isLoading ? '正在加载积分账户' : '积分账户不存在或已被删除'" />
</div>
</template>
@@ -91,25 +50,20 @@ import { Message } from "@/utils/message";
import { useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI";
import PointAPI from "@/api/PointAPI";
import CodeScanner from "@/components/CodeScanner.vue";
import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { Customer } from "@/types/Customer";
import type { PointAccount as PointAccountData } from "@/types/Point";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "PointAccount"
name: "PointAccountDetail"
});
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
const settingStore = useSettingStore();
// ---------- 门店 ----------
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
@@ -122,52 +76,17 @@ async function prepareStoreScope(): Promise<void> {
await currentStoreStore.loadLoginStore();
}
// ---------- 客户与账户 ----------
// ---------- 账户 ----------
const selectedCustomerId = ref(String(route.query.customerId || ""));
const accountId = String(route.params.id || "");
const customer = ref<Customer>();
const account = ref<PointAccountData>();
const selectorVisible = ref(false);
const isLoading = ref(false);
const balanceText = computed(() => formatPoints(account.value?.balance));
async function handleCustomerChange(value: string | string[]): Promise<void> {
selectedCustomerId.value = Array.isArray(value) ? String(value[0] || "") : value;
await router.replace({
name: "PointAccount",
query: selectedCustomerId.value ? { customerId: selectedCustomerId.value } : undefined
});
await loadAccount();
}
async function loadCustomerByCode(code: string): Promise<void> {
const normalizedCode = code.trim();
if (!normalizedCode) {
return;
}
try {
const matched = await CustomerAPI.findByCode(normalizedCode);
if (!matched?.id) {
Message.warning("未找到客户");
return;
}
selectedCustomerId.value = matched.id;
customer.value = matched;
await router.replace({
name: "PointAccount",
query: { customerId: selectedCustomerId.value }
});
await loadAccount();
} catch {
Message.error("客户信息加载失败");
}
}
async function loadAccount(): Promise<void> {
const customerId = selectedCustomerId.value;
if (!customerId) {
if (!accountId) {
customer.value = undefined;
account.value = undefined;
return;
@@ -175,62 +94,35 @@ async function loadAccount(): Promise<void> {
isLoading.value = true;
try {
customer.value = await CustomerAPI.detail(customerId);
try {
account.value = await PointAPI.accountDetail(customerId, currentStoreStore.currentStoreId || undefined);
} catch {
account.value = undefined;
account.value = await PointAPI.detail(
accountId,
String(route.query.storeId || currentStoreStore.currentStoreId || "")
);
if (!account.value) {
customer.value = undefined;
return;
}
customer.value = account.value.customer || (account.value.customerId ? await CustomerAPI.detail(account.value.customerId) : undefined);
} catch {
customer.value = undefined;
account.value = undefined;
Message.error("客户信息加载失败");
Message.error("积分账户加载失败");
} finally {
isLoading.value = false;
}
}
// ---------- 扫码 ----------
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;
}
// ---------- 导航 ----------
async function toLedger(): Promise<void> {
if (!selectedCustomerId.value) {
if (!account.value?.customerId) {
return;
}
await router.push({
name: "PointLedgerList",
query: {
customerId: selectedCustomerId.value,
accountId: account.value?.id || "",
customerId: account.value.customerId,
accountId: account.value.id || "",
transitionDepth: String(getCurrentRouteDepth() + 1)
}
});
@@ -248,12 +140,12 @@ function getCurrentRouteDepth(): number {
}
async function toAdjust(): Promise<void> {
if (!selectedCustomerId.value) {
if (!account.value?.customerId) {
return;
}
await router.push({
name: "PointAdjust",
query: { customerId: selectedCustomerId.value }
query: { customerId: account.value.customerId }
});
}
@@ -263,7 +155,10 @@ function formatPoints(value?: number): string {
return `${(value ?? 0).toLocaleString("zh-CN")} 分`;
}
function formatTimestamp(timestamp: number) {
function formatTimestamp(timestamp?: number): string {
if (!timestamp) {
return "未知时间";
}
const date = new Date(timestamp);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -276,50 +171,23 @@ function formatSignedPoints(value?: number): string {
return pointValue < 0 ? `扣除 ${Math.abs(pointValue).toLocaleString("zh-CN")} 分` : `增加 ${pointValue.toLocaleString("zh-CN")} 分`;
}
watch(() => route.query.customerId, async customerIdValue => {
const customerId = String(customerIdValue || "");
if (customerId !== selectedCustomerId.value) {
selectedCustomerId.value = customerId;
await loadAccount();
}
});
onMounted(async () => {
await prepareStoreScope();
await loadAccount();
try {
await prepareStoreScope();
await loadAccount();
} catch {
Message.error("积分账户页面加载失败");
}
});
</script>
<style scoped lang="less">
.point-account {
.point-account-detail {
gap: 1rem;
display: flex;
padding: calc(var(--app-nav-height) + 4rem) 0 calc(6rem + env(safe-area-inset-bottom, 0));
padding: calc(var(--app-nav-height) + 1rem) 0 calc(6rem + env(safe-area-inset-bottom, 0));
flex-direction: column;
> .header {
top: var(--app-nav-height);
width: 100%;
z-index: 10;
padding: .5rem 1rem;
position: fixed;
.buttons {
gap: 1rem;
display: flex;
.selector {
flex: 1;
min-width: 0;
}
}
.scanner-wrapper {
margin-top: 1rem;
}
}
.account-card {
color: #FFF;
margin: 0 1rem;
@@ -379,11 +247,7 @@ onMounted(async () => {
}
:global(html.theme-dark) {
.point-account {
.selector {
background: #10161f;
}
.point-account-detail {
.account-card {
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32);
}
+273
View File
@@ -0,0 +1,273 @@
<template>
<div v-can="permissionCodes.point.accountRead" class="point-account-list">
<div class="filter bg-white">
<t-input
v-model.trim="keyword"
class="search"
placeholder="搜索客户编码、姓名、拼音或电话"
borderless
/>
</div>
<t-pull-down-refresh
class="list-wrapper"
v-model="isRefreshing"
:refresh-timeout="30000"
@refresh="reload"
@scrolltolower="load"
>
<t-cell-group v-if="pageResult.list.length" class="list" theme="card">
<customer-cell
v-for="item in pageResult.list"
:key="item.id"
:customer="item.customer"
class="item"
arrow
hover
@click="toDetail(item)"
>
<template #description>
<span>余额:</span>
<span
class="balance"
:class="getBalanceTheme(item.balance)"
v-text="formatPoints(item.balance)" />
</template>
</customer-cell>
</t-cell-group>
<div class="load-state" v-text="loadStateText" />
</t-pull-down-refresh>
</div>
</template>
<script setup lang="ts">
import { type PageResult, useUserStore } from "timi-web";
import PointAPI from "@/api/PointAPI";
import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { PointAccount, PointAccountPage } from "@/types/Point";
import { Message } from "@/utils/message";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({
name: "PointAccountList"
});
const router = useRouter();
const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore();
// ---------- 门店 ----------
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
async function prepareStoreScope(): Promise<void> {
if (isGlobalManager.value) {
await currentStoreStore.loadStoreList();
return;
}
await currentStoreStore.loadLoginStore();
}
// ---------- 筛选 ----------
const keyword = ref("");
let keywordTimer: number | undefined;
// ---------- 分页 ----------
const page = reactive<PointAccountPage>({
index: 0,
size: 20
});
const pageResult = reactive<PageResult<PointAccount>>({
total: 0,
list: []
});
const isLoading = ref(false);
const isRefreshing = ref(false);
const isNoMore = ref(false);
let loadToken = 0;
let reloadRequested = false;
const loadStateText = computed(() => {
if (isLoading.value) {
return "正在加载积分账户";
}
if (!pageResult.list.length) {
return "暂无积分账户";
}
if (isNoMore.value) {
return `共 ${pageResult.total} 个积分账户`;
}
return "上拉加载更多";
});
async function reload(): Promise<void> {
if (isLoading.value) {
reloadRequested = true;
return;
}
isRefreshing.value = true;
try {
page.index = 0;
pageResult.total = 0;
pageResult.list = [];
isNoMore.value = false;
await load();
} finally {
isRefreshing.value = false;
}
}
async function load(): Promise<void> {
if (isLoading.value || isNoMore.value || !currentStoreStore.currentStoreId) {
return;
}
const token = loadToken + 1;
loadToken = token;
isLoading.value = true;
try {
const result = await PointAPI.list({
...page,
keyword: keyword.value || undefined
}, { storeId: currentStoreStore.currentStoreId });
if (token !== loadToken) {
return;
}
const list = result.list || [];
pageResult.list = pageResult.list.concat(list);
pageResult.total = result.total || 0;
page.index += 1;
isNoMore.value = pageResult.list.length >= pageResult.total || list.length < page.size;
} catch {
Message.error("积分账户加载失败");
} finally {
if (token === loadToken) {
isLoading.value = false;
}
if (reloadRequested && token === loadToken) {
reloadRequested = false;
await reload();
}
}
}
// ---------- 展示 ----------
function formatPoints(value?: number): string {
return `${(value ?? 0).toLocaleString("zh-CN")} 分`;
}
function getBalanceTheme(value?: number): string {
return value && 0 < value ? "positive" : "zero";
}
// ---------- 导航 ----------
async function toDetail(item: PointAccount): Promise<void> {
if (!item.id) {
return;
}
await router.push({
name: "PointAccountDetail",
params: { id: item.id },
query: item.storeId ? { storeId: item.storeId } : undefined
});
}
// ---------- 生命周期 ----------
watch(keyword, () => {
window.clearTimeout(keywordTimer);
keywordTimer = window.setTimeout(async () => {
await reload();
}, 300);
});
onUnmounted(() => {
window.clearTimeout(keywordTimer);
});
onMounted(async () => {
try {
await prepareStoreScope();
await reload();
} catch {
Message.error("积分账户页面加载失败");
}
});
</script>
<style scoped lang="less">
.point-account-list {
padding: calc(var(--app-nav-height) + 4.5rem) 0 1rem;
.filter {
top: var(--app-nav-height);
left: 0;
right: 0;
z-index: 10;
padding: .5rem 1rem;
position: fixed;
box-shadow: var(--td-shadow-1);
.search {
width: 100%;
border: 1px solid var(--td-component-border);
padding: .5rem 1rem;
border-radius: 1rem;
}
}
.list-wrapper {
min-height: 70vh;
.list {
.item {
padding: 10px;
.description {
color: #52607a;
font-size: .8125rem;
line-height: 1.8;
}
.balance {
font-size: .875rem;
font-weight: 600;
white-space: nowrap;
}
.positive {
color: #0f766e;
}
.zero {
color: #52607a;
}
}
}
}
.load-state {
color: #52607a;
padding: 1rem;
font-size: .8125rem;
text-align: center;
}
}
:global(html.theme-dark) {
.point-account-list {
.description,
.zero,
.load-state {
color: #94a3b8;
}
.positive {
color: #5eead4;
}
}
}
</style>
+1 -1
View File
@@ -165,7 +165,7 @@ async function loadCustomerAccount(): Promise<void> {
selectedCustomer.value = await CustomerAPI.detail(selectedCustomerId.value);
if (userStore.hasPermissions(permissionCodes.point.accountRead)) {
try {
account.value = await PointAPI.accountDetail(selectedCustomerId.value, currentStoreStore.currentStoreId || undefined);
account.value = await PointAPI.detailByCustomerId(selectedCustomerId.value, currentStoreStore.currentStoreId || undefined);
} catch {
account.value = undefined;
}
+1 -1
View File
@@ -344,7 +344,7 @@ async function load(): Promise<void> {
loadToken = token;
isLoading.value = true;
try {
const result = await PointAPI.ledgerList(buildRequest(), buildListOptions());
const result = await PointAPI.listLedger(buildRequest(), buildListOptions());
if (token !== loadToken) {
return;
}