add point account list
This commit is contained in:
+30
-5
@@ -1,6 +1,7 @@
|
||||
import { axios, type Page, type PageResult } from "timi-web";
|
||||
import type {
|
||||
PointAccount,
|
||||
PointAccountPage,
|
||||
PointAdjustRequest,
|
||||
PointLedger,
|
||||
PointLedgerPage,
|
||||
@@ -15,9 +16,21 @@ type ListOptions = {
|
||||
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> {
|
||||
return await axios.post(`${BASE_URI}/account/detail`, undefined, {
|
||||
async function detailByCustomerId(customerId: string, storeId?: string): Promise<PointAccount> {
|
||||
return await axios.post(`${BASE_URI}/account/detail/customer`, undefined, {
|
||||
params: {
|
||||
customerId,
|
||||
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) {
|
||||
return await axios.post(`${BASE_URI}/ledger/list`, page);
|
||||
}
|
||||
@@ -89,8 +112,10 @@ async function ruleRemove(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
export default {
|
||||
accountDetail,
|
||||
ledgerList,
|
||||
list,
|
||||
detailByCustomerId,
|
||||
detail,
|
||||
listLedger,
|
||||
adjust,
|
||||
ruleList,
|
||||
ruleDetail,
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 PointLedgerDetail from "@/pages/point/PointLedgerDetail.vue";
|
||||
import PointLedgerList from "@/pages/point/PointLedgerList.vue";
|
||||
@@ -10,8 +11,8 @@ import PointRuleSort from "@/pages/point/PointRuleSort.vue";
|
||||
const pointRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/management/points/account",
|
||||
name: "PointAccount",
|
||||
component: PointAccount,
|
||||
name: "PointAccountList",
|
||||
component: PointAccountList,
|
||||
meta: {
|
||||
depth: 3,
|
||||
navBarVisible: true,
|
||||
@@ -22,6 +23,20 @@ const pointRoutes: RouteRecordRaw[] = [
|
||||
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",
|
||||
name: "PointRuleSort",
|
||||
|
||||
@@ -47,8 +47,14 @@ export type PointAccount = {
|
||||
totalRevoked?: number;
|
||||
totalAdjusted?: number;
|
||||
version?: number;
|
||||
customer?: Customer;
|
||||
} & Model;
|
||||
|
||||
export type PointAccountPage = {
|
||||
storeId?: string;
|
||||
keyword?: string;
|
||||
} & BasePage;
|
||||
|
||||
export type PointLedger = {
|
||||
accountId?: string;
|
||||
storeId?: string;
|
||||
|
||||
Reference in New Issue
Block a user