From aaeb519bbc4a4af3d5eaf24a07aa7b6fcb9f5863 Mon Sep 17 00:00:00 2001 From: dzc <2388224167@qq.com> Date: Thu, 22 Oct 2026 16:23:47 +0800 Subject: [PATCH] 11 --- src/api/wallet/wallet-inactive.ts | 45 ++ src/api/wallet/wallet-manage.ts | 6 +- src/hooks/walletInactive.ts | 172 +++++ src/views/hard/hard-sh/index.vue | 23 +- src/views/wallet/wallet-inactive/index.vue | 722 +++++++++++++++------ tests/hard-sh-query-lifecycle.test.cjs | 121 ++++ tests/wallet-inactive.test.cjs | 180 +++++ 7 files changed, 1070 insertions(+), 199 deletions(-) create mode 100644 src/api/wallet/wallet-inactive.ts create mode 100644 src/hooks/walletInactive.ts create mode 100644 tests/hard-sh-query-lifecycle.test.cjs create mode 100644 tests/wallet-inactive.test.cjs diff --git a/src/api/wallet/wallet-inactive.ts b/src/api/wallet/wallet-inactive.ts new file mode 100644 index 0000000..56b7bd3 --- /dev/null +++ b/src/api/wallet/wallet-inactive.ts @@ -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 +} diff --git a/src/api/wallet/wallet-manage.ts b/src/api/wallet/wallet-manage.ts index fd82086..eb72b40 100644 --- a/src/api/wallet/wallet-manage.ts +++ b/src/api/wallet/wallet-manage.ts @@ -1,5 +1,6 @@ import request from "/@/utils/request" import { submitExportTask } from "/@/api/asyncTask" +import type { WalletInactivePage, WalletInactiveQuery } from "/@/api/wallet/wallet-inactive" /** 按当前筛选条件提交账户余额后台分批导出。 */ export const submitExport = (query: Record) => submitExportTask("/wallet/wallet-manage/export-async", query) @@ -10,7 +11,7 @@ export const submitQbExport = (query: Record) => submitExportTa /** * 不活跃用户统计分页 */ -export function pageJoinActive(data: any) { +export function pageJoinActive(data: WalletInactiveQuery, signal?: AbortSignal): Promise<{ data: WalletInactivePage }> { return request({ url: "/wallet/wallet-manage/page-join-active", method: "post", @@ -19,7 +20,8 @@ export function pageJoinActive(data: any) { size: data.size, }, data: data, - }) + signal, + }) as unknown as Promise<{ data: WalletInactivePage }> } /** diff --git a/src/hooks/walletInactive.ts b/src/hooks/walletInactive.ts new file mode 100644 index 0000000..b83741d --- /dev/null +++ b/src/hooks/walletInactive.ts @@ -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([]) + const metadata = shallowRef() + 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 | undefined + let departmentTimer: ReturnType | 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 => { + 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, 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, + } +} diff --git a/src/views/hard/hard-sh/index.vue b/src/views/hard/hard-sh/index.vue index 0fdeaa4..d08c83f 100644 --- a/src/views/hard/hard-sh/index.vue +++ b/src/views/hard/hard-sh/index.vue @@ -47,7 +47,7 @@ - +
@@ -113,19 +113,18 @@ const state = reactive({ queryForm: { ...defaultQueryForm }, }) -watch(() => [activeTab.value, hardShByDayRef.value, hardShByMonthRef.value], () => { +// 异步子表挂载后统一查询,避免 mounted、tab-click 和 ref watcher 重复触发重型统计。 +watch([hardShByDayRef, hardShByMonthRef], () => { if (activeTab.value === "MCH" && hardShByDayRef.value?.doSearch) { search() } if (activeTab.value === "MONTH" && hardShByMonthRef.value?.doSearch) { search() } -}) +}, { flush: "post" }) -onMounted(() => { - nextTick(() => { - search() - }) +watch(activeTab, () => { + state.queryForm = { ...defaultQueryForm } }) /** 当前页面只声明原导出场景,进度及重复提交由公共 hook 管理。 */ @@ -139,12 +138,6 @@ const exportExcel = () => { return submitExport({ ...state.queryForm }) } -const tabClick = () => { - console.log(123123); - - cancel() -} - const search = () => { if (activeTab.value === "MCH") { state.queryForm.payTimeYearStr = "" @@ -154,10 +147,10 @@ const search = () => { } switch (activeTab.value) { case "MCH": - hardShByDayRef.value.doSearch(state.queryForm) + hardShByDayRef.value?.doSearch(state.queryForm) break; case "MONTH": - hardShByMonthRef.value.doSearch(state.queryForm) + hardShByMonthRef.value?.doSearch(state.queryForm) break; } } diff --git a/src/views/wallet/wallet-inactive/index.vue b/src/views/wallet/wallet-inactive/index.vue index 08f5249..282f9f9 100644 --- a/src/views/wallet/wallet-inactive/index.vue +++ b/src/views/wallet/wallet-inactive/index.vue @@ -1,207 +1,565 @@ - diff --git a/tests/hard-sh-query-lifecycle.test.cjs b/tests/hard-sh-query-lifecycle.test.cjs new file mode 100644 index 0000000..21298f3 --- /dev/null +++ b/tests/hard-sh-query-lifecycle.test.cjs @@ -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() } +}) diff --git a/tests/wallet-inactive.test.cjs b/tests/wallet-inactive.test.cjs new file mode 100644 index 0000000..dce6d6f --- /dev/null +++ b/tests/wallet-inactive.test.cjs @@ -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() } +})