11
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
530cfcd939
commit
aaeb519bbc
7 files changed
+1070
-199
No files matched your search
@@ -0,0 +1,45 @@
|
|||||||
|
export type WalletCode = "1" | "2" | "3"
|
||||||
|
export type WalletAmount = number | string
|
||||||
|
|
||||||
|
export interface InactiveWallet {
|
||||||
|
code: WalletCode
|
||||||
|
name: string
|
||||||
|
status: "AVAILABLE" | "UNOPENED" | "RESTRICTED"
|
||||||
|
balance: WalletAmount | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInactiveUser {
|
||||||
|
userId: string
|
||||||
|
username?: string
|
||||||
|
userMobile?: string
|
||||||
|
userAvatar?: string
|
||||||
|
deptNames: string[]
|
||||||
|
wallets: InactiveWallet[]
|
||||||
|
totalBalance: WalletAmount
|
||||||
|
partialBalance: boolean
|
||||||
|
firstWalletCreatedTime: string | null
|
||||||
|
lastTransactionTime: string | null
|
||||||
|
inactiveSinceTime: string
|
||||||
|
inactiveMonths: number
|
||||||
|
neverTransacted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInactiveQuery {
|
||||||
|
userId?: string
|
||||||
|
unactiveMonthsNumber: number
|
||||||
|
deptIds: string[]
|
||||||
|
isInclude: boolean
|
||||||
|
current: number
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletInactivePage {
|
||||||
|
records: WalletInactiveUser[]
|
||||||
|
total: number | string
|
||||||
|
current: number | string
|
||||||
|
size: number | string
|
||||||
|
asOfTime: string
|
||||||
|
cutoffTime: string
|
||||||
|
unactiveMonthsNumber: number
|
||||||
|
walletNames: Record<WalletCode, string>
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import request from "/@/utils/request"
|
import request from "/@/utils/request"
|
||||||
import { submitExportTask } from "/@/api/asyncTask"
|
import { submitExportTask } from "/@/api/asyncTask"
|
||||||
|
import type { WalletInactivePage, WalletInactiveQuery } from "/@/api/wallet/wallet-inactive"
|
||||||
|
|
||||||
/** 按当前筛选条件提交账户余额后台分批导出。 */
|
/** 按当前筛选条件提交账户余额后台分批导出。 */
|
||||||
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-async", query)
|
export const submitExport = (query: Record<string, unknown>) => submitExportTask("/wallet/wallet-manage/export-async", query)
|
||||||
@@ -10,7 +11,7 @@ export const submitQbExport = (query: Record<string, unknown>) => submitExportTa
|
|||||||
/**
|
/**
|
||||||
* 不活跃用户统计分页
|
* 不活跃用户统计分页
|
||||||
*/
|
*/
|
||||||
export function pageJoinActive(data: any) {
|
export function pageJoinActive(data: WalletInactiveQuery, signal?: AbortSignal): Promise<{ data: WalletInactivePage }> {
|
||||||
return request({
|
return request({
|
||||||
url: "/wallet/wallet-manage/page-join-active",
|
url: "/wallet/wallet-manage/page-join-active",
|
||||||
method: "post",
|
method: "post",
|
||||||
@@ -19,7 +20,8 @@ export function pageJoinActive(data: any) {
|
|||||||
size: data.size,
|
size: data.size,
|
||||||
},
|
},
|
||||||
data: data,
|
data: data,
|
||||||
})
|
signal,
|
||||||
|
}) as unknown as Promise<{ data: WalletInactivePage }>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, shallowRef } from "vue"
|
||||||
|
import { pageJoinActive } from "/@/api/wallet/wallet-manage"
|
||||||
|
import type { WalletInactivePage, WalletInactiveQuery, WalletInactiveUser } from "/@/api/wallet/wallet-inactive"
|
||||||
|
|
||||||
|
/** 本页独立的查询生命周期;取消请求和序号校验同时防止旧响应覆盖。 */
|
||||||
|
export function useWalletInactive() {
|
||||||
|
const form = reactive({
|
||||||
|
userId: undefined as string | undefined,
|
||||||
|
unactiveMonthsNumber: 3 as number | undefined,
|
||||||
|
deptIds: [] as string[],
|
||||||
|
isInclude: true,
|
||||||
|
})
|
||||||
|
const pagination = reactive({ current: 1, size: 20, total: 0 })
|
||||||
|
const rows = shallowRef<WalletInactiveUser[]>([])
|
||||||
|
const metadata = shallowRef<WalletInactivePage>()
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
const error = ref("")
|
||||||
|
const monthsError = ref("")
|
||||||
|
let sequence = 0
|
||||||
|
let controller: AbortController | undefined
|
||||||
|
let pendingKey = ""
|
||||||
|
let pending: Promise<void> | undefined
|
||||||
|
let departmentTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let disposed = false
|
||||||
|
|
||||||
|
const summary = computed(() => ({
|
||||||
|
neverUsed: rows.value.filter((row) => row.neverTransacted).length,
|
||||||
|
balance: rows.value.reduce((total, row) => total + Number(row.totalBalance), 0),
|
||||||
|
partialBalance: rows.value.some((row) => row.partialBalance),
|
||||||
|
}))
|
||||||
|
const stopPending = () => {
|
||||||
|
sequence++
|
||||||
|
controller?.abort()
|
||||||
|
controller = undefined
|
||||||
|
pending = undefined
|
||||||
|
pendingKey = ""
|
||||||
|
clearTimeout(departmentTimer)
|
||||||
|
departmentTimer = undefined
|
||||||
|
}
|
||||||
|
const clearResult = () => {
|
||||||
|
rows.value = []
|
||||||
|
// 保留最后一次分页总数,避免 Element Plus 在请求中把 total=0 解释为跳回第一页。
|
||||||
|
// 概览由 loaded/loading 控制,请求中和失败时不会展示旧人数。
|
||||||
|
metadata.value = undefined
|
||||||
|
loaded.value = false
|
||||||
|
error.value = ""
|
||||||
|
}
|
||||||
|
const query = (): Promise<void> => {
|
||||||
|
if (disposed) return Promise.resolve()
|
||||||
|
clearTimeout(departmentTimer)
|
||||||
|
departmentTimer = undefined
|
||||||
|
const months = form.unactiveMonthsNumber
|
||||||
|
if (!Number.isInteger(months) || months! < 1 || months! > 120) {
|
||||||
|
stopPending()
|
||||||
|
clearResult()
|
||||||
|
loading.value = false
|
||||||
|
monthsError.value = "请输入1至120的整数月数"
|
||||||
|
return Promise.resolve()
|
||||||
|
}
|
||||||
|
monthsError.value = ""
|
||||||
|
const snapshot: WalletInactiveQuery = {
|
||||||
|
userId: form.userId ? String(form.userId) : undefined,
|
||||||
|
unactiveMonthsNumber: months!,
|
||||||
|
deptIds: [...new Set(form.deptIds.map(String))].sort(),
|
||||||
|
isInclude: form.isInclude,
|
||||||
|
current: pagination.current,
|
||||||
|
size: pagination.size,
|
||||||
|
}
|
||||||
|
const key = JSON.stringify(snapshot)
|
||||||
|
if (pending && key === pendingKey) return pending
|
||||||
|
stopPending()
|
||||||
|
const requestSequence = sequence
|
||||||
|
const requestController = new AbortController()
|
||||||
|
controller = requestController
|
||||||
|
pendingKey = key
|
||||||
|
clearResult()
|
||||||
|
loading.value = true
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await pageJoinActive(snapshot, requestController.signal)
|
||||||
|
if (disposed || requestSequence !== sequence) return
|
||||||
|
const page = response.data
|
||||||
|
if (
|
||||||
|
!page ||
|
||||||
|
!Array.isArray(page.records) ||
|
||||||
|
typeof page.asOfTime !== "string" ||
|
||||||
|
typeof page.cutoffTime !== "string" ||
|
||||||
|
!page.walletNames ||
|
||||||
|
!page.records.every((row) => typeof row.userId === "string" && Array.isArray(row.wallets))
|
||||||
|
) {
|
||||||
|
throw new Error("统计结果不完整,请刷新页面后重试")
|
||||||
|
}
|
||||||
|
rows.value = page.records
|
||||||
|
pagination.current = Number(page.current)
|
||||||
|
pagination.size = Number(page.size)
|
||||||
|
pagination.total = Number(page.total)
|
||||||
|
metadata.value = page
|
||||||
|
loaded.value = true
|
||||||
|
} catch (cause: any) {
|
||||||
|
if (disposed || requestSequence !== sequence || requestController.signal.aborted) return
|
||||||
|
error.value = cause?.msg || cause?.message || "统计查询失败,请重试"
|
||||||
|
} finally {
|
||||||
|
if (requestSequence === sequence) {
|
||||||
|
loading.value = false
|
||||||
|
pending = undefined
|
||||||
|
pendingKey = ""
|
||||||
|
controller = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
return query()
|
||||||
|
}
|
||||||
|
const reset = () => {
|
||||||
|
Object.assign(form, { userId: undefined, unactiveMonthsNumber: 3, deptIds: [], isInclude: true })
|
||||||
|
monthsError.value = ""
|
||||||
|
return search()
|
||||||
|
}
|
||||||
|
const setMonths = (months: number) => {
|
||||||
|
form.unactiveMonthsNumber = months
|
||||||
|
return search()
|
||||||
|
}
|
||||||
|
const changePage = (current: number) => {
|
||||||
|
if (!loaded.value || current === pagination.current) return
|
||||||
|
pagination.current = current
|
||||||
|
return query()
|
||||||
|
}
|
||||||
|
const changeSize = (size: number) => {
|
||||||
|
if (size === pagination.size) return
|
||||||
|
pagination.size = size
|
||||||
|
return search()
|
||||||
|
}
|
||||||
|
const changeDepartments = (ids: Array<string | number>, include: boolean) => {
|
||||||
|
form.deptIds = ids.map(String)
|
||||||
|
form.isInclude = include
|
||||||
|
pagination.current = 1
|
||||||
|
stopPending()
|
||||||
|
clearResult()
|
||||||
|
loading.value = true
|
||||||
|
departmentTimer = setTimeout(() => {
|
||||||
|
void query()
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
onMounted(query)
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true
|
||||||
|
stopPending()
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
pagination,
|
||||||
|
rows,
|
||||||
|
metadata,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
error,
|
||||||
|
monthsError,
|
||||||
|
summary,
|
||||||
|
search,
|
||||||
|
refresh: query,
|
||||||
|
reset,
|
||||||
|
setMonths,
|
||||||
|
changePage,
|
||||||
|
changeSize,
|
||||||
|
changeDepartments,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</el-form>
|
</el-form>
|
||||||
<el-row class="flex">
|
<el-row class="flex">
|
||||||
<el-tabs class="flex-1" v-model="activeTab" @tab-click="tabClick">
|
<el-tabs class="flex-1" v-model="activeTab">
|
||||||
<el-tab-pane v-for="(v) in tabPane" :key="v.name" :label="v.label" :name="v.name" />
|
<el-tab-pane v-for="(v) in tabPane" :key="v.name" :label="v.label" :name="v.name" />
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
<div class="mb8">
|
<div class="mb8">
|
||||||
@@ -113,19 +113,18 @@ const state = reactive<any>({
|
|||||||
queryForm: { ...defaultQueryForm },
|
queryForm: { ...defaultQueryForm },
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => [activeTab.value, hardShByDayRef.value, hardShByMonthRef.value], () => {
|
// 异步子表挂载后统一查询,避免 mounted、tab-click 和 ref watcher 重复触发重型统计。
|
||||||
|
watch([hardShByDayRef, hardShByMonthRef], () => {
|
||||||
if (activeTab.value === "MCH" && hardShByDayRef.value?.doSearch) {
|
if (activeTab.value === "MCH" && hardShByDayRef.value?.doSearch) {
|
||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
if (activeTab.value === "MONTH" && hardShByMonthRef.value?.doSearch) {
|
if (activeTab.value === "MONTH" && hardShByMonthRef.value?.doSearch) {
|
||||||
search()
|
search()
|
||||||
}
|
}
|
||||||
})
|
}, { flush: "post" })
|
||||||
|
|
||||||
onMounted(() => {
|
watch(activeTab, () => {
|
||||||
nextTick(() => {
|
state.queryForm = { ...defaultQueryForm }
|
||||||
search()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
/** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */
|
||||||
@@ -139,12 +138,6 @@ const exportExcel = () => {
|
|||||||
return submitExport({ ...state.queryForm })
|
return submitExport({ ...state.queryForm })
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabClick = () => {
|
|
||||||
console.log(123123);
|
|
||||||
|
|
||||||
cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = () => {
|
const search = () => {
|
||||||
if (activeTab.value === "MCH") {
|
if (activeTab.value === "MCH") {
|
||||||
state.queryForm.payTimeYearStr = ""
|
state.queryForm.payTimeYearStr = ""
|
||||||
@@ -154,10 +147,10 @@ const search = () => {
|
|||||||
}
|
}
|
||||||
switch (activeTab.value) {
|
switch (activeTab.value) {
|
||||||
case "MCH":
|
case "MCH":
|
||||||
hardShByDayRef.value.doSearch(state.queryForm)
|
hardShByDayRef.value?.doSearch(state.queryForm)
|
||||||
break;
|
break;
|
||||||
case "MONTH":
|
case "MONTH":
|
||||||
hardShByMonthRef.value.doSearch(state.queryForm)
|
hardShByMonthRef.value?.doSearch(state.queryForm)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,207 +1,565 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-padding">
|
<div class="layout-padding wallet-inactive-page">
|
||||||
<div class="layout-padding-auto layout-padding-view">
|
<div class="layout-padding-auto layout-padding-view inactive-shell">
|
||||||
<DraggableDepTree
|
<DraggableDepTree
|
||||||
ref="draggableDepTreeRef"
|
ref="departmentTree"
|
||||||
placeholder="请输入部门名称"
|
placeholder="请输入部门名称"
|
||||||
:show-expand="true"
|
:show-expand="true"
|
||||||
@getSelectNode="handleGetSelectNode"
|
|
||||||
switch-inactive-text="包含"
|
switch-inactive-text="包含"
|
||||||
switch-active-text="不包含"
|
switch-active-text="不包含"
|
||||||
:defaultTreeWidth="20"
|
:default-tree-width="20"
|
||||||
:nodeClickable="false"
|
:node-clickable="false"
|
||||||
|
@getSelectNode="handleDepartments"
|
||||||
>
|
>
|
||||||
<template #default>
|
<div ref="content" class="inactive-content" :class="{ 'inactive-content--compact': compact }" :aria-busy="loading">
|
||||||
<SearchPanel
|
<header class="inactive-heading">
|
||||||
v-show="showSearch"
|
<div class="inactive-heading__main">
|
||||||
ref="queryRef"
|
<div class="inactive-heading__icon" aria-hidden="true">
|
||||||
:model="state.queryForm"
|
<el-icon><Wallet /></el-icon>
|
||||||
:loading="Boolean(state.loading)"
|
</div>
|
||||||
:search-text="$t('common.queryBtn')"
|
<div>
|
||||||
:reset-text="$t('common.resetBtn')"
|
<h1>不活跃钱包统计</h1>
|
||||||
@search="getDataList"
|
<p>任一钱包产生有效流水即重新计时,未使用账户从开户起算。</p>
|
||||||
@reset="resetQuery"
|
</div>
|
||||||
>
|
</div>
|
||||||
<el-form-item style="width: 300px" label="用户" prop="userId">
|
<el-button :icon="Refresh" :loading="loading" aria-label="刷新钱包统计" @click="refresh">刷新</el-button>
|
||||||
<select-user v-model="state.queryForm.userId" />
|
</header>
|
||||||
|
|
||||||
|
<SearchPanel :model="form" :loading="loading" title="筛选条件" @search="search" @reset="resetQuery">
|
||||||
|
<el-form-item label="用户" prop="userId" class="inactive-user-filter">
|
||||||
|
<select-user v-model="form.userId" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item style="width: 300px" label="不活跃月数" prop="unactiveMonthsNumber">
|
<el-form-item label="连续未变动" prop="unactiveMonthsNumber" :error="monthsError" class="inactive-month-filter">
|
||||||
<el-input v-model="state.queryForm.unactiveMonthsNumber" type="number" placeholder="不活跃月数" />
|
<div class="inactive-month-controls">
|
||||||
|
<el-input-number
|
||||||
|
v-model="form.unactiveMonthsNumber"
|
||||||
|
:min="1"
|
||||||
|
:max="120"
|
||||||
|
:precision="0"
|
||||||
|
:step-strictly="true"
|
||||||
|
controls-position="right"
|
||||||
|
aria-label="连续未变动月数"
|
||||||
|
/>
|
||||||
|
<span>个月</span>
|
||||||
|
<div class="inactive-presets" role="group" aria-label="快捷选择不活跃月数">
|
||||||
|
<el-button
|
||||||
|
v-for="months in [1, 3, 6, 12]"
|
||||||
|
:key="months"
|
||||||
|
size="small"
|
||||||
|
:type="form.unactiveMonthsNumber === months ? 'primary' : 'default'"
|
||||||
|
plain
|
||||||
|
:aria-pressed="form.unactiveMonthsNumber === months"
|
||||||
|
@click="setMonths(months)"
|
||||||
|
>{{ months }}个月</el-button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</SearchPanel>
|
</SearchPanel>
|
||||||
<el-row>
|
|
||||||
<div class="mb8" style="width: 100%">
|
<div class="inactive-metrics" aria-label="统计概览">
|
||||||
<el-button
|
<div class="inactive-metric">
|
||||||
plain
|
<span>符合条件总人数</span
|
||||||
:disabled="multiple"
|
><strong>{{ ready ? pagination.total.toLocaleString() : "—" }}<small v-if="ready">人</small></strong>
|
||||||
icon="Delete"
|
|
||||||
type="primary"
|
|
||||||
v-auth="'wallet_walletManage_del'"
|
|
||||||
@click="handleDelete(selectObjs)"
|
|
||||||
>
|
|
||||||
删除
|
|
||||||
</el-button>
|
|
||||||
<!-- <right-toolbar
|
|
||||||
v-model:showSearch="showSearch"
|
|
||||||
:export="'wallet_walletInactive_export'"
|
|
||||||
@exportExcel="exportExcel"
|
|
||||||
class="ml10 mr20"
|
|
||||||
style="float: right"
|
|
||||||
@queryTable="getDataList"
|
|
||||||
></right-toolbar> -->
|
|
||||||
</div>
|
</div>
|
||||||
</el-row>
|
<div class="inactive-metric">
|
||||||
<el-table
|
<span>本页从未使用人数</span><strong>{{ ready ? summary.neverUsed : "—" }}<small v-if="ready">人</small></strong>
|
||||||
:data="tableList"
|
</div>
|
||||||
v-loading="state.loading"
|
<div class="inactive-metric">
|
||||||
:row-key="(row: any) => row.id"
|
<span>本页余额合计<span v-if="ready && summary.partialBalance">(仅可见)</span></span
|
||||||
lazy
|
><strong>{{ ready ? money(summary.balance) : "—" }}<small v-if="ready">元</small></strong>
|
||||||
:load="load"
|
</div>
|
||||||
:indent="30"
|
</div>
|
||||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
|
||||||
:summary-method="getSummaries"
|
<div class="inactive-result-heading">
|
||||||
show-summary
|
<h2>用户明细</h2>
|
||||||
:header-cell-style="tableStyle.headerCellStyle"
|
<el-tooltip v-if="ready && metadata" :content="`最后有效流水或开户时间不晚于 ${time(metadata.cutoffTime)}`">
|
||||||
:cell-style="tableStyle.cellStyle"
|
<span class="inactive-result-time"
|
||||||
@selection-change="selectionChangHandle"
|
>连续 {{ metadata.unactiveMonthsNumber }} 个月未变动 · 截至
|
||||||
@sort-change="sortChangeHandle"
|
{{ compact ? time(metadata.asOfTime).slice(0, 10) : time(metadata.asOfTime) }}</span
|
||||||
:row-class-name="tableRowClassName"
|
>
|
||||||
>
|
</el-tooltip>
|
||||||
<el-table-column type="index" label="#" width="60" />
|
<span v-else class="inactive-result-time" role="status">{{
|
||||||
<el-table-column prop="username" label="用户" />
|
loading ? "正在查询统计…" : error ? "查询未完成" : "等待查询"
|
||||||
<el-table-column prop="userAvatar" label="头像">
|
}}</span>
|
||||||
<template #default="{ row }">
|
</div>
|
||||||
<preview-image :src="row.userAvatar" />
|
<el-alert v-if="error" type="error" :closable="false" show-icon class="inactive-error" role="alert">
|
||||||
</template>
|
<template #title>{{ error }}</template>
|
||||||
</el-table-column>
|
<el-button size="small" type="primary" plain @click="refresh">重新查询</el-button>
|
||||||
<el-table-column prop="userMobile" label="手机号" />
|
</el-alert>
|
||||||
<el-table-column prop="deptName" label="部门" />
|
|
||||||
<el-table-column prop="walletTypeName" label="钱包类型" />
|
<div class="inactive-table-region">
|
||||||
<el-table-column prop="balance" label="余额">
|
<el-table :data="rows" row-key="userId" height="100%" v-loading="loading" class="inactive-table">
|
||||||
<template #default="{ row }">{{ row.balance && displayPrice(row.balance) }}</template>
|
<el-table-column label="用户" :fixed="contentWidth >= 560 ? 'left' : undefined" width="180">
|
||||||
</el-table-column>
|
<template #default="{ row }">
|
||||||
<el-table-column prop="updateTime" label="最后充值时间" />
|
<div class="inactive-user">
|
||||||
</el-table>
|
<el-avatar :size="32" :src="row.userAvatar" :icon="User" aria-hidden="true" />
|
||||||
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
|
<div class="inactive-user__text">
|
||||||
</template>
|
<strong :title="row.username">{{ row.username || "用户信息不可用" }}</strong
|
||||||
|
><span>{{ row.userMobile || `ID ${row.userId}` }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="部门" min-width="120" show-overflow-tooltip>
|
||||||
|
<template #default="{ row }">{{ row.deptNames?.join("、") || "—" }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="不活跃时长" width="110" align="center">
|
||||||
|
<template #default="{ row }"
|
||||||
|
><el-tooltip :content="`从${row.neverTransacted ? '开户' : '最后流水'} ${time(row.inactiveSinceTime)} 起计算`"
|
||||||
|
><el-tag type="warning" effect="light">{{ row.inactiveMonths }}个月</el-tag></el-tooltip
|
||||||
|
></template
|
||||||
|
>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column
|
||||||
|
v-for="column in walletColumns"
|
||||||
|
:key="column.code"
|
||||||
|
:label="`${column.name}(元)`"
|
||||||
|
min-width="125"
|
||||||
|
align="right"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="walletFor(row, column.code)?.status === 'AVAILABLE'"
|
||||||
|
class="inactive-amount"
|
||||||
|
:class="{ 'inactive-amount--negative': Number(walletFor(row, column.code)?.balance) < 0 }"
|
||||||
|
>{{ money(walletFor(row, column.code)?.balance) }}</span
|
||||||
|
>
|
||||||
|
<el-tag v-else-if="walletFor(row, column.code)?.status === 'RESTRICTED'" type="warning" size="small" effect="plain"
|
||||||
|
>无权查看</el-tag
|
||||||
|
>
|
||||||
|
<span v-else class="inactive-muted">未开通</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="余额合计(元)" min-width="135" align="right">
|
||||||
|
<template #default="{ row }"
|
||||||
|
><strong class="inactive-amount" :class="{ 'inactive-amount--negative': Number(row.totalBalance) < 0 }">{{
|
||||||
|
money(row.totalBalance)
|
||||||
|
}}</strong>
|
||||||
|
<div v-if="row.partialBalance" class="inactive-muted">仅可见钱包</div></template
|
||||||
|
>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最后变动时间" min-width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="row.neverTransacted"
|
||||||
|
><el-tag type="info" size="small" effect="plain">从未使用</el-tag>
|
||||||
|
<div class="inactive-muted">开户 {{ time(row.firstWalletCreatedTime) }}</div></template
|
||||||
|
>
|
||||||
|
<time v-else>{{ time(row.lastTransactionTime) }}</time>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<template #empty
|
||||||
|
><div class="inactive-empty">
|
||||||
|
<span>{{ loading ? "正在查询…" : error ? "统计未完成,请重新查询" : "暂无符合条件的用户" }}</span
|
||||||
|
><small v-if="!loading && !error">可调整月数、用户或部门条件后查询</small>
|
||||||
|
</div></template
|
||||||
|
>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
<footer class="inactive-pagination">
|
||||||
|
<span v-if="compact" class="inactive-page-position"
|
||||||
|
>第 {{ ready ? pagination.current : "—" }} / {{ ready ? totalPages : "—" }} 页</span
|
||||||
|
>
|
||||||
|
<span class="inactive-page-count">本页 {{ ready ? rows.length : "—" }} 人</span>
|
||||||
|
<el-pagination
|
||||||
|
:total="pagination.total"
|
||||||
|
:page-size="pagination.size"
|
||||||
|
:current-page="pagination.current"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:pager-count="5"
|
||||||
|
:disabled="!ready"
|
||||||
|
background
|
||||||
|
:layout="paginationLayout"
|
||||||
|
@size-change="changeSize"
|
||||||
|
@current-change="changePage"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
</DraggableDepTree>
|
</DraggableDepTree>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts" name="systemWalletManage">
|
<script setup lang="ts" name="walletInactiveStatistics">
|
||||||
import { BasicTableProps, useTable } from "/@/hooks/table"
|
import { computed, ref } from "vue"
|
||||||
import { pageJoinActive, delObjs, listJoinActive } from "/@/api/wallet/wallet-manage"
|
import { Refresh, User, Wallet } from "@element-plus/icons-vue"
|
||||||
import { useMessage, useMessageBox } from "/@/hooks/message"
|
import { useElementSize } from "@vueuse/core"
|
||||||
import { useDict } from "/@/hooks/dict"
|
import { useWalletInactive } from "/@/hooks/walletInactive"
|
||||||
|
import type { WalletCode, WalletInactiveUser } from "/@/api/wallet/wallet-inactive"
|
||||||
import { displayPrice } from "/@/utils/formatMoney"
|
import { displayPrice } from "/@/utils/formatMoney"
|
||||||
import { formatDate } from "/@/utils/formatTime"
|
|
||||||
|
|
||||||
const { wallet_type, yes_no_type } = useDict("wallet_type", "yes_no_type")
|
const departmentTree = ref<{ resetData: () => void }>()
|
||||||
// 定义变量内容
|
const content = ref<HTMLElement>()
|
||||||
const draggableDepTreeRef = ref()
|
const { width: contentWidth } = useElementSize(content)
|
||||||
// 搜索变量
|
const compact = computed(() => contentWidth.value < 380)
|
||||||
const queryRef = ref()
|
const {
|
||||||
const showSearch = ref(true)
|
form,
|
||||||
// 多选变量
|
pagination,
|
||||||
const selectObjs = ref([]) as any
|
rows,
|
||||||
const multiple = ref(true)
|
metadata,
|
||||||
|
loading,
|
||||||
const state: BasicTableProps = reactive<BasicTableProps>({
|
loaded,
|
||||||
queryForm: {
|
error,
|
||||||
unactiveMonthsNumber: 3,
|
monthsError,
|
||||||
},
|
summary,
|
||||||
pageList: pageJoinActive,
|
search,
|
||||||
})
|
refresh,
|
||||||
const tableList = computed(() => {
|
reset,
|
||||||
return state.dataList?.map((item: any) => ({ ...item, walletTypeName: "", updateTime: "", balance: "", children: [], hasChildren: true }))
|
setMonths,
|
||||||
})
|
changePage,
|
||||||
|
changeSize,
|
||||||
const { getDataList, currentChangeHandle, sizeChangeHandle, sortChangeHandle, postDownBlobFile, tableStyle } = useTable(state)
|
changeDepartments,
|
||||||
|
} = useWalletInactive()
|
||||||
// 清空搜索条件
|
const ready = computed(() => loaded.value && !loading.value && !error.value)
|
||||||
const resetQuery = () => {
|
const totalPages = computed(() => Math.max(1, Math.ceil(pagination.total / pagination.size)))
|
||||||
// 清空搜索条件
|
const paginationLayout = computed(() =>
|
||||||
queryRef.value?.resetFields()
|
contentWidth.value < 380
|
||||||
// 清空多选
|
? "prev, next"
|
||||||
selectObjs.value = []
|
: contentWidth.value < 620
|
||||||
state.queryForm.deptId = ""
|
? "prev, pager, next"
|
||||||
state.queryForm.deptIds = []
|
: contentWidth.value < 960
|
||||||
draggableDepTreeRef.value.resetData()
|
? "sizes, prev, pager, next"
|
||||||
getDataList()
|
: "sizes, prev, pager, next, jumper",
|
||||||
}
|
)
|
||||||
|
const walletColumns = computed(() =>
|
||||||
// 导出excel
|
(["1", "2", "3"] as WalletCode[]).map((code) => ({
|
||||||
const exportExcel = () => {
|
code,
|
||||||
postDownBlobFile(
|
name: metadata.value?.walletNames?.[code] || { "1": "饭卡钱包", "2": "消费钱包", "3": "充值钱包" }[code],
|
||||||
"/wallet/wallet-inactive/export",
|
})),
|
||||||
Object.assign(state.queryForm, { current: state.pagination?.current, size: state.pagination?.size }),
|
)
|
||||||
"不活跃钱包统计.xlsx",
|
const money = (amount: string | number | null | undefined) => displayPrice(amount ?? 0, "")
|
||||||
|
const time = (value: string | null | undefined) => (value ? value.replace("T", " ") : "—")
|
||||||
|
const walletFor = (row: WalletInactiveUser, code: WalletCode) => row.wallets.find((wallet) => wallet.code === code)
|
||||||
|
const handleDepartments = (_node: unknown, nodes: Array<{ id: string | number }>, excluded: boolean) =>
|
||||||
|
changeDepartments(
|
||||||
|
nodes.map((node) => node.id),
|
||||||
|
!excluded,
|
||||||
)
|
)
|
||||||
}
|
const resetQuery = () => {
|
||||||
|
departmentTree.value?.resetData()
|
||||||
const tableRowClassName = ({ row, rowIndex }: { row: any; rowIndex: number }) => {
|
void reset()
|
||||||
if (row._flag) {
|
|
||||||
return "success-row"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const load = (row: any, treeNode: unknown, resolve: (date: any[]) => void) => {
|
|
||||||
listJoinActive({ userId: row.userId }).then((res) => {
|
|
||||||
resolve(res.data.map((item: any) => ({ ...item, id: item.id + "0", _flag: true })))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
:deep(.el-table) .success-row {
|
.wallet-inactive-page {
|
||||||
--el-table-tr-bg-color: var(--el-color-success-light-9);
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
--inactive-accent: color-mix(in srgb, var(--el-color-primary) 60%, var(--el-text-color-primary));
|
||||||
|
}
|
||||||
|
.inactive-shell {
|
||||||
|
padding: 0;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.inactive-shell :deep(.draggable-dep-tree) {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.inactive-shell :deep(.draggable-dep-tree__content) {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.inactive-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.inactive-heading,
|
||||||
|
.inactive-heading__main,
|
||||||
|
.inactive-result-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.inactive-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.inactive-heading__main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inactive-heading__icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.inactive-heading h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.inactive-heading p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.inactive-content :deep(.search-panel) {
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.inactive-user-filter {
|
||||||
|
width: 290px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.inactive-month-filter {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.inactive-month-controls,
|
||||||
|
.inactive-presets {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.inactive-month-controls :deep(.el-input-number) {
|
||||||
|
width: 104px;
|
||||||
|
}
|
||||||
|
.inactive-presets :deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.inactive-metrics {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.inactive-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--el-fill-color-extra-light);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inactive-metric > span {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.inactive-metric strong {
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.inactive-metric small {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.inactive-result-heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 12px;
|
||||||
|
}
|
||||||
|
.inactive-result-heading h2 {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.inactive-result-time,
|
||||||
|
.inactive-page-count {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.inactive-error {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.inactive-error :deep(.el-alert__title) {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.inactive-table-region {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inactive-table {
|
||||||
|
--el-table-header-bg-color: var(--el-fill-color-light);
|
||||||
|
--el-table-header-text-color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.inactive-table :deep(.el-table__cell) {
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
.inactive-table :deep(.el-table__header .cell) {
|
||||||
|
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;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
.inactive-amount {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.inactive-amount--negative {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
color: color-mix(in srgb, var(--el-color-danger) 60%, var(--el-text-color-primary));
|
||||||
|
}
|
||||||
|
.inactive-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px 8px;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.inactive-empty small {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.inactive-pagination {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
.inactive-page-count {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.inactive-content :deep(.el-button:focus-visible) {
|
||||||
|
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-table :deep(.el-tag) {
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
.inactive-content :deep(.el-button--primary) {
|
||||||
|
--el-button-text-color: var(--el-bg-color);
|
||||||
|
--el-button-bg-color: var(--inactive-accent);
|
||||||
|
--el-button-border-color: var(--inactive-accent);
|
||||||
|
--el-button-hover-text-color: var(--el-bg-color);
|
||||||
|
--el-button-hover-bg-color: var(--inactive-accent);
|
||||||
|
--el-button-hover-border-color: var(--inactive-accent);
|
||||||
|
--el-button-active-text-color: var(--el-bg-color);
|
||||||
|
--el-button-active-bg-color: var(--inactive-accent);
|
||||||
|
--el-button-active-border-color: var(--inactive-accent);
|
||||||
|
}
|
||||||
|
.inactive-content :deep(.el-button--primary.is-plain) {
|
||||||
|
--el-button-text-color: var(--inactive-accent);
|
||||||
|
--el-button-bg-color: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
.inactive-content--compact :deep(.search-panel__form .el-form-item) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
.inactive-content--compact :deep(.search-panel__form .el-form-item__label) {
|
||||||
|
justify-content: flex-start;
|
||||||
|
height: 24px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
.inactive-content--compact :deep(.search-panel__form .el-form-item__content) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.inactive-content--compact .inactive-month-controls {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.inactive-content--compact .inactive-metric strong {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.inactive-page-position {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.inactive-pagination :deep(.el-pagination__jump) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 680px) {
|
||||||
|
.inactive-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.inactive-heading__icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.inactive-heading h1 {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.inactive-metrics {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.inactive-metric {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 10px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.inactive-metric strong {
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 24px;
|
||||||
|
}
|
||||||
|
.inactive-pagination :deep(.el-pagination__sizes) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.inactive-page-count {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.inactive-pagination {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.inactive-content {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.inactive-content :deep(*) {
|
||||||
|
transition: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
const { test } = require("node:test")
|
||||||
|
const assert = require("node:assert/strict")
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const path = require("node:path")
|
||||||
|
const ts = require("typescript")
|
||||||
|
const { parse, compileScript } = require("@vue/compiler-sfc")
|
||||||
|
const vue = require("vue")
|
||||||
|
|
||||||
|
// 执行页面真实的 setup 和 Vue 挂载/异步组件生命周期,子表只记录请求参数,无需新增浏览器测试依赖。
|
||||||
|
const filename = path.join(__dirname, "../src/views/hard/hard-sh/index.vue")
|
||||||
|
const descriptor = parse(fs.readFileSync(filename, "utf8"), { filename }).descriptor
|
||||||
|
const source = compileScript(descriptor, { id: "hard-sh-lifecycle-test" }).content
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020, esModuleInterop: true },
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function mountPage() {
|
||||||
|
const requests = []
|
||||||
|
const child = (kind) => vue.defineComponent({
|
||||||
|
setup(_, { expose }) {
|
||||||
|
const query = (params) => requests.push({ kind, params: JSON.parse(JSON.stringify(params)) })
|
||||||
|
expose({ doSearch: query, doCancel: query })
|
||||||
|
return () => vue.h("table")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const day = child("day")
|
||||||
|
const month = child("month")
|
||||||
|
const stubRequire = (id) => {
|
||||||
|
if (id.includes("hard-sh-by-day.vue")) return day
|
||||||
|
if (id.includes("hard-sh-by-month.vue")) return month
|
||||||
|
if (id.endsWith("AsyncTaskProgress.vue")) return {}
|
||||||
|
if (id.includes("hooks/batchExport")) return {
|
||||||
|
useBatchExport: () => ({ exportTaskConfig: {}, submitExport() {}, handleExportSuccess() {}, handleExportFailure() {} }),
|
||||||
|
}
|
||||||
|
if (id.includes("api/hard/hard-total")) return {}
|
||||||
|
return require(id)
|
||||||
|
}
|
||||||
|
const loaded = { exports: {} }
|
||||||
|
const autoImports = ["ref", "reactive", "watch", "defineAsyncComponent", "onMounted", "nextTick"]
|
||||||
|
new Function("module", "exports", "require", ...autoImports, compiled)(
|
||||||
|
loaded, loaded.exports, stubRequire, ...autoImports.map((key) => vue[key]),
|
||||||
|
)
|
||||||
|
let bindings
|
||||||
|
const renderer = vue.createRenderer({
|
||||||
|
createElement: (type) => ({ type, children: [] }),
|
||||||
|
createText: (text) => ({ text }), createComment: (text) => ({ text }),
|
||||||
|
setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text },
|
||||||
|
parentNode: (node) => node.parent,
|
||||||
|
nextSibling: (node) => node.parent?.children[node.parent.children.indexOf(node) + 1],
|
||||||
|
patchProp() {},
|
||||||
|
insert(node, parent, anchor = null) {
|
||||||
|
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||||
|
node.parent = parent
|
||||||
|
const index = anchor ? parent.children.indexOf(anchor) : -1
|
||||||
|
parent.children.splice(index < 0 ? parent.children.length : index, 0, node)
|
||||||
|
},
|
||||||
|
remove(node) {
|
||||||
|
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const host = vue.defineComponent({
|
||||||
|
setup() {
|
||||||
|
bindings = loaded.exports.default.setup({}, { expose() {} })
|
||||||
|
bindings.draggableDepMchTreeRef.value = { resetData() {} }
|
||||||
|
return () => bindings.activeTab.value === "MCH"
|
||||||
|
? vue.h(bindings.HardShByDay, { ref: bindings.hardShByDayRef })
|
||||||
|
: vue.h(bindings.HardShByMonth, { ref: bindings.hardShByMonthRef })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const app = renderer.createApp(host)
|
||||||
|
app.mount({ children: [] })
|
||||||
|
return { bindings, requests, close: () => app.unmount() }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
await vue.nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("初次打开异步子表只查询一次,挂载前点击查询也不会报错", async () => {
|
||||||
|
const page = mountPage()
|
||||||
|
try {
|
||||||
|
assert.doesNotThrow(() => page.bindings.search())
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.requests.length, 1)
|
||||||
|
assert.equal(page.requests[0].kind, "day")
|
||||||
|
assert.equal(page.requests[0].params.payTimeList.length, 2)
|
||||||
|
assert.equal(page.requests[0].params.payTimeYearStr, "")
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("切换日/月子表每次只查询一次,查询条件重置后再发送", async () => {
|
||||||
|
const page = mountPage()
|
||||||
|
try {
|
||||||
|
await settle()
|
||||||
|
page.bindings.state.queryForm.merchantId = "999"
|
||||||
|
page.bindings.activeTab.value = "MONTH"
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.requests.length, 2)
|
||||||
|
assert.equal(page.requests[1].kind, "month")
|
||||||
|
assert.equal(page.requests[1].params.merchantId, undefined)
|
||||||
|
assert.match(page.requests[1].params.payTimeYearStr, /^\d{4}$/)
|
||||||
|
page.bindings.activeTab.value = "MCH"
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.requests.length, 3)
|
||||||
|
assert.equal(page.requests[2].kind, "day")
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("部门筛选和手动刷新仍各发出一次请求", async () => {
|
||||||
|
const page = mountPage()
|
||||||
|
try {
|
||||||
|
await settle()
|
||||||
|
page.bindings.handleGetSelectNode({ id: "10" }, [{ id: "11" }], false, "DEPT")
|
||||||
|
assert.equal(page.requests.length, 2)
|
||||||
|
assert.deepEqual(page.requests[1].params.userDeptIds, ["11"])
|
||||||
|
assert.equal(page.requests[1].params.isInclude, true)
|
||||||
|
page.bindings.search()
|
||||||
|
assert.equal(page.requests.length, 3)
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
const { test } = require("node:test")
|
||||||
|
const assert = require("node:assert/strict")
|
||||||
|
const fs = require("node:fs")
|
||||||
|
const path = require("node:path")
|
||||||
|
const ts = require("typescript")
|
||||||
|
const vue = require("vue")
|
||||||
|
const { parse, compileScript, compileTemplate } = require("@vue/compiler-sfc")
|
||||||
|
const root = path.join(__dirname, "../src")
|
||||||
|
|
||||||
|
function load(source, mocks) {
|
||||||
|
const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText
|
||||||
|
const mod = { exports: {} }
|
||||||
|
new Function("module", "exports", "require", compiled)(mod, mod.exports, id => id in mocks ? mocks[id] : require(id))
|
||||||
|
return mod.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount() {
|
||||||
|
const requests = []
|
||||||
|
const hook = load(fs.readFileSync(path.join(root, "hooks/walletInactive.ts"), "utf8"), {
|
||||||
|
"/@/api/wallet/wallet-manage": { pageJoinActive: (query, signal) => new Promise((resolve, reject) => requests.push({ query, signal, resolve, reject })) },
|
||||||
|
})
|
||||||
|
const renderer = vue.createRenderer({
|
||||||
|
createElement: () => ({}), createText: () => ({}), createComment: () => ({}),
|
||||||
|
setText() {}, setElementText() {}, patchProp() {}, insert() {}, remove() {}, parentNode: () => null, nextSibling: () => null,
|
||||||
|
})
|
||||||
|
let state
|
||||||
|
const app = renderer.createApp({ setup() { state = hook.useWalletInactive(); return () => null } })
|
||||||
|
app.mount({})
|
||||||
|
return { state, requests, close: () => app.unmount() }
|
||||||
|
}
|
||||||
|
const settle = () => new Promise(resolve => setImmediate(resolve))
|
||||||
|
const response = (query, records = [], extra = {}) => ({ data: {
|
||||||
|
records: records.map((row, index) => ({ userId: String(index), wallets: [], ...row })), total: records.length, current: query.current, size: query.size,
|
||||||
|
asOfTime: "2026-10-22 12:00:00", cutoffTime: "2026-07-22 12:00:00", unactiveMonthsNumber: query.unactiveMonthsNumber,
|
||||||
|
walletNames: { "1": "饭卡钱包", "2": "消费钱包", "3": "充值钱包" }, ...extra,
|
||||||
|
} })
|
||||||
|
|
||||||
|
test("挂载只请求一次,沿用原接口并传递取消信号", async () => {
|
||||||
|
const page = mount()
|
||||||
|
try {
|
||||||
|
assert.equal(page.requests.length, 1)
|
||||||
|
assert.deepEqual(page.requests[0].query, { userId: undefined, unactiveMonthsNumber: 3, deptIds: [], isInclude: true, current: 1, size: 20 })
|
||||||
|
const requests = []
|
||||||
|
const api = load(fs.readFileSync(path.join(root, "api/wallet/wallet-manage.ts"), "utf8"), {
|
||||||
|
"/@/utils/request": { default: options => { requests.push(options); return Promise.resolve({}) } }, "/@/api/asyncTask": {},
|
||||||
|
})
|
||||||
|
await api.pageJoinActive(page.requests[0].query, page.requests[0].signal)
|
||||||
|
assert.equal(requests[0].url, "/wallet/wallet-manage/page-join-active")
|
||||||
|
assert.equal(requests[0].signal, page.requests[0].signal)
|
||||||
|
assert.deepEqual(requests[0].params, { current: 1, size: 20 })
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("同条件在途请求去重,新条件取消旧请求,旧响应不能覆盖结果", async () => {
|
||||||
|
const page = mount()
|
||||||
|
try {
|
||||||
|
page.state.refresh()
|
||||||
|
assert.equal(page.requests.length, 1)
|
||||||
|
page.state.form.userId = "9007199254740993999"
|
||||||
|
page.state.setMonths(6)
|
||||||
|
assert.equal(page.requests.length, 2)
|
||||||
|
assert.equal(page.requests[0].signal.aborted, true)
|
||||||
|
assert.equal(page.requests[1].query.userId, "9007199254740993999")
|
||||||
|
page.requests[1].resolve(response(page.requests[1].query, [{ userId: "new", totalBalance: "0", neverTransacted: true }]))
|
||||||
|
await settle()
|
||||||
|
page.requests[0].resolve(response(page.requests[0].query, [{ userId: "old" }]))
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.state.rows.value[0].userId, "new")
|
||||||
|
assert.equal(page.state.summary.value.balance, 0)
|
||||||
|
assert.equal(page.state.metadata.value.unactiveMonthsNumber, 6)
|
||||||
|
assert.equal(page.state.loading.value, false)
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("连续部门勾选合并查询且参数冻结,卸载取消计时与请求", async () => {
|
||||||
|
const page = mount()
|
||||||
|
page.state.changeDepartments(["11"], true)
|
||||||
|
page.state.changeDepartments(["11", "12"], true)
|
||||||
|
page.state.changeDepartments(["13"], false)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 290))
|
||||||
|
assert.equal(page.requests.length, 2)
|
||||||
|
assert.deepEqual(page.requests[1].query.deptIds, ["13"])
|
||||||
|
assert.equal(page.requests[1].query.isInclude, false)
|
||||||
|
page.state.form.deptIds.push("14")
|
||||||
|
assert.deepEqual(page.requests[1].query.deptIds, ["13"])
|
||||||
|
page.state.changeDepartments(["15"], true)
|
||||||
|
page.close()
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 290))
|
||||||
|
assert.equal(page.requests.length, 2)
|
||||||
|
assert.equal(page.requests[1].signal.aborted, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("非法月数不请求,重置恢复默认值并阻止旧失败污染新结果", async () => {
|
||||||
|
const page = mount()
|
||||||
|
try {
|
||||||
|
for (const value of [undefined, null, 0, -1, 1.5, 121]) {
|
||||||
|
page.state.form.unactiveMonthsNumber = value
|
||||||
|
await page.state.search()
|
||||||
|
assert.equal(page.requests.length, 1)
|
||||||
|
assert.match(page.state.monthsError.value, /整数/)
|
||||||
|
}
|
||||||
|
page.state.form.userId = "77"
|
||||||
|
page.state.form.deptIds = ["8"]
|
||||||
|
page.state.pagination.current = 9
|
||||||
|
page.state.reset()
|
||||||
|
assert.equal(page.requests.length, 2)
|
||||||
|
assert.equal(page.requests[1].query.current, 1)
|
||||||
|
assert.equal(page.requests[1].query.unactiveMonthsNumber, 3)
|
||||||
|
assert.equal(page.requests[1].query.userId, undefined)
|
||||||
|
assert.deepEqual(page.requests[1].query.deptIds, [])
|
||||||
|
page.requests[0].reject({ msg: "旧请求失败" })
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.state.error.value, "")
|
||||||
|
assert.equal(page.state.loading.value, true)
|
||||||
|
page.requests[1].resolve(response(page.requests[1].query))
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.state.loaded.value, true)
|
||||||
|
assert.equal(page.state.monthsError.value, "")
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("失败清除旧统计并可重试,真实空结果与失败状态分开", async () => {
|
||||||
|
const page = mount()
|
||||||
|
try {
|
||||||
|
page.requests[0].resolve(response(page.requests[0].query, [{ totalBalance: -50, neverTransacted: false }], { total: 99 }))
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.state.summary.value.balance, -50)
|
||||||
|
page.state.refresh()
|
||||||
|
page.requests[1].reject({ msg: "查询超时" })
|
||||||
|
await settle()
|
||||||
|
assert.deepEqual(page.state.rows.value, [])
|
||||||
|
assert.equal(page.state.loaded.value, false)
|
||||||
|
assert.equal(page.state.error.value, "查询超时")
|
||||||
|
page.state.refresh()
|
||||||
|
page.requests[2].resolve(response(page.requests[2].query))
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.state.error.value, "")
|
||||||
|
assert.equal(page.state.loaded.value, true)
|
||||||
|
assert.equal(page.state.pagination.total, 0)
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("翻页、调整每页人数、服务端纠正页码均不会重复请求", async () => {
|
||||||
|
const page = mount()
|
||||||
|
try {
|
||||||
|
page.requests[0].resolve(response(page.requests[0].query, [], { total: 200 }))
|
||||||
|
await settle()
|
||||||
|
page.state.changePage(3)
|
||||||
|
assert.equal(page.requests[1].query.current, 3)
|
||||||
|
assert.equal(page.state.pagination.total, 200, "在途请求不能清零总数,否则真实分页组件会自动回到第1页")
|
||||||
|
page.state.changeSize(50)
|
||||||
|
assert.equal(page.requests[2].query.current, 1)
|
||||||
|
assert.equal(page.requests[2].query.size, 50)
|
||||||
|
page.requests[2].resolve(response(page.requests[2].query, [], { current: 1, total: 0 }))
|
||||||
|
await settle()
|
||||||
|
page.state.changePage(1)
|
||||||
|
assert.equal(page.requests.length, 3)
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
|
|
||||||
|
test("页面实际 Vue 模板、脚本与样式可编译", () => {
|
||||||
|
const filename = path.join(root, "views/wallet/wallet-inactive/index.vue")
|
||||||
|
const { descriptor, errors } = parse(fs.readFileSync(filename, "utf8"), { filename })
|
||||||
|
assert.deepEqual(errors, [])
|
||||||
|
const script = compileScript(descriptor, { id: "wallet-inactive-test" })
|
||||||
|
assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: "wallet-inactive-test", compilerOptions: { bindingMetadata: script.bindings } }).errors, [])
|
||||||
|
const sass = require("sass")
|
||||||
|
assert.ok(sass.compileString(descriptor.styles[0].content).css.length > 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("旧接口或不完整响应显示失败,不将缺失余额解释为零", async () => {
|
||||||
|
const page = mount()
|
||||||
|
try {
|
||||||
|
page.requests[0].resolve({ data: { records: [{ userId: "77", balance: 100 }], total: 1, current: 1, size: 20 } })
|
||||||
|
await settle()
|
||||||
|
assert.equal(page.state.loaded.value, false)
|
||||||
|
assert.match(page.state.error.value, /统计结果不完整/)
|
||||||
|
assert.deepEqual(page.state.rows.value, [])
|
||||||
|
} finally { page.close() }
|
||||||
|
})
|
||||||
Reference in new issue
Block a user