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
+30 -5
View File
@@ -1,6 +1,7 @@
import { axios, type Page, type PageResult } from "timi-web"; import { axios, type Page, type PageResult } from "timi-web";
import type { import type {
PointAccount, PointAccount,
PointAccountPage,
PointAdjustRequest, PointAdjustRequest,
PointLedger, PointLedger,
PointLedgerPage, PointLedgerPage,
@@ -15,9 +16,21 @@ type ListOptions = {
storeId?: string; storeId?: string;
}; };
/** 分页查询积分账户。账户列表由后端关联返回客户资料。 */
async function list(page: PointAccountPage, options?: ListOptions): Promise<PageResult<PointAccount>> {
if (options?.allStores) {
return await axios.post(`${BASE_URI}/account/list`, page);
}
return await axios.post(`${BASE_URI}/account/list`, {
...page,
storeId: options?.storeId || getCurrentStoreId()
});
}
/** 查询指定客户的积分账户。账户不存在表示客户尚未产生过积分流水。 */ /** 查询指定客户的积分账户。账户不存在表示客户尚未产生过积分流水。 */
async function accountDetail(customerId: string, storeId?: string): Promise<PointAccount> { async function detailByCustomerId(customerId: string, storeId?: string): Promise<PointAccount> {
return await axios.post(`${BASE_URI}/account/detail`, undefined, { return await axios.post(`${BASE_URI}/account/detail/customer`, undefined, {
params: { params: {
customerId, customerId,
storeId: storeId || getCurrentStoreId() || undefined storeId: storeId || getCurrentStoreId() || undefined
@@ -25,8 +38,18 @@ async function accountDetail(customerId: string, storeId?: string): Promise<Poin
}); });
} }
/** 按账户 ID 查询积分账户详情。 */
async function detail(accountId: string, storeId?: string): Promise<PointAccount> {
return await axios.post(`${BASE_URI}/account/detail`, undefined, {
params: {
id: accountId,
storeId: storeId || getCurrentStoreId() || undefined
}
});
}
/** 分页查询积分流水。全局管理员不传门店时允许查询全部门店。 */ /** 分页查询积分流水。全局管理员不传门店时允许查询全部门店。 */
async function ledgerList(page: PointLedgerPage, options?: ListOptions): Promise<PageResult<PointLedger>> { async function listLedger(page: PointLedgerPage, options?: ListOptions): Promise<PageResult<PointLedger>> {
if (options?.allStores) { if (options?.allStores) {
return await axios.post(`${BASE_URI}/ledger/list`, page); return await axios.post(`${BASE_URI}/ledger/list`, page);
} }
@@ -89,8 +112,10 @@ async function ruleRemove(id: string): Promise<void> {
} }
export default { export default {
accountDetail, list,
ledgerList, detailByCustomerId,
detail,
listLedger,
adjust, adjust,
ruleList, ruleList,
ruleDetail, ruleDetail,
+114
View File
@@ -0,0 +1,114 @@
<template>
<t-cell class="customer-cell" :arrow="arrow" :hover="hover">
<template #leftIcon>
<t-image
v-if="photoURL"
:src="photoURL"
:alt="customerName"
fit="cover"
class="avatar"
/>
<div v-else class="avatar fallback" v-text="customerName.slice(0, 1) || '客'" />
</template>
<template #title>
<div class="title">
<div class="main">
<span class="name" v-text="customerName || '未命名客户'" />
<gender-icon :value="customer?.gender" size="1.125rem" />
</div>
<span class="code" v-text="customer?.code || ''" />
</div>
</template>
<template v-if="$slots.description" #description>
<slot name="description" />
</template>
<template v-if="$slots.note" #note>
<slot name="note" />
</template>
</t-cell>
</template>
<script setup lang="ts">
import type { Customer } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
defineOptions({
name: "CustomerCell"
});
interface Props {
customer?: Customer;
arrow?: boolean;
hover?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
arrow: false,
hover: false
});
const customer = computed(() => props.customer);
const customerName = computed(() => customer.value?.name || customer.value?.code || "");
const photoURL = computed(() => customer.value ? getCustomerPhotoURL(customer.value, 64) : "");
</script>
<style scoped lang="less">
.customer-cell {
padding: 10px;
:deep(.t-cell__title) {
margin-right: .75rem;
}
:deep(.t-cell__note) {
flex: 0;
}
.avatar {
width: 3.5rem;
height: 3.5rem;
}
.title {
gap: .5rem;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.main {
gap: .5rem;
display: flex;
align-items: center;
.name {
font-weight: 600;
}
}
.code {
color: #52607a;
font-size: .75rem;
}
}
}
:deep(.avatar .t-image__img) {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: inherit;
}
:global(html.theme-dark) {
.customer-cell {
.code {
color: #94a3b8;
}
.fallback {
background: #115e59;
}
}
}
</style>
+7 -3
View File
@@ -378,7 +378,7 @@ async function loadPointAccount(): Promise<void> {
return; return;
} }
try { try {
pointAccount.value = await PointAPI.accountDetail(customer.value.id, customer.value.storeId); pointAccount.value = await PointAPI.detailByCustomerId(customer.value.id, customer.value.storeId);
} catch { } catch {
pointAccount.value = undefined; pointAccount.value = undefined;
} }
@@ -388,10 +388,14 @@ async function toPointAccount(): Promise<void> {
if (!customer.value?.id) { if (!customer.value?.id) {
return; return;
} }
if (!pointAccount.value?.id) {
await router.push({ name: "PointAccountList" });
return;
}
await router.push({ await router.push({
name: "PointAccount", name: "PointAccountDetail",
params: { id: pointAccount.value.id },
query: { query: {
customerId: customer.value.id,
storeId: customer.value.storeId || "", storeId: customer.value.storeId || "",
transitionDepth: String(Number(route.meta.depth ?? 0) + 1) transitionDepth: String(Number(route.meta.depth ?? 0) + 1)
} }
+3 -19
View File
@@ -30,9 +30,9 @@
:key="item.id || item.code || index" :key="item.id || item.code || index"
class="item" class="item"
> >
<t-cell <customer-cell
class="cell" class="cell"
:title="item.name || item.code || '-'" :customer="item"
arrow arrow
hover hover
@click="toCustomerDetail(item)" @click="toCustomerDetail(item)"
@@ -40,17 +40,7 @@
<template #description> <template #description>
<telephone-link :value="item.telephone" placeholder="未填写电话" /> <telephone-link :value="item.telephone" placeholder="未填写电话" />
</template> </template>
<template #leftIcon> </customer-cell>
<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>
<template #right> <template #right>
<div v-can="permissionCodes.customer.update" class="remove bg-red white" @click="removeInvitee(item)">删除</div> <div v-can="permissionCodes.customer.update" class="remove bg-red white" @click="removeInvitee(item)">删除</div>
</template> </template>
@@ -105,7 +95,6 @@ import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue"; import CustomerSelector from "@/components/CustomerSelector.vue";
import { useSettingStore } from "@/store/settingStore"; import { useSettingStore } from "@/store/settingStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import { getCustomerPhotoURL } from "@/utils/customer";
import { permissionCodes } from "@/utils/permission"; import { permissionCodes } from "@/utils/permission";
defineOptions({ defineOptions({
@@ -322,11 +311,6 @@ watch(() => route.params.id, loadCustomer);
.cell { .cell {
padding: 6px 10px 6px 6px; padding: 6px 10px 6px 6px;
.avatar {
width: 3.25rem;
height: 3.25rem;
}
} }
} }
+3 -43
View File
@@ -38,36 +38,18 @@
@scrolltolower="fetch" @scrolltolower="fetch"
> >
<t-cell-group class="items" theme="card"> <t-cell-group class="items" theme="card">
<t-cell <customer-cell
class="item" class="item"
v-for="(item, index) in pageResult.list" v-for="(item, index) in pageResult.list"
:key="item.id || item.code || index" :key="item.id || item.code || index"
:customer="item"
arrow arrow
@click="router.push(`/management/customers/${item.id}`)" @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> <template #description>
<telephone-link :value="item.telephone" /> <telephone-link :value="item.telephone" />
</template> </template>
</t-cell> </customer-cell>
</t-cell-group> </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 ? '正在加载客户' : '暂无客户数据'" />
<div v-if="pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" /> <div v-if="pageResult.list.length" class="load-state" v-text="isLoading ? '正在加载更多' : isNoMore ? '已经到底了' : '上拉加载更多'" />
@@ -85,14 +67,12 @@
import { Message } from "@/utils/message"; import { Message } from "@/utils/message";
import { type Page, type PageResult, Time, Toolkit, useUserStore } from "timi-web"; import { type Page, type PageResult, Time, Toolkit, useUserStore } from "timi-web";
import CustomerAPI from "@/api/CustomerAPI"; import CustomerAPI from "@/api/CustomerAPI";
import GenderIcon from "@/components/GenderIcon.vue";
import TagSelector from "@/components/TagSelector.vue"; import TagSelector from "@/components/TagSelector.vue";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import { useNavBarStore } from "@/store/navBarStore"; import { useNavBarStore } from "@/store/navBarStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { NavBarAction } from "@/types/NavBarAction"; import type { NavBarAction } from "@/types/NavBarAction";
import type { Tag } from "@/types/Tag"; import type { Tag } from "@/types/Tag";
import { getCustomerPhotoURL } from "@/utils/customer";
import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { corePermissionCodes, gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ defineOptions({
@@ -304,26 +284,6 @@ async function doExport(): Promise<void> {
.item { .item {
padding: 10px; 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;
}
}
} }
} }
} }
+4 -68
View File
@@ -47,27 +47,16 @@
class="list" class="list"
theme="card" theme="card"
> >
<t-cell <customer-cell
class="item" class="item"
:class="{ 'in-top3': index < 3}" :class="{ 'in-top3': index < 3}"
v-for="(item, index) in pageResult.list" v-for="(item, index) in pageResult.list"
:key="item.customerId" :key="item.customerId"
arrow arrow
@click="toCustomerDetail(item.customerId || '')" @click="toCustomerDetail(item.customerId || '')"
:customer="item.customer"
> >
<template #left-icon> <template #note>
<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 <div
class="rank" class="rank"
:class="{ :class="{
@@ -77,10 +66,6 @@
}" }"
v-text="index + 1" v-text="index + 1"
/> />
<div class="name" v-text="getCustomerName(item)" />
</div>
<t-tag size="small" variant="light-outline" :content="item.customer?.code" />
</div>
</template> </template>
<template #description> <template #description>
<div class="description"> <div class="description">
@@ -110,7 +95,7 @@
/> />
</div> </div>
</template> </template>
</t-cell> </customer-cell>
</t-cell-group> </t-cell-group>
<div v-else class="load-state" v-text="emptyStateText" /> <div v-else class="load-state" v-text="emptyStateText" />
<div v-if="pageResult.list.length" class="load-state" v-text="loadStateText" /> <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 EventRecordAPI from "@/api/EventRecordAPI";
import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord"; import type { CustomerEventRankView, EventRecordRankPage, EventRecordRankType } from "@/types/EventRecord";
import type { EventItem } from "@/types/EventItem"; import type { EventItem } from "@/types/EventItem";
import { getCustomerPhotoURL } from "@/utils/customer";
import { getErrorMessage } from "@/utils/error"; import { getErrorMessage } from "@/utils/error";
import { formatEventNumberValue } from "@/utils/eventValue"; import { formatEventNumberValue } from "@/utils/eventValue";
import { permissionCodes } from "@/utils/permission"; 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 { function getTotalNumberValueText(item: CustomerEventRankView): string {
return formatEventNumberValue(selectedNumberEvent.value, item.totalNumberValue); return formatEventNumberValue(selectedNumberEvent.value, item.totalNumberValue);
} }
@@ -543,36 +519,6 @@ onMounted(async () => {
.list { .list {
.item { .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;
display: flex;
min-width: 0;
justify-content: space-between;
.left {
gap: .5rem;
display: flex;
.rank { .rank {
flex: 0 0 1.5rem; flex: 0 0 1.5rem;
@@ -603,16 +549,6 @@ onMounted(async () => {
} }
} }
.name {
color: #0F172A;
overflow: hidden;
font-size: .875rem;
white-space: nowrap;
text-overflow: ellipsis;
}
}
}
.description { .description {
gap: .5rem; gap: .5rem;
display: flex; display: flex;
@@ -1,40 +1,9 @@
<template> <template>
<div v-can="permissionCodes.point.accountRead" class="point-account"> <div v-can="permissionCodes.point.accountRead" class="point-account-detail">
<div class="header bg-white"> <card-container v-if="customer">
<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>
<customer-profile-card :customer="customer" /> <customer-profile-card :customer="customer" />
</card-container> </card-container>
<section v-if="customer && account" class="account-card"> <section v-if="account" class="account-card">
<div class="header"> <div class="header">
<div>积分卡</div> <div>积分卡</div>
<div class="time dark-white" v-text="formatTimestamp(account.createdAt!)" /> <div class="time dark-white" v-text="formatTimestamp(account.createdAt!)" />
@@ -46,7 +15,7 @@
<div class="id" v-text="account.id?.toUpperCase()" /> <div class="id" v-text="account.id?.toUpperCase()" />
</section> </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.totalEarned)" />
<t-cell title="累计消耗" :note="formatPoints(account.totalConsumed)" /> <t-cell title="累计消耗" :note="formatPoints(account.totalConsumed)" />
<t-cell title="累计撤销" :note="formatPoints(account.totalRevoked)" /> <t-cell title="累计撤销" :note="formatPoints(account.totalRevoked)" />
@@ -72,17 +41,7 @@
</t-button> </t-button>
</section> </section>
<t-empty <t-empty v-if="!account" :description="isLoading ? '正在加载积分账户' : '积分账户不存在或已被删除'" />
v-if="customer && !account"
:description="isLoading ? '正在加载积分账户' : '该客户还没有积分流水,积分账户会在首次产生积分时自动创建。'"
/>
<t-empty v-if="!customer" description="请选择客户查看积分账户" />
<customer-selector
v-model="selectedCustomerId"
v-model:visible="selectorVisible"
@change="handleCustomerChange"
/>
</div> </div>
</template> </template>
@@ -91,25 +50,20 @@ import { Message } from "@/utils/message";
import { useUserStore } from "timi-web"; import { 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 CustomerProfileCard from "@/components/CustomerProfileCard.vue"; import CustomerProfileCard from "@/components/CustomerProfileCard.vue";
import CustomerSelector from "@/components/CustomerSelector.vue";
import { useSettingStore } from "@/store/settingStore";
import { useCurrentStoreStore } from "@/store/currentStoreStore"; import { useCurrentStoreStore } from "@/store/currentStoreStore";
import type { Customer } from "@/types/Customer"; import type { Customer } from "@/types/Customer";
import type { PointAccount as PointAccountData } from "@/types/Point"; import type { PointAccount as PointAccountData } from "@/types/Point";
import { gaoRoleCodes, permissionCodes } from "@/utils/permission"; import { gaoRoleCodes, permissionCodes } from "@/utils/permission";
defineOptions({ defineOptions({
name: "PointAccount" name: "PointAccountDetail"
}); });
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const currentStoreStore = useCurrentStoreStore(); const currentStoreStore = useCurrentStoreStore();
const settingStore = useSettingStore();
// ---------- 门店 ---------- // ---------- 门店 ----------
const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager)); const isGlobalManager = computed(() => userStore.hasRoles(gaoRoleCodes.globalManager));
@@ -122,52 +76,17 @@ async function prepareStoreScope(): Promise<void> {
await currentStoreStore.loadLoginStore(); await currentStoreStore.loadLoginStore();
} }
// ---------- 客户与账户 ---------- // ---------- 账户 ----------
const selectedCustomerId = ref(String(route.query.customerId || "")); const accountId = String(route.params.id || "");
const customer = ref<Customer>(); const customer = ref<Customer>();
const account = ref<PointAccountData>(); const account = ref<PointAccountData>();
const selectorVisible = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const balanceText = computed(() => formatPoints(account.value?.balance)); 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> { async function loadAccount(): Promise<void> {
const customerId = selectedCustomerId.value; if (!accountId) {
if (!customerId) {
customer.value = undefined; customer.value = undefined;
account.value = undefined; account.value = undefined;
return; return;
@@ -175,62 +94,35 @@ async function loadAccount(): Promise<void> {
isLoading.value = true; isLoading.value = true;
try { try {
customer.value = await CustomerAPI.detail(customerId); account.value = await PointAPI.detail(
try { accountId,
account.value = await PointAPI.accountDetail(customerId, currentStoreStore.currentStoreId || undefined); String(route.query.storeId || currentStoreStore.currentStoreId || "")
} catch { );
account.value = undefined; if (!account.value) {
customer.value = undefined;
return;
} }
customer.value = account.value.customer || (account.value.customerId ? await CustomerAPI.detail(account.value.customerId) : undefined);
} catch { } catch {
customer.value = undefined; customer.value = undefined;
account.value = undefined; account.value = undefined;
Message.error("客户信息加载失败"); Message.error("积分账户加载失败");
} finally { } finally {
isLoading.value = false; 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> { async function toLedger(): Promise<void> {
if (!selectedCustomerId.value) { if (!account.value?.customerId) {
return; return;
} }
await router.push({ await router.push({
name: "PointLedgerList", name: "PointLedgerList",
query: { query: {
customerId: selectedCustomerId.value, customerId: account.value.customerId,
accountId: account.value?.id || "", accountId: account.value.id || "",
transitionDepth: String(getCurrentRouteDepth() + 1) transitionDepth: String(getCurrentRouteDepth() + 1)
} }
}); });
@@ -248,12 +140,12 @@ function getCurrentRouteDepth(): number {
} }
async function toAdjust(): Promise<void> { async function toAdjust(): Promise<void> {
if (!selectedCustomerId.value) { if (!account.value?.customerId) {
return; return;
} }
await router.push({ await router.push({
name: "PointAdjust", 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")} 分`; return `${(value ?? 0).toLocaleString("zh-CN")} 分`;
} }
function formatTimestamp(timestamp: number) { function formatTimestamp(timestamp?: number): string {
if (!timestamp) {
return "未知时间";
}
const date = new Date(timestamp); const date = new Date(timestamp);
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); 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")} 分`; 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 () => { onMounted(async () => {
try {
await prepareStoreScope(); await prepareStoreScope();
await loadAccount(); await loadAccount();
} catch {
Message.error("积分账户页面加载失败");
}
}); });
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.point-account { .point-account-detail {
gap: 1rem; gap: 1rem;
display: flex; 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; 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 { .account-card {
color: #FFF; color: #FFF;
margin: 0 1rem; margin: 0 1rem;
@@ -379,11 +247,7 @@ onMounted(async () => {
} }
:global(html.theme-dark) { :global(html.theme-dark) {
.point-account { .point-account-detail {
.selector {
background: #10161f;
}
.account-card { .account-card {
box-shadow: 0 .75rem 2rem rgba(2, 6, 23, .32); 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); selectedCustomer.value = await CustomerAPI.detail(selectedCustomerId.value);
if (userStore.hasPermissions(permissionCodes.point.accountRead)) { if (userStore.hasPermissions(permissionCodes.point.accountRead)) {
try { try {
account.value = await PointAPI.accountDetail(selectedCustomerId.value, currentStoreStore.currentStoreId || undefined); account.value = await PointAPI.detailByCustomerId(selectedCustomerId.value, currentStoreStore.currentStoreId || undefined);
} catch { } catch {
account.value = undefined; account.value = undefined;
} }
+1 -1
View File
@@ -344,7 +344,7 @@ async function load(): Promise<void> {
loadToken = token; loadToken = token;
isLoading.value = true; isLoading.value = true;
try { try {
const result = await PointAPI.ledgerList(buildRequest(), buildListOptions()); const result = await PointAPI.listLedger(buildRequest(), buildListOptions());
if (token !== loadToken) { if (token !== loadToken) {
return; return;
} }
+18 -3
View File
@@ -1,5 +1,6 @@
import type { RouteRecordRaw } from "vue-router"; import type { RouteRecordRaw } from "vue-router";
import PointAccount from "@/pages/point/PointAccount.vue"; import PointAccountDetail from "@/pages/point/PointAccountDetail.vue";
import PointAccountList from "@/pages/point/PointAccountList.vue";
import PointAdjust from "@/pages/point/PointAdjust.vue"; import PointAdjust from "@/pages/point/PointAdjust.vue";
import PointLedgerDetail from "@/pages/point/PointLedgerDetail.vue"; import PointLedgerDetail from "@/pages/point/PointLedgerDetail.vue";
import PointLedgerList from "@/pages/point/PointLedgerList.vue"; import PointLedgerList from "@/pages/point/PointLedgerList.vue";
@@ -10,8 +11,8 @@ import PointRuleSort from "@/pages/point/PointRuleSort.vue";
const pointRoutes: RouteRecordRaw[] = [ const pointRoutes: RouteRecordRaw[] = [
{ {
path: "/management/points/account", path: "/management/points/account",
name: "PointAccount", name: "PointAccountList",
component: PointAccount, component: PointAccountList,
meta: { meta: {
depth: 3, depth: 3,
navBarVisible: true, navBarVisible: true,
@@ -22,6 +23,20 @@ const pointRoutes: RouteRecordRaw[] = [
darkBodyBackground: "#10161f" darkBodyBackground: "#10161f"
} }
}, },
{
path: "/management/points/account/:id",
name: "PointAccountDetail",
component: PointAccountDetail,
meta: {
depth: 4,
navBarVisible: true,
navBarCanBack: true,
navBarTitle: "积分账户详情",
tabBarVisible: false,
bodyBackground: "#F5F5F8",
darkBodyBackground: "#10161f"
}
},
{ {
path: "/management/points/rules/sort", path: "/management/points/rules/sort",
name: "PointRuleSort", name: "PointRuleSort",
+6
View File
@@ -47,8 +47,14 @@ export type PointAccount = {
totalRevoked?: number; totalRevoked?: number;
totalAdjusted?: number; totalAdjusted?: number;
version?: number; version?: number;
customer?: Customer;
} & Model; } & Model;
export type PointAccountPage = {
storeId?: string;
keyword?: string;
} & BasePage;
export type PointLedger = { export type PointLedger = {
accountId?: string; accountId?: string;
storeId?: string; storeId?: string;