feat:余额区间查询
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
build / task (16.x, npm, macOS-latest) (push) Canceled after 0s
build / task (16.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, npm, windows-latest) (push) Canceled after 0s
build / task (16.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (16.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (16.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (16.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (16.x, yarn, windows-latest) (push) Canceled after 0s
build / task (18.x, npm, macOS-latest) (push) Canceled after 0s
build / task (18.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, npm, windows-latest) (push) Canceled after 0s
build / task (18.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (18.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (18.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (18.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (18.x, yarn, windows-latest) (push) Canceled after 0s
build / task (20.x, npm, macOS-latest) (push) Canceled after 0s
build / task (20.x, npm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, npm, windows-latest) (push) Canceled after 0s
build / task (20.x, pnpm, macOS-latest) (push) Canceled after 0s
build / task (20.x, pnpm, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, pnpm, windows-latest) (push) Canceled after 0s
build / task (20.x, yarn, macOS-latest) (push) Canceled after 0s
build / task (20.x, yarn, ubuntu-latest) (push) Canceled after 0s
build / task (20.x, yarn, windows-latest) (push) Canceled after 0s
This commit is contained in:
1 parent
41d9c49508
commit
431371a13c
9 files changed
+767
-402
No files matched your search
@@ -22,6 +22,7 @@ VITE_PWD_ENC_KEY='pigxpigxpigxpigx'
|
||||
|
||||
# OAUTH2 密码模式客户端信息
|
||||
VITE_OAUTH2_PASSWORD_CLIENT='pjcloud:picloudqqq'
|
||||
# VITE_OAUTH2_PASSWORD_CLIENT='pig:pig'
|
||||
|
||||
# OAUTH2 短信客户端信息
|
||||
VITE_OAUTH2_MOBILE_CLIENT='app:app'
|
||||
|
||||
+4
-4
@@ -10,15 +10,15 @@ ENV = 'development'
|
||||
# ADMIN 服务地址
|
||||
# VITE_ADMIN_PROXY_PATH = http://123.157.250.162:8089/api/ # 测试线上
|
||||
# VITE_ADMIN_PROXY_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
|
||||
# VITE_ADMIN_PROXY_PATH = http://localhost:9999/
|
||||
VITE_ADMIN_PROXY_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
VITE_ADMIN_PROXY_PATH = http://localhost:9999/
|
||||
# VITE_ADMIN_PROXY_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
# VITE_ADMIN_PROXY_PATH = https://www.pjzhhq.cn/api # 正式环境线上
|
||||
|
||||
# 图片访问基础地址
|
||||
# VITE_IMAGE_BASE_PATH = http://123.157.250.162:8089/api/ # 测试线上
|
||||
# VITE_IMAGE_BASE_PATH = http://123.156.230.15:8089/api/ # 测试线上-过期
|
||||
# VITE_IMAGE_BASE_PATH = http://localhost:9999
|
||||
VITE_IMAGE_BASE_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
VITE_IMAGE_BASE_PATH = http://localhost:9999
|
||||
# VITE_IMAGE_BASE_PATH = https://dzc-test-1.fy.takin.cc # 邓-本地
|
||||
# VITE_IMAGE_BASE_PATH = https://www.pjzhhq.cn/api # 正式环境线上
|
||||
|
||||
# 代码生成服务地址
|
||||
|
||||
@@ -8,6 +8,61 @@ export const submitExport = (query: Record<string, unknown>) => submitExportTask
|
||||
/** 按所选月份与原筛选条件提交钱包历史余额分批导出。 */
|
||||
export const submitQbExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-async", query)
|
||||
|
||||
/** 用户月末余额汇总筛选;金额边界单位为分,包含上下界,空值表示不限。 */
|
||||
export interface WalletQbUserQuery {
|
||||
walletRecordMonth: string
|
||||
userId?: string | number
|
||||
walletTypeIdList?: Array<string | number>
|
||||
deptIds?: Array<string | number>
|
||||
isInclude?: boolean
|
||||
minBalance?: number
|
||||
maxBalance?: number
|
||||
}
|
||||
|
||||
/** 每个用户一行的月末余额;金额均为分,计入当前筛选范围的钱包。 */
|
||||
export interface WalletQbUserRow {
|
||||
userId: string | number
|
||||
username?: string
|
||||
userAvatar?: string
|
||||
userMobile?: string
|
||||
deptName?: string
|
||||
balance: number
|
||||
eatBalance: number
|
||||
payBalance: number
|
||||
topupBalance: number
|
||||
walletCount: number
|
||||
lastTransactionTime?: string
|
||||
}
|
||||
|
||||
/** 当前筛选范围内全部用户的余额汇总,独立于列表分页。 */
|
||||
export interface WalletQbUserTotal {
|
||||
total: number
|
||||
eatTotal: number
|
||||
payTotal: number
|
||||
topupTotal: number
|
||||
userCount: number
|
||||
walletCount: number
|
||||
}
|
||||
|
||||
/** 用户合计余额及各钱包余额在数据库完成全量筛选、排序和分页。 */
|
||||
export function fetchQbUsers(query: WalletQbUserQuery & { current: number; size: number; ascs?: string; descs?: string }, signal?: AbortSignal) {
|
||||
return request({
|
||||
url: "/wallet/wallet-manage/page-qb-users",
|
||||
method: "post",
|
||||
params: { current: query.current, size: query.size, ascs: query.ascs || undefined, descs: query.descs || undefined },
|
||||
data: query,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
/** 按当前用户合计余额区间计算全部匹配用户的分类余额。 */
|
||||
export function qbUsersTotal(query: WalletQbUserQuery, signal?: AbortSignal) {
|
||||
return request({ url: "/wallet/wallet-manage/qb-users-total", method: "post", data: query, signal })
|
||||
}
|
||||
|
||||
/** 导出每用户一行的全量汇总,保持列表月份、区间及部门筛选。 */
|
||||
export const submitQbUsersExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-qb-users-async", query)
|
||||
|
||||
/**
|
||||
* 不活跃用户统计分页
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 items-center gap-3 text-left">
|
||||
<el-avatar :size="props.avatarSize" :src="$fileUrl(props.user.avatar) || undefined" @error="$fileRetry(props.user.avatar)" :alt="displayName" class="user-info-cell__avatar shrink-0">
|
||||
<div v-if="props.variant === 'compact'" class="user-info-cell--compact">
|
||||
<el-avatar :size="resolvedAvatarSize" :src="$fileUrl(compactAvatar) || undefined" @error="$fileRetry(compactAvatar)"
|
||||
aria-hidden="true" class="user-info-cell__compact-avatar">
|
||||
<el-icon><User /></el-icon>
|
||||
</el-avatar>
|
||||
<div class="user-info-cell__compact-text">
|
||||
<strong :title="compactName">{{ compactName }}</strong>
|
||||
<span :title="compactSecondary">{{ compactSecondary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex min-w-0 items-center gap-3 text-left">
|
||||
<el-avatar :size="resolvedAvatarSize" :src="$fileUrl(props.user.avatar) || undefined" @error="$fileRetry(props.user.avatar)" :alt="displayName" class="user-info-cell__avatar shrink-0">
|
||||
{{ avatarFallback }}
|
||||
</el-avatar>
|
||||
<div class="min-w-0 flex-1">
|
||||
@@ -37,13 +47,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="UserInfoCell">
|
||||
import { computed } from "vue"
|
||||
import { User } from "@element-plus/icons-vue"
|
||||
|
||||
/** 可复用用户信息单元格需要的最小用户摘要。 */
|
||||
interface UserInfoCellUser {
|
||||
/** 用户头像地址;为空时组件展示姓名或账号首字。 */
|
||||
/** 用户头像地址;为空或加载失败时,详细模式显示姓名或账号首字,紧凑模式显示用户图标。 */
|
||||
avatar?: string | null
|
||||
/** 用户姓名;为空时显示明确的缺失占位。 */
|
||||
name?: string | null
|
||||
/** 用户登录账号;为空时显示未分配。 */
|
||||
/** 详细模式表示登录账号;紧凑模式兼容钱包接口的用户姓名。 */
|
||||
username?: string | null
|
||||
/** 用户绑定手机号;为空时显示未绑定。 */
|
||||
phone?: string | null
|
||||
@@ -51,13 +64,23 @@ interface UserInfoCellUser {
|
||||
gender?: string | number | null
|
||||
/** 用户所属部门名称;为空时显示未分配。 */
|
||||
deptName?: string | null
|
||||
/** 规范用户摘要的主键;紧凑模式缺手机号时用于 ID 提示,保留字符串大整数。 */
|
||||
id?: string | number | null
|
||||
/** 钱包接口的用户主键;与钱包或订单行的 id 同时存在时优先用于身份提示。 */
|
||||
userId?: string | number | null
|
||||
/** 钱包接口的用户头像地址,紧凑模式下作为 avatar 的兼容字段。 */
|
||||
userAvatar?: string | null
|
||||
/** 钱包接口的用户手机号,紧凑模式下作为 phone 的兼容字段。 */
|
||||
userMobile?: string | null
|
||||
}
|
||||
|
||||
/** 用户信息单元格的展示配置。 */
|
||||
interface UserInfoCellProps {
|
||||
/** 后端接口或上层列表传入的用户摘要。 */
|
||||
user: UserInfoCellUser
|
||||
/** 头像直径,单位为像素。 */
|
||||
/** 展示密度:details 保留完整资料,compact 仅显示头像、姓名和手机号或用户 ID。 */
|
||||
variant?: "details" | "compact"
|
||||
/** 头像直径,单位为像素;未传时详细模式为 38,紧凑模式为 32。 */
|
||||
avatarSize?: number
|
||||
/** 是否展示性别文本。 */
|
||||
showGender?: boolean
|
||||
@@ -71,7 +94,7 @@ interface UserInfoCellProps {
|
||||
|
||||
/** 当前组件的输入与默认展示策略。 */
|
||||
const props = withDefaults(defineProps<UserInfoCellProps>(), {
|
||||
avatarSize: 38,
|
||||
variant: "details",
|
||||
showGender: true,
|
||||
showAccount: true,
|
||||
showPhone: true,
|
||||
@@ -90,6 +113,23 @@ const formatText = (value: string | null | undefined, emptyText: string) => {
|
||||
return text || emptyText
|
||||
}
|
||||
|
||||
/** 头像尺寸集中由展示模式决定,已有页面的显式尺寸优先。 */
|
||||
const resolvedAvatarSize = computed(() => props.avatarSize ?? (props.variant === "compact" ? 32 : 38))
|
||||
|
||||
/** 紧凑模式兼容规范摘要与钱包列表字段,姓名缺失时展示明确占位。 */
|
||||
const compactName = computed(() => formatText(props.user.name?.trim() || props.user.username, "用户信息不可用"))
|
||||
|
||||
/** 紧凑头像仍由统一文件访问能力解析,不在单元格中增加用户资料请求。 */
|
||||
const compactAvatar = computed(() => props.user.avatar?.trim() || props.user.userAvatar?.trim() || undefined)
|
||||
|
||||
/** 紧凑次行优先展示手机号;缺失时以用户 ID 帮助识别,零值主键也保留。 */
|
||||
const compactSecondary = computed(() => {
|
||||
const phone = props.user.phone?.trim() || props.user.userMobile?.trim()
|
||||
if (phone) return phone
|
||||
const id = props.user.userId ?? props.user.id
|
||||
return id !== undefined && id !== null && String(id).trim() ? `ID ${id}` : "用户信息不可用"
|
||||
})
|
||||
|
||||
/** 用户姓名展示文本。 */
|
||||
const displayName = computed(() => formatText(props.user.name, "未填写姓名"))
|
||||
|
||||
@@ -126,6 +166,43 @@ const departmentTitle = computed(() => formatText(props.user.deptName, "未分
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.user-info-cell--compact {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.user-info-cell__compact-avatar {
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
.user-info-cell__compact-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.user-info-cell__compact-text strong {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-primary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-info-cell__compact-text span {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--el-text-color-regular);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-info-cell__avatar {
|
||||
font-weight: 600;
|
||||
color: var(--el-color-primary);
|
||||
|
||||
@@ -92,13 +92,7 @@
|
||||
<el-table :data="rows" row-key="userId" height="100%" v-loading="loading" class="inactive-table">
|
||||
<el-table-column label="用户" :fixed="contentWidth >= 560 ? 'left' : undefined" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="inactive-user">
|
||||
<el-avatar :size="32" :src="row.userAvatar" :icon="User" aria-hidden="true" />
|
||||
<div class="inactive-user__text">
|
||||
<strong :title="row.username">{{ row.username || "用户信息不可用" }}</strong
|
||||
><span>{{ row.userMobile || `ID ${row.userId}` }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<user-info-cell :user="row" variant="compact" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="部门" min-width="120" show-overflow-tooltip>
|
||||
@@ -182,7 +176,7 @@
|
||||
|
||||
<script setup lang="ts" name="walletInactiveStatistics">
|
||||
import { computed, ref } from "vue"
|
||||
import { Refresh, User, Wallet } from "@element-plus/icons-vue"
|
||||
import { Refresh, Wallet } from "@element-plus/icons-vue"
|
||||
import { useElementSize } from "@vueuse/core"
|
||||
import { useWalletInactive } from "/@/hooks/walletInactive"
|
||||
import type { WalletCode, WalletInactiveUser } from "/@/api/wallet/wallet-inactive"
|
||||
@@ -404,28 +398,6 @@ const resetQuery = () => {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.inactive-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.inactive-user :deep(.el-avatar) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.inactive-user__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.inactive-user__text strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.inactive-user__text span,
|
||||
.inactive-muted {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,276 +1,255 @@
|
||||
<template>
|
||||
<div class="layout-padding">
|
||||
<div class="layout-padding-auto layout-padding-view">
|
||||
<div class="layout-padding wallet-qb-page">
|
||||
<div class="layout-padding-auto layout-padding-view wallet-shell">
|
||||
<DraggableDepTree ref="draggableDepTreeRef" placeholder="请输入部门名称" :show-expand="true"
|
||||
@getSelectNode="handleGetSelectNode" switch-inactive-text="包含" switch-active-text="不包含"
|
||||
:defaultTreeWidth="20" :nodeClickable="false">
|
||||
:defaultTreeWidth="18" :nodeClickable="false">
|
||||
<template #default>
|
||||
<SearchPanel v-show="showSearch" ref="queryRef" :model="state.queryForm"
|
||||
:loading="Boolean(state.loading)" :search-text="$t('common.queryBtn')"
|
||||
:reset-text="$t('common.resetBtn')" @search="getDataList" @reset="resetQuery">
|
||||
<el-form-item style="width: 300px" label="用户" prop="userId">
|
||||
<select-user v-model="state.queryForm.userId" />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 250px" label="钱包类型" prop="walletTypeIdList">
|
||||
<select-wallet-type multiple v-model="state.queryForm.walletTypeIdList" placeholder="饭卡类型"
|
||||
clearable />
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 200px" label="月份" prop="walletRecordMonth">
|
||||
<el-date-picker v-model="state.queryForm.walletRecordMonth" value-format="YYYY-MM"
|
||||
type="month" :clearable="false" placeholder="选择月份" />
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
<el-row>
|
||||
<div class="mb8" style="width: 100%">
|
||||
<el-button plain :disabled="multiple" icon="Delete" type="primary"
|
||||
v-auth="'wallet_walletManage_del'" @click="handleDelete(selectObjs)">
|
||||
删除
|
||||
</el-button>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_walletRecord_export'"
|
||||
@exportExcel="exportExcel" class="ml10 mr20" style="float: right"
|
||||
@queryTable="getDataList">
|
||||
<el-tooltip class="item" effect="dark" content="统计" placement="top">
|
||||
<el-button icon="DataLine" circle :loading="balanceStatisticsLoading"
|
||||
@click="viewBalanceStatistics" />
|
||||
</el-tooltip>
|
||||
</right-toolbar>
|
||||
</div>
|
||||
</el-row>
|
||||
<el-table :data="state.dataList" v-loading="state.loading" border :summary-method="getSummaries"
|
||||
show-summary :cell-style="tableStyle.cellStyle" :header-cell-style="tableStyle.headerCellStyle"
|
||||
@selection-change="selectionChangHandle" @sort-change="handleBalanceSortChange">
|
||||
<el-table-column type="index" label="#" width="60" />
|
||||
<el-table-column prop="username" width="120" label="用户" />
|
||||
<el-table-column prop="userAvatar" width="80" label="头像">
|
||||
<template #default="{ row }">
|
||||
<preview-image :src="row.userAvatar" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="userMobile" width="120" label="手机号" />
|
||||
<el-table-column prop="deptName" label="部门" width="200" />
|
||||
<el-table-column prop="walletTypeUniqueCode" width="200" label="饭卡类型">
|
||||
<template #default="{ row }">
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="color-primary">{{ row.walletTypeName }}</div>
|
||||
<div class="flex items-center just ml5">
|
||||
<dict-tag :options="wallet_type" :value="row.walletTypeUniqueCode" />
|
||||
</div>
|
||||
<div class="wallet-content">
|
||||
<SearchPanel class="wallet-filters" v-show="showSearch" ref="queryRef" :model="state.queryForm" title="筛选条件"
|
||||
:loading="state.loading" @search="getDataList" @reset="resetQuery">
|
||||
<el-form-item class="wallet-filter-user" label="用户" prop="userId">
|
||||
<select-user size="small" v-model="state.queryForm.userId" />
|
||||
</el-form-item>
|
||||
<el-form-item class="wallet-filter-type" label="钱包类型" prop="walletTypeIdList">
|
||||
<select-wallet-type size="small" multiple v-model="state.queryForm.walletTypeIdList" placeholder="全部钱包" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item class="wallet-filter-month" label="月份" prop="walletRecordMonth">
|
||||
<el-date-picker size="small" style="width: 100%" v-model="state.queryForm.walletRecordMonth" value-format="YYYY-MM"
|
||||
type="month" :clearable="false" placeholder="选择月份" />
|
||||
</el-form-item>
|
||||
<el-form-item class="wallet-filter-range" label="合计余额" :error="rangeError">
|
||||
<div class="flex items-center gap-2 min-w-0 w-full">
|
||||
<el-input size="small" v-model="state.queryForm.minBalanceYuan" placeholder="最低金额" inputmode="decimal"
|
||||
clearable aria-label="最低合计余额,单位元" maxlength="18" />
|
||||
<span class="wallet-muted shrink-0">至</span>
|
||||
<el-input size="small" v-model="state.queryForm.maxBalanceYuan" placeholder="最高金额" inputmode="decimal"
|
||||
clearable aria-label="最高合计余额,单位元" maxlength="18" />
|
||||
<span class="wallet-muted shrink-0">元</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cardNumber" width="150" label="饭卡卡号" />
|
||||
<el-table-column prop="balance" align="center" width="100" label="余额" sortable="custom">
|
||||
<template #default="{ row }">
|
||||
<div class="color-primary">{{ displayPrice(row.balance) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="isFreeze" width="120" label="是否冻结饭卡">
|
||||
<template #default="{ row }">
|
||||
<dict-tag :options="yes_no_type" :value="row.isFreeze" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="false" prop="activeBindStatus" width="120" label="饭卡绑定状态" />
|
||||
<el-table-column prop="lastTopUpTime" width="200" label="最后充值时间">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.lastTopUpTime">{{ row.lastTopUpTime }}</span>
|
||||
<span v-else class="color-danger">本月未充值</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" :disabled="Boolean(state.loading)" />
|
||||
</el-form-item>
|
||||
</SearchPanel>
|
||||
|
||||
<section class="wallet-detail" aria-labelledby="wallet-detail-title">
|
||||
<div class="wallet-table-toolbar">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 id="wallet-detail-title" class="wallet-section-title">钱包余额统计</h1>
|
||||
<span class="wallet-result-meta">{{ appliedQuery.walletRecordMonth }} 月末 · {{ state.pagination.total }} 位用户 · 单位:元</span>
|
||||
<el-tag v-if="activeRangeLabel" effect="plain" size="small">{{ activeRangeLabel }}</el-tag>
|
||||
</div>
|
||||
<right-toolbar v-model:showSearch="showSearch" :export="'wallet_walletRecord_export'"
|
||||
@exportExcel="exportExcel" @queryTable="getDataList">
|
||||
<el-tooltip content="余额统计" placement="top">
|
||||
<el-button :icon="DataAnalysis" circle :loading="balanceStatisticsLoading" :disabled="state.loading"
|
||||
aria-label="余额统计" @click="openBalanceStatistics" />
|
||||
</el-tooltip>
|
||||
</right-toolbar>
|
||||
</div>
|
||||
<el-alert v-if="pageError" type="error" :title="pageError" show-icon :closable="false" class="mb-3" />
|
||||
<div class="wallet-table-region">
|
||||
<el-table :data="state.dataList" v-loading="state.loading" row-key="userId" height="100%"
|
||||
:summary-method="getSummaries" show-summary :header-cell-style="tableHeaderStyle"
|
||||
@sort-change="handleBalanceSortChange" class="wallet-user-table">
|
||||
<el-table-column prop="username" label="用户" min-width="180">
|
||||
<template #default="{ row }"><user-info-cell :user="row" variant="compact" /></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deptName" label="所属部门" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column v-for="column in walletColumns" :key="column.prop" :prop="column.prop" :label="column.label"
|
||||
align="right" header-align="right" min-width="125" sortable="custom">
|
||||
<template #default="{ row }"><span class="wallet-amount" :class="{ 'wallet-negative': row[column.prop] < 0 }">{{ displayPrice(row[column.prop], '') }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="balance" label="合计余额" align="right" header-align="right" min-width="140" sortable="custom">
|
||||
<template #default="{ row }"><span class="wallet-amount wallet-total-amount" :class="{ 'wallet-negative': row.balance < 0 }">{{ displayPrice(row.balance, '') }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="walletCount" label="钱包数" align="center" width="76" />
|
||||
<el-table-column prop="lastTransactionTime" label="最近流水时间" min-width="168">
|
||||
<template #default="{ row }"><span class="wallet-muted text-sm">{{ row.lastTransactionTime || '暂无流水' }}</span></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty :description="pageError ? '查询失败,请点击刷新重试' : '没有符合条件的用户,可调整余额区间或筛选条件'" :image-size="72" /></template>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="wallet-footer">
|
||||
<span class="wallet-muted text-xs">合计仅包含所选类型和部门范围内的钱包</span>
|
||||
<pagination small @size-change="sizeChangeHandle" @current-change="currentChangeHandle"
|
||||
v-bind="state.pagination" :disabled="state.loading" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</DraggableDepTree>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="showDrawer" @close="drawerClose">
|
||||
<el-table v-loading="balanceStatisticsLoading" :data="balanceStatisticsLoaded ? [balanceStatistics] : []"
|
||||
:empty-text="balanceStatisticsLoading ? '统计中…' : '暂无统计数据'" style="width: 100%">
|
||||
<el-table-column align="center" label="余额统计">
|
||||
<el-table-column align="center" prop="total" label="总余额">
|
||||
<template #default="{ row }">
|
||||
{{ displayPrice(row.total) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" prop="eatTotal" label="饭卡钱包">
|
||||
<template #default="{ row }">
|
||||
{{ displayPrice(row.eatTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" prop="payTotal" label="消费钱包">
|
||||
<template #default="{ row }">
|
||||
{{ displayPrice(row.payTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column align="center" prop="payTotal" label="充值钱包">
|
||||
<template #default="{ row }">
|
||||
{{ displayPrice(row.topupTotal) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-drawer v-model="showDrawer" title="余额统计" size="min(520px, 100vw)" @close="clearStatistics">
|
||||
<section class="wallet-statistics" aria-labelledby="wallet-overview-title" :aria-busy="balanceStatisticsLoading">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h2 id="wallet-overview-title" class="wallet-section-title">{{ appliedQuery.walletRecordMonth }} 月末</h2>
|
||||
<span class="wallet-muted text-sm">{{ balanceStatisticsLoaded ? `覆盖 ${balanceStatistics.userCount || 0} 位用户 · ${balanceStatistics.walletCount || 0} 个钱包` : '当前筛选范围 · 全部用户' }}</span>
|
||||
</div>
|
||||
<el-button :icon="DataAnalysis" :loading="balanceStatisticsLoading" :disabled="state.loading"
|
||||
@click="viewBalanceStatistics">{{ balanceStatisticsError ? '重试统计' : balanceStatisticsLoaded ? '重新统计' : '计算统计' }}</el-button>
|
||||
</div>
|
||||
<el-tag v-if="activeRangeLabel" effect="light" class="mb-3">{{ activeRangeLabel }}</el-tag>
|
||||
<div class="wallet-metrics">
|
||||
<article v-for="card in summaryCards" :key="card.key" class="wallet-metric" :class="{ 'wallet-metric-total': card.key === 'total' }">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<span class="wallet-metric-label">{{ card.label }}</span>
|
||||
<el-icon class="wallet-metric-icon" :style="{ color: card.color }" aria-hidden="true"><component :is="card.icon" /></el-icon>
|
||||
</div>
|
||||
<el-skeleton :loading="balanceStatisticsLoading" animated>
|
||||
<template #template><el-skeleton-item variant="h1" class="my-3" style="width: 70%" /></template>
|
||||
<template #default>
|
||||
<div class="wallet-metric-value"><span v-if="balanceStatisticsLoaded" class="wallet-currency">¥</span>{{ balanceStatisticsLoaded ? displayPrice(balanceStatistics[card.key], '') : '—' }}</div>
|
||||
</template>
|
||||
</el-skeleton>
|
||||
<p class="wallet-muted text-xs">{{ card.description }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="balanceStatisticsError" class="wallet-error mt-3" role="alert">{{ balanceStatisticsError }}</p>
|
||||
<p v-else class="wallet-muted text-xs mt-3" role="status">{{ balanceStatisticsLoading ? '正在计算当前范围的余额…' : balanceStatisticsLoaded ? '统计金额覆盖全部筛选结果,单位:元。' : '点击“计算统计”查看当前筛选范围的全量余额。' }}</p>
|
||||
</section>
|
||||
</el-drawer>
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess"
|
||||
@failure="handleExportFailure" />
|
||||
<async-task-progress-component ref="exportProgressRef" :config="exportTaskConfig" @success="handleExportSuccess" @failure="handleExportFailure" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="systemWalletManage">
|
||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
||||
import { fetchListJoinQb, delObjs, qbTotal, submitQbExport } from "/@/api/wallet/wallet-manage"
|
||||
import { computed, ref } from "vue"
|
||||
import { Coin, CreditCard, DataAnalysis, Money, Wallet } from "@element-plus/icons-vue"
|
||||
import { submitQbUsersExport } from "/@/api/wallet/wallet-manage"
|
||||
import type { WalletQbUserTotal } from "/@/api/wallet/wallet-manage"
|
||||
import { useBatchExport } from "/@/hooks/batchExport"
|
||||
import AsyncTaskProgressComponent from "/@/components/AsyncTaskProgress.vue"
|
||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
||||
import { useDict } from "/@/hooks/dict"
|
||||
import { displayPrice } from "/@/utils/formatMoney"
|
||||
import { formatDate } from "/@/utils/formatTime"
|
||||
import { onBeforeUnmount } from "vue"
|
||||
import { useWalletQbUsers } from "./useWalletQbUsers"
|
||||
|
||||
const { wallet_type, yes_no_type } = useDict("wallet_type", "yes_no_type")
|
||||
// 定义变量内容
|
||||
/** 页面仅管理展示和关联组件,筛选与异步查询生命周期由专用 hook 持有。 */
|
||||
const draggableDepTreeRef = ref()
|
||||
// 搜索变量
|
||||
const queryRef = ref()
|
||||
const showSearch = ref(true)
|
||||
// 多选变量
|
||||
const selectObjs = ref([]) as any
|
||||
const multiple = ref(true)
|
||||
const showDrawer = ref(false) // 余额统计抽屉开关
|
||||
const balanceStatisticsLoading = ref(false)
|
||||
const balanceStatisticsLoaded = ref(false)
|
||||
const balanceStatistics = reactive<any>({})
|
||||
let balanceStatisticsController: AbortController | undefined
|
||||
let balanceStatisticsSequence = 0
|
||||
/** 全量余额统计按需在抽屉展示,主区域留给用户余额明细。 */
|
||||
const showDrawer = ref(false)
|
||||
const { state, appliedQuery, rangeError, pageError, getDataList, currentChangeHandle, sizeChangeHandle, handleBalanceSortChange,
|
||||
viewBalanceStatistics, clearStatistics, balanceStatistics, balanceStatisticsLoading, balanceStatisticsLoaded, balanceStatisticsError, getAppliedQuery } = useWalletQbUsers()
|
||||
|
||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
||||
queryForm: {
|
||||
walletRecordMonth: formatDate(new Date(), "YYYY-mm"),
|
||||
},
|
||||
pageList: fetchListJoinQb,
|
||||
/** 打开统计抽屉后计算已应用筛选范围,关闭时取消在途统计。 */
|
||||
const openBalanceStatistics = () => {
|
||||
showDrawer.value = true
|
||||
return viewBalanceStatistics()
|
||||
}
|
||||
|
||||
/** 各钱包与合计采用相同单位,金额列使用等宽数字并右对齐便于横向对照。 */
|
||||
const walletColumns = [
|
||||
{ prop: "eatBalance", label: "饭卡钱包" }, { prop: "payBalance", label: "消费钱包" }, { prop: "topupBalance", label: "充值钱包" },
|
||||
]
|
||||
/** 四张统计卡固定对应服务端分类,颜色全部沿用主题变量。 */
|
||||
const summaryCards: Array<{ key: keyof WalletQbUserTotal; label: string; description: string; icon: any; color: string }> = [
|
||||
{ key: "total", label: "合计余额", description: "三个钱包类别的余额合计", icon: Wallet, color: "var(--el-color-primary)" },
|
||||
{ key: "eatTotal", label: "饭卡钱包", description: "饭卡类钱包余额", icon: CreditCard, color: "var(--el-color-success)" },
|
||||
{ key: "payTotal", label: "消费钱包", description: "消费类钱包余额", icon: Money, color: "var(--el-color-warning)" },
|
||||
{ key: "topupTotal", label: "充值钱包", description: "充值类钱包余额", icon: Coin, color: "var(--el-color-info)" },
|
||||
]
|
||||
const tableHeaderStyle = { background: "var(--el-fill-color-light)", color: "var(--el-text-color-regular)", fontWeight: "500" }
|
||||
|
||||
/** 明示已经生效的金额范围,与待提交的表单输入区分。 */
|
||||
const activeRangeLabel = computed(() => {
|
||||
const { minBalance, maxBalance } = appliedQuery.value
|
||||
if (minBalance === undefined && maxBalance === undefined) return ""
|
||||
if (minBalance === undefined) return `合计 ≤ ${displayPrice(maxBalance)}`
|
||||
if (maxBalance === undefined) return `合计 ≥ ${displayPrice(minBalance)}`
|
||||
return `${displayPrice(minBalance, '')}–${displayPrice(maxBalance)} 元(含边界)`
|
||||
})
|
||||
|
||||
// table hook
|
||||
const { getDataList, currentChangeHandle, sizeChangeHandle, tableStyle } = useTable(state)
|
||||
|
||||
/** 按所选月份的全部筛选结果排序;切换或取消排序时回到首页,翻页继续沿用当前方向。 */
|
||||
const handleBalanceSortChange = ({ prop, order }: { prop?: string; order: "ascending" | "descending" | null }) => {
|
||||
// 每次替换单列排序条件,避免重复点击累积字段;月末余额由后端先排序再分页。
|
||||
state.ascs = prop === "balance" && order === "ascending" ? ["balance"] : []
|
||||
state.descs = prop === "balance" && order === "descending" ? ["balance"] : []
|
||||
/** 重置全部搜索输入及部门树,恢复当月查询;排序方向保留。 */
|
||||
const resetQuery = () => {
|
||||
queryRef.value?.resetFields()
|
||||
Object.assign(state.queryForm, { userId: undefined, walletTypeIdList: [], deptIds: [], isInclude: true, minBalanceYuan: "", maxBalanceYuan: "" })
|
||||
draggableDepTreeRef.value?.resetData()
|
||||
return getDataList()
|
||||
}
|
||||
|
||||
// 清空搜索条件
|
||||
const resetQuery = () => {
|
||||
// 清空搜索条件
|
||||
queryRef.value?.resetFields()
|
||||
// 清空多选
|
||||
selectObjs.value = []
|
||||
state.queryForm.deptId = ""
|
||||
state.queryForm.deptIds = []
|
||||
draggableDepTreeRef.value.resetData()
|
||||
getDataList()
|
||||
/** 部门变更作为一次完整搜索,后端在用户汇总前限制钱包范围。 */
|
||||
const handleGetSelectNode = (_node: any, checkedTreeNodes: any[], isDeepTree: boolean) => {
|
||||
state.queryForm.deptIds = checkedTreeNodes.map(item => item.id)
|
||||
state.queryForm.isInclude = !isDeepTree
|
||||
return getDataList()
|
||||
}
|
||||
|
||||
// 余额统计
|
||||
const viewBalanceStatistics = async () => {
|
||||
showDrawer.value = true
|
||||
// 慢查询在途时重复点击不再发送统计请求。
|
||||
if (balanceStatisticsLoading.value) return
|
||||
balanceStatisticsLoading.value = true
|
||||
balanceStatisticsLoaded.value = false
|
||||
const sequence = ++balanceStatisticsSequence
|
||||
const controller = new AbortController()
|
||||
balanceStatisticsController = controller
|
||||
try {
|
||||
// 数组也取快照,避免筛选变化影响本次查询参数。
|
||||
const query = JSON.parse(JSON.stringify(state.queryForm))
|
||||
const { code, data, msg } = await qbTotal(query, controller.signal)
|
||||
if (sequence !== balanceStatisticsSequence || controller.signal.aborted) return
|
||||
if (code == 0) {
|
||||
Object.assign(balanceStatistics, data)
|
||||
balanceStatisticsLoaded.value = true
|
||||
return
|
||||
}
|
||||
return useMessage().error(msg || data?.msg || "获取数据失败")
|
||||
} catch (error: any) {
|
||||
if (sequence !== balanceStatisticsSequence || controller.signal.aborted) return
|
||||
useMessage().error(error?.msg || error?.message || "获取数据失败")
|
||||
} finally {
|
||||
if (sequence === balanceStatisticsSequence) {
|
||||
balanceStatisticsLoading.value = false
|
||||
balanceStatisticsController = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭抽屉
|
||||
const drawerClose = () => {
|
||||
// 在关闭开始时取消,避免关闭动画结束前重开抽屉被旧响应覆盖。
|
||||
balanceStatisticsSequence++
|
||||
balanceStatisticsController?.abort()
|
||||
balanceStatisticsController = undefined
|
||||
balanceStatisticsLoading.value = false
|
||||
balanceStatisticsLoaded.value = false
|
||||
for (const key in balanceStatistics) {
|
||||
delete balanceStatistics[key]
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(drawerClose)
|
||||
|
||||
// 导出excel
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:qb",
|
||||
taskName: "钱包记录导出", fileName: "钱包记录.xlsx", submitTask: submitQbExport,
|
||||
/** 表尾明确标注本页合计,避免与抽屉中全部筛选结果的全量统计混淆。 */
|
||||
const getSummaries = ({ columns, data }: { columns: any[]; data: any[] }) => columns.map((column, index) => {
|
||||
if (index === 0) return "本页合计"
|
||||
if (!["balance", "eatBalance", "payBalance", "topupBalance"].includes(column.property)) return ""
|
||||
return displayPrice(data.reduce((total, row) => total + Number(row[column.property] || 0), 0), "")
|
||||
})
|
||||
|
||||
const exportExcel = () => submitExport({ ...state.queryForm })
|
||||
|
||||
const getSummaries = (param: any) => {
|
||||
const { columns, data } = param
|
||||
const totalColumns = ["balance"]
|
||||
const totalRow = Array.from({ length: columns.length })
|
||||
columns.forEach((i: any, index: number) => {
|
||||
if (totalColumns.includes(i.property)) {
|
||||
const total = data
|
||||
.filter((v: any) => v[i.property] != null)
|
||||
.map((v: any) => v[i.property])
|
||||
.reduce((pren: number, next: number) => pren + next, 0)
|
||||
totalRow[index] = displayPrice(total)
|
||||
}
|
||||
})
|
||||
return totalRow
|
||||
}
|
||||
|
||||
// 多选事件
|
||||
const selectionChangHandle = (objs: { id: string }[]) => {
|
||||
selectObjs.value = objs.map(({ id }) => id)
|
||||
multiple.value = !objs.length
|
||||
}
|
||||
|
||||
// 树节点选择时
|
||||
const handleGetSelectNode = (curClickedNode: any, checkedTreeNodes: any, isDeepTree: any) => {
|
||||
state.queryForm.deptId = curClickedNode.id
|
||||
state.queryForm.deptIds = checkedTreeNodes.map((item: any) => item.id)
|
||||
state.queryForm.isInclude = !isDeepTree
|
||||
getDataList()
|
||||
}
|
||||
|
||||
// 删除操作
|
||||
const handleDelete = async (ids: string[]) => {
|
||||
try {
|
||||
await useMessageBox().confirm("此操作将永久删除")
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await delObjs(ids)
|
||||
getDataList()
|
||||
useMessage().success("删除成功")
|
||||
} catch (err: any) {
|
||||
useMessage().error(err.msg)
|
||||
}
|
||||
}
|
||||
/** 全量用户汇总导出复用后台任务进度和下载,使用已生效的查询快照。 */
|
||||
const { exportProgressRef, exportTaskConfig, submitExport, handleExportSuccess, handleExportFailure } = useBatchExport({
|
||||
progressUrl: "/wallet/async-task/progress", redisKey: "wallet:export:qb-users",
|
||||
taskName: "用户钱包余额汇总导出", fileName: "用户钱包余额汇总.xlsx", submitTask: submitQbUsersExport,
|
||||
})
|
||||
const exportExcel = () => submitExport(getAppliedQuery())
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.wallet-qb-page { width: 100%; min-width: 0; }
|
||||
.wallet-shell { min-height: 0; padding: 0; background: var(--el-bg-color); }
|
||||
.wallet-shell :deep(.draggable-dep-tree) { height: 100%; min-height: 0; }
|
||||
.wallet-shell :deep(.draggable-dep-tree__content) { overflow: hidden; padding: 8px 10px 8px 14px; }
|
||||
.wallet-content { display: flex; flex-direction: column; height: 100%; min-width: 0; min-height: 0; gap: 8px; }
|
||||
.wallet-section-title { margin: 0; font-size: 14px; line-height: 22px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.wallet-muted, .wallet-result-meta { color: var(--el-text-color-regular); }
|
||||
.wallet-result-meta { font-size: 12px; line-height: 20px; }
|
||||
.wallet-filters { flex-shrink: 0; }
|
||||
.wallet-content :deep(.wallet-filters) { margin: 0; padding: 6px 10px 0; border-radius: 4px; }
|
||||
.wallet-content :deep(.wallet-filters .search-panel__header) { min-height: 20px; margin-bottom: 4px; }
|
||||
.wallet-content :deep(.wallet-filters .el-form-item) { margin-right: 12px; margin-bottom: 6px; }
|
||||
.wallet-content :deep(.wallet-filters .el-form-item__label) { height: 26px; line-height: 26px; font-size: 12px; padding-right: 8px; }
|
||||
.wallet-content :deep(.wallet-filters .el-form-item__content) { min-width: 0; line-height: 26px; }
|
||||
.wallet-content :deep(.wallet-filters .search-panel__actions) { min-height: 26px; margin-bottom: 6px; }
|
||||
.wallet-content :deep(.wallet-filters .search-panel__actions .el-button) { height: 26px; padding: 4px 10px; font-size: 12px; }
|
||||
.wallet-filter-user { width: 200px; }
|
||||
.wallet-filter-type { width: 205px; }
|
||||
.wallet-filter-month { width: 165px; }
|
||||
.wallet-filter-range { width: 310px; padding-bottom: 4px; }
|
||||
.wallet-content :deep(.wallet-filters .wallet-filter-range.is-error) { margin-bottom: 18px; }
|
||||
.wallet-detail { display: flex; flex: 1; flex-direction: column; min-width: 0; min-height: 0; background: var(--el-bg-color); }
|
||||
.wallet-table-toolbar { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px 12px; padding: 2px 0 8px; }
|
||||
.wallet-table-toolbar :deep(.el-button.is-circle) { width: 28px; height: 28px; padding: 6px; }
|
||||
.wallet-table-toolbar :deep(.el-button + .el-button) { margin-left: 6px; }
|
||||
.wallet-table-region { flex: 1; min-width: 0; min-height: 0; }
|
||||
.wallet-user-table { font-size: 13px; }
|
||||
.wallet-user-table :deep(.el-table__cell) { padding: 6px 0; }
|
||||
.wallet-user-table :deep(.cell) { line-height: 18px; }
|
||||
.wallet-user-table :deep(.el-table__header .cell) { font-size: 12px; }
|
||||
.wallet-user-table :deep(.el-table__footer-wrapper td) { font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
.wallet-amount { font-variant-numeric: tabular-nums; }
|
||||
.wallet-total-amount { font-size: 14px; font-weight: 600; color: var(--el-color-primary); }
|
||||
.wallet-negative, .wallet-error { color: var(--el-color-danger); }
|
||||
.wallet-footer { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px 12px; padding-top: 8px; }
|
||||
.wallet-footer :deep(.el-pagination) { margin-top: 0; flex-wrap: wrap; gap: 6px; }
|
||||
.wallet-metrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.wallet-metric { min-width: 0; padding: 12px; border: 1px solid var(--el-border-color-lighter); border-radius: 4px; background: var(--el-fill-color-blank); }
|
||||
.wallet-metric-total { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary-light-8); }
|
||||
.wallet-metric-label { color: var(--el-text-color-regular); font-weight: 500; font-size: 12px; }
|
||||
.wallet-metric-icon { font-size: 16px; }
|
||||
.wallet-metric-value { margin: 10px 0 8px; color: var(--el-text-color-primary); font-size: 24px; line-height: 1.2; font-weight: 600; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.wallet-metric-total .wallet-metric-value { color: var(--el-color-primary); }
|
||||
.wallet-currency { margin-right: 4px; font-size: 14px; font-weight: 400; }
|
||||
@media (max-width: 768px) {
|
||||
.wallet-shell { overflow: auto; }
|
||||
.wallet-shell :deep(.draggable-dep-tree) { height: auto; min-height: 100%; flex-direction: column; gap: 8px; }
|
||||
.wallet-shell :deep(.draggable-dep-tree__tree-pane) { width: 100% !important; height: 160px !important; flex: 0 0 160px; }
|
||||
.wallet-shell :deep(.draggable-dep-tree__content-pane) { width: 100% !important; height: auto !important; overflow: visible; }
|
||||
.wallet-shell :deep(.draggable-dep-tree > .splitpanes__splitter), .wallet-shell :deep(.draggable-dep-tree__fold) { display: none; }
|
||||
.wallet-shell :deep(.dep-tree-panel) { margin-right: 0; }
|
||||
.wallet-shell :deep(.draggable-dep-tree__content) { padding: 0 6px 8px; height: auto; overflow: visible; }
|
||||
.wallet-content { height: auto; }
|
||||
.wallet-table-region { flex: 0 0 440px; height: 440px; }
|
||||
.wallet-footer :deep(.el-pagination__sizes), .wallet-footer :deep(.el-pagination__jump) { display: none; }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.wallet-filter-user, .wallet-filter-type, .wallet-filter-month, .wallet-filter-range { width: 100%; }
|
||||
.wallet-content :deep(.wallet-filters .el-form-item) { margin-right: 0; }
|
||||
.wallet-metric-value { font-size: 22px; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) { :deep(.el-skeleton.is-animated .el-skeleton__item) { animation: none; } }
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue"
|
||||
import { fetchQbUsers, qbUsersTotal } from "/@/api/wallet/wallet-manage"
|
||||
import type { WalletQbUserQuery, WalletQbUserRow, WalletQbUserTotal } from "/@/api/wallet/wallet-manage"
|
||||
import { useMessage } from "/@/hooks/message"
|
||||
import { formatDate } from "/@/utils/formatTime"
|
||||
|
||||
/** 金额输入以元展示,空字符串表示不限;请求使用整数分,避免浮点数比较金额。 */
|
||||
export function parseBalanceYuan(value: string | number | undefined): number | undefined {
|
||||
const input = String(value ?? "").trim()
|
||||
if (!input) return undefined
|
||||
if (!/^-?(?:\d+(?:\.\d{0,2})?|\.\d{1,2})$/.test(input)) throw new Error("余额最多输入两位小数")
|
||||
// 先拼接整数分再转换数值,大额输入也不经过小数乘法,避免二进制浮点丢失一分钱。
|
||||
const [integer, fraction = ""] = input.replace(/^-/, "").split(".")
|
||||
const cents = Number(`${integer || "0"}${fraction.padEnd(2, "0")}`) * (input.startsWith("-") ? -1 : 1)
|
||||
if (!Number.isSafeInteger(cents)) throw new Error("余额超出支持范围")
|
||||
return cents
|
||||
}
|
||||
|
||||
/** 用户汇总页独立持有筛选快照、取消句柄和请求序号,旧响应不能覆盖新查询。 */
|
||||
export function useWalletQbUsers() {
|
||||
/** 搜索表单是待提交条件;翻页、排序、统计与导出只使用已应用条件。 */
|
||||
const state = reactive({
|
||||
queryForm: {
|
||||
walletRecordMonth: formatDate(new Date(), "YYYY-mm"),
|
||||
userId: undefined as string | number | undefined,
|
||||
walletTypeIdList: [] as Array<string | number>,
|
||||
deptIds: [] as Array<string | number>,
|
||||
isInclude: true,
|
||||
minBalanceYuan: "",
|
||||
maxBalanceYuan: "",
|
||||
},
|
||||
dataList: [] as WalletQbUserRow[],
|
||||
pagination: { current: 1, size: 20, total: 0, pageSizes: [20, 50, 100, 200], layout: "total, sizes, prev, pager, next, jumper" },
|
||||
loading: false,
|
||||
})
|
||||
/** 最近一次提交的查询快照,金额已转成整数分。 */
|
||||
const appliedQuery = ref<WalletQbUserQuery>({ walletRecordMonth: state.queryForm.walletRecordMonth })
|
||||
/** 服务端排序字段白名单,取消排序后由后端使用稳定的用户顺序。 */
|
||||
const sort = reactive({ ascs: "", descs: "" })
|
||||
const pageError = ref("")
|
||||
const balanceStatisticsLoading = ref(false)
|
||||
const balanceStatisticsLoaded = ref(false)
|
||||
const balanceStatisticsError = ref("")
|
||||
const balanceStatistics = reactive<Partial<WalletQbUserTotal>>({})
|
||||
/** 列表与统计独立取消,改变筛选或卸载时释放浏览器等待。 */
|
||||
let listController: AbortController | undefined
|
||||
let statisticsController: AbortController | undefined
|
||||
let listSequence = 0
|
||||
let statisticsSequence = 0
|
||||
let disposed = false
|
||||
|
||||
/** 构建可独立使用的筛选快照,单边、零和负数边界均保留。 */
|
||||
const buildQuery = (): WalletQbUserQuery => {
|
||||
const { walletRecordMonth, userId, walletTypeIdList, deptIds, isInclude, minBalanceYuan, maxBalanceYuan } = state.queryForm
|
||||
const minBalance = parseBalanceYuan(minBalanceYuan)
|
||||
const maxBalance = parseBalanceYuan(maxBalanceYuan)
|
||||
if (minBalance !== undefined && maxBalance !== undefined && minBalance > maxBalance) throw new Error("最低余额不能大于最高余额")
|
||||
return { walletRecordMonth, userId, walletTypeIdList: [...walletTypeIdList], deptIds: [...deptIds], isInclude, minBalance, maxBalance }
|
||||
}
|
||||
|
||||
/** 将区间错误展示在金额输入旁,提交时也阻止无效请求。 */
|
||||
const rangeError = computed(() => {
|
||||
try { buildQuery(); return "" } catch (error: any) { return error.message }
|
||||
})
|
||||
|
||||
/** 清除旧统计,避免月份或区间改变后仍展示上一范围的数字。 */
|
||||
const clearStatistics = () => {
|
||||
statisticsSequence++
|
||||
statisticsController?.abort()
|
||||
statisticsController = undefined
|
||||
balanceStatisticsLoading.value = false
|
||||
balanceStatisticsLoaded.value = false
|
||||
balanceStatisticsError.value = ""
|
||||
for (const key of Object.keys(balanceStatistics)) delete balanceStatistics[key as keyof WalletQbUserTotal]
|
||||
}
|
||||
|
||||
/** 查询已应用筛选下的当前页;成功、失败和取消均受请求序号约束。 */
|
||||
const queryPage = async () => {
|
||||
listController?.abort()
|
||||
const controller = new AbortController()
|
||||
listController = controller
|
||||
const sequence = ++listSequence
|
||||
state.loading = true
|
||||
pageError.value = ""
|
||||
try {
|
||||
const query = JSON.parse(JSON.stringify(appliedQuery.value))
|
||||
const response = await fetchQbUsers({ ...query, ...sort, current: state.pagination.current, size: state.pagination.size }, controller.signal)
|
||||
if (disposed || sequence !== listSequence || controller.signal.aborted) return
|
||||
if (response.code !== undefined && response.code !== 0) throw new Error(response.msg || "余额查询失败")
|
||||
state.dataList = response.data?.records ?? []
|
||||
state.pagination.total = response.data?.total ?? 0
|
||||
} catch (error: any) {
|
||||
if (disposed || sequence !== listSequence || controller.signal.aborted) return
|
||||
state.dataList = []
|
||||
state.pagination.total = 0
|
||||
pageError.value = error?.msg || error?.message || "余额查询失败,请重试"
|
||||
} finally {
|
||||
if (sequence === listSequence && !disposed) {
|
||||
state.loading = false
|
||||
listController = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提交搜索条件,范围变更后清空旧统计;区间无效时不发出请求。 */
|
||||
const getDataList = async () => {
|
||||
let query: WalletQbUserQuery
|
||||
try { query = buildQuery() } catch (error: any) { useMessage().error(error.message); return }
|
||||
// 同一范围重新查询也可能有新流水,旧统计必须失效,避免刷新后金额新旧混用。
|
||||
clearStatistics()
|
||||
appliedQuery.value = query
|
||||
state.pagination.current = 1
|
||||
state.dataList = []
|
||||
await queryPage()
|
||||
}
|
||||
|
||||
/** 翻页保持已查询范围,不隐式应用尚未提交的表单输入。 */
|
||||
const currentChangeHandle = (current: number) => {
|
||||
if (current === state.pagination.current) return
|
||||
state.pagination.current = current
|
||||
return queryPage()
|
||||
}
|
||||
|
||||
/** 改变每页人数时回到第一页。 */
|
||||
const sizeChangeHandle = (size: number) => {
|
||||
state.pagination.size = size
|
||||
state.pagination.current = 1
|
||||
return queryPage()
|
||||
}
|
||||
|
||||
/** 四个金额列均由后端对全部匹配用户排序,切换方向后回到第一页。 */
|
||||
const handleBalanceSortChange = ({ prop, order }: { prop?: string; order: "ascending" | "descending" | null }) => {
|
||||
const fields: Record<string, string> = { balance: "balance", eatBalance: "eat_balance", payBalance: "pay_balance", topupBalance: "topup_balance" }
|
||||
const field = fields[prop || ""]
|
||||
sort.ascs = field && order === "ascending" ? field : ""
|
||||
sort.descs = field && order === "descending" ? field : ""
|
||||
state.pagination.current = 1
|
||||
return queryPage()
|
||||
}
|
||||
|
||||
/** 全量统计按需计算,翻页不重复扫描历史余额;统计失败可直接重试。 */
|
||||
const viewBalanceStatistics = async () => {
|
||||
if (balanceStatisticsLoading.value || state.loading || disposed) return
|
||||
const sequence = ++statisticsSequence
|
||||
const controller = new AbortController()
|
||||
statisticsController = controller
|
||||
balanceStatisticsLoading.value = true
|
||||
balanceStatisticsLoaded.value = false
|
||||
balanceStatisticsError.value = ""
|
||||
try {
|
||||
const query = JSON.parse(JSON.stringify(appliedQuery.value))
|
||||
const response = await qbUsersTotal(query, controller.signal)
|
||||
if (disposed || sequence !== statisticsSequence || controller.signal.aborted) return
|
||||
if (response.code !== undefined && response.code !== 0) throw new Error(response.msg || "统计失败")
|
||||
Object.assign(balanceStatistics, response.data)
|
||||
balanceStatisticsLoaded.value = true
|
||||
} catch (error: any) {
|
||||
if (disposed || sequence !== statisticsSequence || controller.signal.aborted) return
|
||||
balanceStatisticsError.value = error?.msg || error?.message || "统计失败,请重试"
|
||||
} finally {
|
||||
if (sequence === statisticsSequence && !disposed) {
|
||||
balanceStatisticsLoading.value = false
|
||||
statisticsController = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 导出与当前页面使用同一范围快照,不带列表分页。 */
|
||||
const getAppliedQuery = () => JSON.parse(JSON.stringify({ ...appliedQuery.value, ...sort }))
|
||||
|
||||
/** 组件卸载时取消在途请求,防止旧响应写入或弹出错误。 */
|
||||
const dispose = () => {
|
||||
disposed = true
|
||||
listSequence++
|
||||
listController?.abort()
|
||||
clearStatistics()
|
||||
}
|
||||
|
||||
onMounted(getDataList)
|
||||
onBeforeUnmount(dispose)
|
||||
return { state, appliedQuery, rangeError, pageError, getDataList, currentChangeHandle, sizeChangeHandle, handleBalanceSortChange,
|
||||
viewBalanceStatistics, clearStatistics, balanceStatistics, balanceStatisticsLoading, balanceStatisticsLoaded, balanceStatisticsError,
|
||||
getAppliedQuery, dispose }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ function load(source, mocks, globals = {}) {
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
for (const scene of ["cz", "jz", "qb"]) {
|
||||
for (const scene of ["cz", "jz"]) {
|
||||
test(`${scene} 页面提交对应后台任务,固定筛选并保留进度和下载流程`, async () => {
|
||||
const requests = [], progress = [], downloads = []
|
||||
const submitted = { taskId: "a".repeat(32), redisKey: `wallet:export:${scene}`, status: "PENDING" }
|
||||
@@ -48,12 +48,12 @@ for (const scene of ["cz", "jz", "qb"]) {
|
||||
const state = page.default.setup({}, { expose() {} })
|
||||
state.exportProgressRef.value = { startSubmitting: name => progress.push(name), start: value => progress.push(value), submissionFailed: message => assert.fail(message) }
|
||||
Object.assign(state.state.queryForm, { current: 8, size: 10, userId: "123", deptIds: ["456"], walletTypeIdList: ["789"], isInclude: false })
|
||||
if (scene !== "qb") Object.assign(state.state.queryForm, { bizType: "3", createTimeList: ["2026-09-01", "2026-09-30"], tagList: ["7"] })
|
||||
Object.assign(state.state.queryForm, { bizType: "3", createTimeList: ["2026-09-01", "2026-09-30"], tagList: ["7"] })
|
||||
const before = JSON.parse(JSON.stringify(state.state.queryForm))
|
||||
const expected = { ...before }; delete expected.current; delete expected.size
|
||||
await state.exportExcel()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(requests[0].url, scene === "qb" ? "/wallet/wallet-manage/export-qb-async" : `/wallet/wallet-transaction/export-${scene}-async`)
|
||||
assert.equal(requests[0].url, `/wallet/wallet-transaction/export-${scene}-async`)
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].data, expected)
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(state.state.queryForm)), before)
|
||||
@@ -66,3 +66,66 @@ for (const scene of ["cz", "jz", "qb"]) {
|
||||
assert.deepEqual(downloads, [["https://files.example.com/export.xlsx", "原导出文件.xlsx"]])
|
||||
})
|
||||
}
|
||||
|
||||
test("用户钱包汇总导出沿用已应用的区间和筛选,分页和未提交的表单不进入后台任务", async () => {
|
||||
const requests = [], progress = [], downloads = []
|
||||
const submitted = { taskId: "b".repeat(32), redisKey: "wallet:export:qb-users", status: "PENDING" }
|
||||
const request = options => {
|
||||
requests.push(options)
|
||||
return Promise.resolve(options.url.endsWith("/page-qb-users")
|
||||
? { code: 0, data: { records: [], total: 100 } } : { data: submitted })
|
||||
}
|
||||
const tasks = load(fs.readFileSync(path.join(root, "api/asyncTask.ts"), "utf8"), { "/@/utils/request": { default: request } })
|
||||
const api = load(fs.readFileSync(path.join(root, "api/wallet/wallet-manage.ts"), "utf8"), {
|
||||
"/@/utils/request": { default: request }, "/@/api/asyncTask": tasks,
|
||||
})
|
||||
const message = { useMessage: () => ({ error: message => assert.fail(message) }) }
|
||||
const users = load(fs.readFileSync(path.join(root, "views/wallet/wallet-qb/useWalletQbUsers.ts"), "utf8"), {
|
||||
vue, "/@/api/wallet/wallet-manage": api, "/@/hooks/message": message,
|
||||
"/@/utils/formatTime": { formatDate: () => "2026-11" },
|
||||
})
|
||||
const batch = load(fs.readFileSync(path.join(root, "hooks/batchExport.ts"), "utf8"), {
|
||||
vue, "/@/components/AsyncTaskProgress.vue": {}, "/@/hooks/message": message,
|
||||
"/@/utils/exportDownload": { downloadExportFile: (...args) => downloads.push(args) },
|
||||
})
|
||||
const descriptor = parse(fs.readFileSync(path.join(root, "views/wallet/wallet-qb/index.vue"), "utf8")).descriptor
|
||||
const page = load(compileScript(descriptor, { id: "qb-users-export" }).content, {
|
||||
vue, "./useWalletQbUsers": users, "/@/api/wallet/wallet-manage": api,
|
||||
"/@/hooks/batchExport": batch, "/@/components/AsyncTaskProgress.vue": {},
|
||||
"/@/hooks/message": message, "/@/hooks/dict": { useDict: () => ({}) },
|
||||
"/@/utils/formatMoney": {}, "/@/utils/formatTime": { formatDate: () => "2026-11" },
|
||||
}, { ref: vue.ref, reactive: vue.reactive, computed: vue.computed })
|
||||
const renderer = vue.createRenderer({
|
||||
createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
|
||||
setText() {}, setElementText() {}, patchProp() {}, insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null,
|
||||
})
|
||||
let state
|
||||
const app = renderer.createApp({ setup() { state = page.default.setup({}, { expose() {} }); return () => null } })
|
||||
app.mount({})
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
state.exportProgressRef.value = { startSubmitting: name => progress.push(name), start: value => progress.push(value), submissionFailed: message => assert.fail(message) }
|
||||
Object.assign(state.state.queryForm, { userId: "123", deptIds: ["456"], walletTypeIdList: ["789"], isInclude: false,
|
||||
minBalanceYuan: "1000.01", maxBalanceYuan: "2000" })
|
||||
await state.getDataList()
|
||||
const expected = state.getAppliedQuery()
|
||||
state.currentChangeHandle(8)
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
Object.assign(state.state.queryForm, { minBalanceYuan: "0", walletRecordMonth: "2026-10" })
|
||||
state.state.queryForm.deptIds.push("999")
|
||||
await state.exportExcel()
|
||||
const exported = requests.filter(request => request.url.endsWith("/export-qb-users-async"))
|
||||
assert.equal(exported.length, 1)
|
||||
assert.equal(exported[0].method, "post")
|
||||
assert.deepEqual(exported[0].data, expected)
|
||||
assert.equal(exported[0].data.minBalance, 100001)
|
||||
assert.equal(exported[0].data.maxBalance, 200000)
|
||||
assert.equal(exported[0].data.current, undefined)
|
||||
assert.equal(exported[0].data.size, undefined)
|
||||
assert.equal(state.exportTaskConfig.progressUrl, "/wallet/async-task/progress")
|
||||
assert.equal(state.exportTaskConfig.redisKey, submitted.redisKey)
|
||||
assert.deepEqual(progress[1], submitted)
|
||||
state.handleExportSuccess({ ...submitted, status: "SUCCESS", resultFileUrl: "https://files.example.com/users.xlsx", resultFileName: "用户钱包余额.xlsx" })
|
||||
assert.deepEqual(downloads, [["https://files.example.com/users.xlsx", "用户钱包余额.xlsx"]])
|
||||
} finally { app.unmount() }
|
||||
})
|
||||
+148
-114
@@ -8,179 +8,213 @@ const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||
|
||||
const root = path.join(__dirname, "../src")
|
||||
const filename = path.join(root, "views/wallet/wallet-qb/index.vue")
|
||||
const descriptor = parse(fs.readFileSync(filename, "utf8"), { filename }).descriptor
|
||||
const source = compileScript(descriptor, { id: "wallet-qb-statistics-test" }).content
|
||||
|
||||
function load(source, mocks) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
|
||||
}).outputText
|
||||
const mod = { exports: {} }
|
||||
const autoImports = ["ref", "reactive", "onMounted"]
|
||||
const autoImports = ["ref", "reactive", "computed", "watch", "onMounted", "onBeforeUnmount"]
|
||||
new Function("module", "exports", "require", ...autoImports, compiled)(
|
||||
mod, mod.exports, id => id in mocks ? mocks[id] : require(id), ...autoImports.map(key => vue[key]),
|
||||
)
|
||||
return mod.exports
|
||||
}
|
||||
|
||||
function mount() {
|
||||
const requests = []
|
||||
const lists = []
|
||||
const listRequests = []
|
||||
const errors = []
|
||||
function mount({ deferredLists = false } = {}) {
|
||||
const lists = [], statistics = [], errors = []
|
||||
const request = options => {
|
||||
if (options.url.endsWith("/page-qb-users")) {
|
||||
if (deferredLists) return new Promise((resolve, reject) => lists.push({ ...options, resolve, reject }))
|
||||
lists.push(options)
|
||||
return Promise.resolve({ code: 0, data: { records: [], total: 100 } })
|
||||
}
|
||||
assert.equal(options.url, "/wallet/wallet-manage/qb-users-total")
|
||||
return new Promise((resolve, reject) => statistics.push({ ...options, resolve, reject }))
|
||||
}
|
||||
const api = load(fs.readFileSync(path.join(root, "api/wallet/wallet-manage.ts"), "utf8"), {
|
||||
"/@/utils/request": options => { listRequests.push(options); return Promise.resolve({ data: { records: [], total: 100 } }) },
|
||||
"/@/api/asyncTask": {},
|
||||
"/@/utils/request": request, "/@/api/asyncTask": {},
|
||||
})
|
||||
const table = load(fs.readFileSync(path.join(root, "hooks/table.ts"), "utf8"), {
|
||||
"element-plus": { ElMessage: { error: message => errors.push(message) } },
|
||||
"/@/utils/other": {}, "../utils/formatTime": {},
|
||||
})
|
||||
const page = load(source, {
|
||||
"/@/hooks/table": table,
|
||||
"/@/api/wallet/wallet-manage": {
|
||||
fetchListJoinQb: query => { lists.push(query); return api.fetchListJoinQb(query) },
|
||||
qbTotal: (query, signal) => new Promise((resolve, reject) => requests.push({ query, signal, resolve, reject })),
|
||||
},
|
||||
"/@/hooks/batchExport": { useBatchExport: () => ({}) },
|
||||
"/@/components/AsyncTaskProgress.vue": {},
|
||||
const hook = load(fs.readFileSync(path.join(root, "views/wallet/wallet-qb/useWalletQbUsers.ts"), "utf8"), {
|
||||
vue, "/@/api/wallet/wallet-manage": api,
|
||||
"/@/hooks/message": { useMessage: () => ({ error: message => errors.push(message) }) },
|
||||
"/@/hooks/dict": { useDict: () => ({}) },
|
||||
"/@/utils/formatMoney": {},
|
||||
"/@/utils/formatTime": { formatDate: () => "2026-10" },
|
||||
"/@/utils/formatTime": { formatDate: () => "2026-11" },
|
||||
})
|
||||
const renderer = vue.createRenderer({
|
||||
createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
|
||||
setText() {}, setElementText() {}, patchProp() {}, insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null,
|
||||
})
|
||||
let state
|
||||
const app = renderer.createApp({
|
||||
setup() { state = page.default.setup({}, { expose() {} }); return () => null },
|
||||
})
|
||||
const app = renderer.createApp({ setup() { state = hook.useWalletQbUsers(); return () => null } })
|
||||
app.mount({})
|
||||
return { state, requests, lists, listRequests, errors, close: () => app.unmount() }
|
||||
return { state, lists, statistics, errors, close: () => app.unmount() }
|
||||
}
|
||||
|
||||
const settle = () => new Promise(resolve => setImmediate(resolve))
|
||||
const result = total => ({ code: 0, data: { total, eatTotal: total, payTotal: 0, topupTotal: 0 } })
|
||||
const result = total => ({ code: 0, data: { total, eatTotal: total, payTotal: 0, topupTotal: 0, userCount: 4, walletCount: 7 } })
|
||||
|
||||
test("余额排序经真实表格 hook 和 API 传至后端,切换回首页、翻页保留筛选且取消后恢复默认", async () => {
|
||||
const page = mount()
|
||||
try {
|
||||
await settle()
|
||||
Object.assign(page.state.state.queryForm, { deptIds: ["11"], walletTypeIdList: ["21"], isInclude: false })
|
||||
page.state.currentChangeHandle(3)
|
||||
await settle()
|
||||
await page.state.handleBalanceSortChange({ prop: "balance", order: "ascending" })
|
||||
const ascending = page.listRequests.at(-1)
|
||||
assert.equal(ascending.url, "/wallet/wallet-manage/page-join-qb")
|
||||
assert.equal(ascending.method, "post")
|
||||
assert.deepEqual(ascending.params, { current: 1, size: 10, ascs: "balance", descs: undefined })
|
||||
assert.equal(ascending.data.walletRecordMonth, "2026-10")
|
||||
assert.deepEqual(ascending.data.deptIds, ["11"])
|
||||
assert.deepEqual(ascending.data.walletTypeIdList, ["21"])
|
||||
assert.equal(ascending.data.isInclude, false)
|
||||
page.state.currentChangeHandle(2)
|
||||
await settle()
|
||||
assert.deepEqual(page.listRequests.at(-1).params, { current: 2, size: 10, ascs: "balance", descs: undefined })
|
||||
await page.state.handleBalanceSortChange({ prop: "balance", order: "descending" })
|
||||
await page.state.handleBalanceSortChange({ prop: "balance", order: "descending" })
|
||||
assert.deepEqual(page.listRequests.at(-1).params, { current: 1, size: 10, ascs: undefined, descs: "balance" })
|
||||
await page.state.handleBalanceSortChange({ order: null })
|
||||
assert.deepEqual(page.listRequests.at(-1).params, { current: 1, size: 10, ascs: undefined, descs: undefined })
|
||||
assert.equal(page.requests.length, 0)
|
||||
} finally { page.close() }
|
||||
test("用户汇总 API 使用新路径并传递分页、排序和取消信号,旧接口继续兼容", async () => {
|
||||
const requests = []
|
||||
const api = load(fs.readFileSync(path.join(root, "api/wallet/wallet-manage.ts"), "utf8"), {
|
||||
"/@/utils/request": options => { requests.push(options); return Promise.resolve({}) }, "/@/api/asyncTask": {},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const query = { current: 2, size: 20, ascs: "eat_balance", descs: "", walletRecordMonth: "2026-11", minBalance: 0 }
|
||||
await api.fetchQbUsers(query, controller.signal)
|
||||
assert.equal(requests[0].url, "/wallet/wallet-manage/page-qb-users")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.deepEqual(requests[0].params, { current: 2, size: 20, ascs: "eat_balance", descs: undefined })
|
||||
assert.equal(requests[0].data, query)
|
||||
assert.equal(requests[0].signal, controller.signal)
|
||||
await api.qbUsersTotal(query, controller.signal)
|
||||
assert.equal(requests[1].url, "/wallet/wallet-manage/qb-users-total")
|
||||
assert.equal(requests[1].method, "post")
|
||||
assert.equal(requests[1].signal, controller.signal)
|
||||
await api.qbTotal(query, controller.signal)
|
||||
await api.fetchListJoinQb(query)
|
||||
assert.equal(requests[2].url, "/wallet/wallet-manage/qb-total")
|
||||
assert.equal(requests[3].url, "/wallet/wallet-manage/page-join-qb")
|
||||
})
|
||||
|
||||
test("列表首次加载、筛选和翻页均不触发全量余额统计", async () => {
|
||||
test("首次加载和翻页仅查询用户汇总列表,统计由用户单独触发", async () => {
|
||||
const page = mount()
|
||||
try {
|
||||
await settle()
|
||||
assert.equal(page.lists.length, 1)
|
||||
await page.state.getDataList()
|
||||
page.state.currentChangeHandle(2)
|
||||
await settle()
|
||||
page.state.sizeChangeHandle(20)
|
||||
await settle()
|
||||
assert.equal(page.lists.length, 4)
|
||||
assert.equal(page.requests.length, 0)
|
||||
assert.equal(page.lists.length, 3)
|
||||
assert.equal(page.statistics.length, 0)
|
||||
} finally { page.close() }
|
||||
})
|
||||
|
||||
test("统计请求在途去重,数组筛选冻结且加载完成前不展示旧统计", async () => {
|
||||
test("合计余额区间以元输入精确转成分,包含零、负数及单边条件", async () => {
|
||||
const page = mount()
|
||||
try {
|
||||
Object.assign(page.state.state.queryForm, { deptIds: ["11"], walletTypeIdList: ["9007199254740993999"], isInclude: false })
|
||||
page.state.viewBalanceStatistics()
|
||||
page.state.viewBalanceStatistics()
|
||||
assert.equal(page.requests.length, 1)
|
||||
assert.equal(page.state.balanceStatisticsLoading.value, true)
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, false)
|
||||
page.state.state.queryForm.deptIds.push("12")
|
||||
assert.deepEqual(page.requests[0].query, {
|
||||
walletRecordMonth: "2026-10", deptIds: ["11"], walletTypeIdList: ["9007199254740993999"], isInclude: false,
|
||||
})
|
||||
page.requests[0].resolve(result(1200))
|
||||
await settle()
|
||||
assert.equal(page.state.balanceStatistics.total, 1200)
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, true)
|
||||
assert.equal(page.state.balanceStatisticsLoading.value, false)
|
||||
for (const [minimum, maximum, expectedMin, expectedMax] of [
|
||||
["1000.10", "2000.99", 100010, 200099], ["-5.01", "0", -501, 0],
|
||||
["90071992547409.01", "90071992547409.91", 9007199254740901, 9007199254740991],
|
||||
["0", "", 0, undefined], ["", "2000", undefined, 200000], ["", "", undefined, undefined],
|
||||
]) {
|
||||
Object.assign(page.state.state.queryForm, { minBalanceYuan: minimum, maxBalanceYuan: maximum })
|
||||
await page.state.getDataList()
|
||||
assert.equal(page.lists.at(-1).data.minBalance, expectedMin)
|
||||
assert.equal(page.lists.at(-1).data.maxBalance, expectedMax)
|
||||
assert.equal(page.state.rangeError.value, "")
|
||||
}
|
||||
} finally { page.close() }
|
||||
})
|
||||
|
||||
test("关闭和重开立即取消旧请求,旧成功或失败不污染新统计与 loading", async () => {
|
||||
test("反向区间、非法金额及超出分精度的输入拦截请求并保留已应用条件", async () => {
|
||||
const page = mount()
|
||||
try {
|
||||
await settle()
|
||||
const original = page.state.getAppliedQuery()
|
||||
for (const [minimum, maximum] of [["2000", "1000"], ["abc", ""], ["1.001", ""], ["9007199254740992", ""]]) {
|
||||
const before = page.lists.length
|
||||
Object.assign(page.state.state.queryForm, { minBalanceYuan: minimum, maxBalanceYuan: maximum })
|
||||
await page.state.getDataList()
|
||||
assert.equal(page.lists.length, before)
|
||||
assert.ok(page.state.rangeError.value)
|
||||
assert.deepEqual(page.state.getAppliedQuery(), original)
|
||||
}
|
||||
} finally { page.close() }
|
||||
})
|
||||
|
||||
test("翻页、分项排序和统计沿用已应用快照,未提交的表单修改不会改变查询范围", async () => {
|
||||
const page = mount()
|
||||
try {
|
||||
await settle()
|
||||
Object.assign(page.state.state.queryForm, { deptIds: ["11"], walletTypeIdList: ["21"], isInclude: false, minBalanceYuan: "1000", maxBalanceYuan: "2000" })
|
||||
await page.state.getDataList()
|
||||
const applied = page.state.getAppliedQuery()
|
||||
page.state.state.queryForm.deptIds.push("12")
|
||||
Object.assign(page.state.state.queryForm, { walletRecordMonth: "2026-10", minBalanceYuan: "0" })
|
||||
page.state.currentChangeHandle(3)
|
||||
await settle()
|
||||
assert.equal(page.lists.at(-1).params.current, 3)
|
||||
assert.deepEqual(page.lists.at(-1).data.deptIds, ["11"])
|
||||
assert.equal(page.lists.at(-1).data.minBalance, 100000)
|
||||
for (const [prop, order, ascending, descending] of [
|
||||
["eatBalance", "ascending", "eat_balance", undefined], ["payBalance", "descending", undefined, "pay_balance"],
|
||||
["topupBalance", "ascending", "topup_balance", undefined], ["balance", "descending", undefined, "balance"],
|
||||
]) {
|
||||
await page.state.handleBalanceSortChange({ prop, order })
|
||||
assert.equal(page.lists.at(-1).params.current, 1)
|
||||
assert.equal(page.lists.at(-1).params.ascs, ascending)
|
||||
assert.equal(page.lists.at(-1).params.descs, descending)
|
||||
}
|
||||
await page.state.handleBalanceSortChange({ order: null })
|
||||
assert.equal(page.lists.at(-1).params.ascs, undefined)
|
||||
assert.equal(page.lists.at(-1).params.descs, undefined)
|
||||
page.state.viewBalanceStatistics()
|
||||
page.state.drawerClose()
|
||||
assert.equal(page.requests[0].signal.aborted, true)
|
||||
page.state.state.queryForm.walletRecordMonth = "2026-09"
|
||||
page.state.viewBalanceStatistics()
|
||||
page.requests[0].reject(new Error("旧请求失败"))
|
||||
assert.equal(page.statistics.length, 1)
|
||||
const { ascs, descs, ...expectedStatisticsQuery } = applied
|
||||
assert.deepEqual(page.statistics[0].data, expectedStatisticsQuery)
|
||||
const snapshot = page.state.getAppliedQuery()
|
||||
snapshot.deptIds.push("99")
|
||||
assert.deepEqual(page.state.getAppliedQuery().deptIds, ["11"])
|
||||
} finally { page.close() }
|
||||
})
|
||||
|
||||
test("新筛选取消旧统计,迟到的成功和失败均不覆盖新结果,空数据可明确统计为零", async () => {
|
||||
const page = mount()
|
||||
try {
|
||||
await settle()
|
||||
page.state.viewBalanceStatistics()
|
||||
Object.assign(page.state.state.queryForm, { minBalanceYuan: "1000" })
|
||||
await page.state.getDataList()
|
||||
assert.equal(page.statistics[0].signal.aborted, true)
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, false)
|
||||
page.state.viewBalanceStatistics()
|
||||
page.statistics[0].reject(new Error("旧请求失败"))
|
||||
await settle()
|
||||
assert.equal(page.state.balanceStatisticsLoading.value, true)
|
||||
assert.deepEqual(page.errors, [])
|
||||
page.state.drawerClose()
|
||||
page.state.clearStatistics()
|
||||
page.state.viewBalanceStatistics()
|
||||
page.requests[2].resolve(result(3000))
|
||||
page.statistics[2].resolve(result(0))
|
||||
await settle()
|
||||
page.requests[1].resolve(result(1000))
|
||||
page.statistics[1].resolve(result(1000))
|
||||
await settle()
|
||||
assert.equal(page.state.balanceStatistics.total, 3000)
|
||||
assert.equal(page.state.balanceStatistics.total, 0)
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, true)
|
||||
assert.equal(page.state.balanceStatisticsLoading.value, false)
|
||||
} finally { page.close() }
|
||||
})
|
||||
|
||||
test("网络失败展示可读错误并可重试,卸载取消在途请求", async () => {
|
||||
const page = mount()
|
||||
page.state.viewBalanceStatistics()
|
||||
page.requests[0].reject(new Error("查询超时"))
|
||||
await settle()
|
||||
assert.deepEqual(page.errors, ["查询超时"])
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, false)
|
||||
assert.equal(page.state.balanceStatisticsLoading.value, false)
|
||||
page.state.viewBalanceStatistics()
|
||||
assert.equal(page.requests.length, 2)
|
||||
page.close()
|
||||
assert.equal(page.requests[1].signal.aborted, true)
|
||||
page.requests[1].resolve(result(2000))
|
||||
await settle()
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, false)
|
||||
test("统计失败可以重试,卸载取消统计和列表,过期列表不会覆盖新页", async () => {
|
||||
const page = mount({ deferredLists: true })
|
||||
try {
|
||||
await settle()
|
||||
Object.assign(page.state.state.queryForm, { minBalanceYuan: "1000" })
|
||||
const newest = page.state.getDataList()
|
||||
assert.equal(page.lists[0].signal.aborted, true)
|
||||
page.lists[1].resolve({ code: 0, data: { records: [{ userId: "new" }], total: 1 } })
|
||||
await newest
|
||||
page.lists[0].resolve({ code: 0, data: { records: [{ userId: "old" }], total: 100 } })
|
||||
await settle()
|
||||
assert.equal(page.state.state.dataList[0].userId, "new")
|
||||
page.state.viewBalanceStatistics()
|
||||
page.statistics[0].reject(new Error("查询超时"))
|
||||
await settle()
|
||||
assert.equal(page.state.balanceStatisticsLoaded.value, false)
|
||||
assert.equal(page.state.balanceStatisticsLoading.value, false)
|
||||
assert.equal(page.state.balanceStatisticsError.value, "查询超时")
|
||||
page.state.viewBalanceStatistics()
|
||||
page.state.currentChangeHandle(2)
|
||||
page.close()
|
||||
assert.equal(page.statistics[1].signal.aborted, true)
|
||||
assert.equal(page.lists[2].signal.aborted, true)
|
||||
} finally { /* 此测试在验证卸载时已经关闭组件。 */ }
|
||||
})
|
||||
|
||||
test("统计 API 保留路径与 POST 参数并传递取消信号,实际 Vue 模板可编译", async () => {
|
||||
const requests = []
|
||||
const api = load(fs.readFileSync(path.join(root, "api/wallet/wallet-manage.ts"), "utf8"), {
|
||||
"/@/utils/request": options => { requests.push(options); return Promise.resolve({}) },
|
||||
"/@/api/asyncTask": {},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const query = { walletRecordMonth: "2026-10", deptIds: ["11"] }
|
||||
await api.qbTotal(query, controller.signal)
|
||||
assert.equal(requests[0].url, "/wallet/wallet-manage/qb-total")
|
||||
assert.equal(requests[0].method, "post")
|
||||
assert.equal(requests[0].data, query)
|
||||
assert.equal(requests[0].signal, controller.signal)
|
||||
const script = compileScript(descriptor, { id: "wallet-qb-statistics-test" })
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: "wallet-qb-statistics-test", compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||
test("真实页面的查询、统计和用户汇总表格模板可编译", () => {
|
||||
const descriptor = parse(fs.readFileSync(filename, "utf8"), { filename }).descriptor
|
||||
const script = compileScript(descriptor, { id: "wallet-qb-users-test" })
|
||||
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: "wallet-qb-users-test", compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||
})
|
||||
Reference in new issue
Block a user