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
185 lines
8.5 KiB
TypeScript
185 lines
8.5 KiB
TypeScript
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue"
|
|
import { fetchQbUsers, qbUsersTotal } from "/@/api/wallet/wallet-manage"
|
|
import type { WalletQbUserQuery, WalletQbUserRow, WalletQbUserTotal } from "/@/api/wallet/wallet-manage"
|
|
import { useMessage } from "/@/hooks/message"
|
|
import { formatDate } from "/@/utils/formatTime"
|
|
|
|
/** 金额输入以元展示,空字符串表示不限;请求使用整数分,避免浮点数比较金额。 */
|
|
export function parseBalanceYuan(value: string | number | undefined): number | undefined {
|
|
const input = String(value ?? "").trim()
|
|
if (!input) return undefined
|
|
if (!/^-?(?:\d+(?:\.\d{0,2})?|\.\d{1,2})$/.test(input)) throw new Error("余额最多输入两位小数")
|
|
// 先拼接整数分再转换数值,大额输入也不经过小数乘法,避免二进制浮点丢失一分钱。
|
|
const [integer, fraction = ""] = input.replace(/^-/, "").split(".")
|
|
const cents = Number(`${integer || "0"}${fraction.padEnd(2, "0")}`) * (input.startsWith("-") ? -1 : 1)
|
|
if (!Number.isSafeInteger(cents)) throw new Error("余额超出支持范围")
|
|
return cents
|
|
}
|
|
|
|
/** 用户汇总页独立持有筛选快照、取消句柄和请求序号,旧响应不能覆盖新查询。 */
|
|
export function useWalletQbUsers() {
|
|
/** 搜索表单是待提交条件;翻页、排序、统计与导出只使用已应用条件。 */
|
|
const state = reactive({
|
|
queryForm: {
|
|
walletRecordMonth: formatDate(new Date(), "YYYY-mm"),
|
|
userId: undefined as string | number | undefined,
|
|
walletTypeIdList: [] as Array<string | number>,
|
|
deptIds: [] as Array<string | number>,
|
|
isInclude: true,
|
|
minBalanceYuan: "",
|
|
maxBalanceYuan: "",
|
|
},
|
|
dataList: [] as WalletQbUserRow[],
|
|
pagination: { current: 1, size: 20, total: 0, pageSizes: [20, 50, 100, 200], layout: "total, sizes, prev, pager, next, jumper" },
|
|
loading: false,
|
|
})
|
|
/** 最近一次提交的查询快照,金额已转成整数分。 */
|
|
const appliedQuery = ref<WalletQbUserQuery>({ walletRecordMonth: state.queryForm.walletRecordMonth })
|
|
/** 服务端排序字段白名单,取消排序后由后端使用稳定的用户顺序。 */
|
|
const sort = reactive({ ascs: "", descs: "" })
|
|
const pageError = ref("")
|
|
const balanceStatisticsLoading = ref(false)
|
|
const balanceStatisticsLoaded = ref(false)
|
|
const balanceStatisticsError = ref("")
|
|
const balanceStatistics = reactive<Partial<WalletQbUserTotal>>({})
|
|
/** 列表与统计独立取消,改变筛选或卸载时释放浏览器等待。 */
|
|
let listController: AbortController | undefined
|
|
let statisticsController: AbortController | undefined
|
|
let listSequence = 0
|
|
let statisticsSequence = 0
|
|
let disposed = false
|
|
|
|
/** 构建可独立使用的筛选快照,单边、零和负数边界均保留。 */
|
|
const buildQuery = (): WalletQbUserQuery => {
|
|
const { walletRecordMonth, userId, walletTypeIdList, deptIds, isInclude, minBalanceYuan, maxBalanceYuan } = state.queryForm
|
|
const minBalance = parseBalanceYuan(minBalanceYuan)
|
|
const maxBalance = parseBalanceYuan(maxBalanceYuan)
|
|
if (minBalance !== undefined && maxBalance !== undefined && minBalance > maxBalance) throw new Error("最低余额不能大于最高余额")
|
|
return { walletRecordMonth, userId, walletTypeIdList: [...walletTypeIdList], deptIds: [...deptIds], isInclude, minBalance, maxBalance }
|
|
}
|
|
|
|
/** 将区间错误展示在金额输入旁,提交时也阻止无效请求。 */
|
|
const rangeError = computed(() => {
|
|
try { buildQuery(); return "" } catch (error: any) { return error.message }
|
|
})
|
|
|
|
/** 清除旧统计,避免月份或区间改变后仍展示上一范围的数字。 */
|
|
const clearStatistics = () => {
|
|
statisticsSequence++
|
|
statisticsController?.abort()
|
|
statisticsController = undefined
|
|
balanceStatisticsLoading.value = false
|
|
balanceStatisticsLoaded.value = false
|
|
balanceStatisticsError.value = ""
|
|
for (const key of Object.keys(balanceStatistics)) delete balanceStatistics[key as keyof WalletQbUserTotal]
|
|
}
|
|
|
|
/** 查询已应用筛选下的当前页;成功、失败和取消均受请求序号约束。 */
|
|
const queryPage = async () => {
|
|
listController?.abort()
|
|
const controller = new AbortController()
|
|
listController = controller
|
|
const sequence = ++listSequence
|
|
state.loading = true
|
|
pageError.value = ""
|
|
try {
|
|
const query = JSON.parse(JSON.stringify(appliedQuery.value))
|
|
const response = await fetchQbUsers({ ...query, ...sort, current: state.pagination.current, size: state.pagination.size }, controller.signal)
|
|
if (disposed || sequence !== listSequence || controller.signal.aborted) return
|
|
if (response.code !== undefined && response.code !== 0) throw new Error(response.msg || "余额查询失败")
|
|
state.dataList = response.data?.records ?? []
|
|
state.pagination.total = response.data?.total ?? 0
|
|
} catch (error: any) {
|
|
if (disposed || sequence !== listSequence || controller.signal.aborted) return
|
|
state.dataList = []
|
|
state.pagination.total = 0
|
|
pageError.value = error?.msg || error?.message || "余额查询失败,请重试"
|
|
} finally {
|
|
if (sequence === listSequence && !disposed) {
|
|
state.loading = false
|
|
listController = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 提交搜索条件,范围变更后清空旧统计;区间无效时不发出请求。 */
|
|
const getDataList = async () => {
|
|
let query: WalletQbUserQuery
|
|
try { query = buildQuery() } catch (error: any) { useMessage().error(error.message); return }
|
|
// 同一范围重新查询也可能有新流水,旧统计必须失效,避免刷新后金额新旧混用。
|
|
clearStatistics()
|
|
appliedQuery.value = query
|
|
state.pagination.current = 1
|
|
state.dataList = []
|
|
await queryPage()
|
|
}
|
|
|
|
/** 翻页保持已查询范围,不隐式应用尚未提交的表单输入。 */
|
|
const currentChangeHandle = (current: number) => {
|
|
if (current === state.pagination.current) return
|
|
state.pagination.current = current
|
|
return queryPage()
|
|
}
|
|
|
|
/** 改变每页人数时回到第一页。 */
|
|
const sizeChangeHandle = (size: number) => {
|
|
state.pagination.size = size
|
|
state.pagination.current = 1
|
|
return queryPage()
|
|
}
|
|
|
|
/** 四个金额列均由后端对全部匹配用户排序,切换方向后回到第一页。 */
|
|
const handleBalanceSortChange = ({ prop, order }: { prop?: string; order: "ascending" | "descending" | null }) => {
|
|
const fields: Record<string, string> = { balance: "balance", eatBalance: "eat_balance", payBalance: "pay_balance", topupBalance: "topup_balance" }
|
|
const field = fields[prop || ""]
|
|
sort.ascs = field && order === "ascending" ? field : ""
|
|
sort.descs = field && order === "descending" ? field : ""
|
|
state.pagination.current = 1
|
|
return queryPage()
|
|
}
|
|
|
|
/** 全量统计按需计算,翻页不重复扫描历史余额;统计失败可直接重试。 */
|
|
const viewBalanceStatistics = async () => {
|
|
if (balanceStatisticsLoading.value || state.loading || disposed) return
|
|
const sequence = ++statisticsSequence
|
|
const controller = new AbortController()
|
|
statisticsController = controller
|
|
balanceStatisticsLoading.value = true
|
|
balanceStatisticsLoaded.value = false
|
|
balanceStatisticsError.value = ""
|
|
try {
|
|
const query = JSON.parse(JSON.stringify(appliedQuery.value))
|
|
const response = await qbUsersTotal(query, controller.signal)
|
|
if (disposed || sequence !== statisticsSequence || controller.signal.aborted) return
|
|
if (response.code !== undefined && response.code !== 0) throw new Error(response.msg || "统计失败")
|
|
Object.assign(balanceStatistics, response.data)
|
|
balanceStatisticsLoaded.value = true
|
|
} catch (error: any) {
|
|
if (disposed || sequence !== statisticsSequence || controller.signal.aborted) return
|
|
balanceStatisticsError.value = error?.msg || error?.message || "统计失败,请重试"
|
|
} finally {
|
|
if (sequence === statisticsSequence && !disposed) {
|
|
balanceStatisticsLoading.value = false
|
|
statisticsController = undefined
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 导出与当前页面使用同一范围快照,不带列表分页。 */
|
|
const getAppliedQuery = () => JSON.parse(JSON.stringify({ ...appliedQuery.value, ...sort }))
|
|
|
|
/** 组件卸载时取消在途请求,防止旧响应写入或弹出错误。 */
|
|
const dispose = () => {
|
|
disposed = true
|
|
listSequence++
|
|
listController?.abort()
|
|
clearStatistics()
|
|
}
|
|
|
|
onMounted(getDataList)
|
|
onBeforeUnmount(dispose)
|
|
return { state, appliedQuery, rangeError, pageError, getDataList, currentChangeHandle, sizeChangeHandle, handleBalanceSortChange,
|
|
viewBalanceStatistics, clearStatistics, balanceStatistics, balanceStatisticsLoading, balanceStatisticsLoaded, balanceStatisticsError,
|
|
getAppliedQuery, dispose }
|
|
}
|