diff --git a/.env b/.env index e5797d8..c3ee2ac 100644 --- a/.env +++ b/.env @@ -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' diff --git a/.env.development b/.env.development index 5da1377..42ba90d 100644 --- a/.env.development +++ b/.env.development @@ -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 # 正式环境线上 # 代码生成服务地址 diff --git a/src/api/wallet/wallet-manage.ts b/src/api/wallet/wallet-manage.ts index a63e8e2..b1c5274 100644 --- a/src/api/wallet/wallet-manage.ts +++ b/src/api/wallet/wallet-manage.ts @@ -8,6 +8,61 @@ export const submitExport = (query: Record) => submitExportTask /** 按所选月份与原筛选条件提交钱包历史余额分批导出。 */ export const submitQbExport = (query: Record) => submitExportTask("/wallet/wallet-manage/export-qb-async", query) +/** 用户月末余额汇总筛选;金额边界单位为分,包含上下界,空值表示不限。 */ +export interface WalletQbUserQuery { + walletRecordMonth: string + userId?: string | number + walletTypeIdList?: Array + deptIds?: Array + 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) => submitExportTask("/wallet/wallet-manage/export-qb-users-async", query) + /** * 不活跃用户统计分页 */ diff --git a/src/components/UserInfoCell/index.vue b/src/components/UserInfoCell/index.vue index 196fbb1..36b1985 100644 --- a/src/components/UserInfoCell/index.vue +++ b/src/components/UserInfoCell/index.vue @@ -1,6 +1,16 @@ diff --git a/src/views/wallet/wallet-qb/useWalletQbUsers.ts b/src/views/wallet/wallet-qb/useWalletQbUsers.ts new file mode 100644 index 0000000..f76620e --- /dev/null +++ b/src/views/wallet/wallet-qb/useWalletQbUsers.ts @@ -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, + deptIds: [] as Array, + 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({ 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>({}) + /** 列表与统计独立取消,改变筛选或卸载时释放浏览器等待。 */ + 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 = { 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 } +} diff --git a/tests/wallet-ledger-exports.test.cjs b/tests/wallet-ledger-exports.test.cjs index 4c5f7ad..4b29915 100644 --- a/tests/wallet-ledger-exports.test.cjs +++ b/tests/wallet-ledger-exports.test.cjs @@ -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() } +}) diff --git a/tests/wallet-qb-statistics.test.cjs b/tests/wallet-qb-statistics.test.cjs index ee86963..e2330a4 100644 --- a/tests/wallet-qb-statistics.test.cjs +++ b/tests/wallet-qb-statistics.test.cjs @@ -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, []) })