From 54e776f487ad43920fc5c9bd7f41e73dec60f1a2 Mon Sep 17 00:00:00 2001 From: qian Date: Sat, 13 Jun 2026 20:23:24 +0800 Subject: [PATCH] =?UTF-8?q?chore:=E8=A1=A5=E4=BC=A0=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/admin/src/composables/useTopProgress.ts | 94 ++++++++++++++++++++ web/tenant/src/composables/useTopProgress.ts | 94 ++++++++++++++++++++ web/tenant/src/utils/payment.ts | 44 +++++++++ 3 files changed, 232 insertions(+) create mode 100644 web/admin/src/composables/useTopProgress.ts create mode 100644 web/tenant/src/composables/useTopProgress.ts create mode 100644 web/tenant/src/utils/payment.ts diff --git a/web/admin/src/composables/useTopProgress.ts b/web/admin/src/composables/useTopProgress.ts new file mode 100644 index 0000000..e4cbc4c --- /dev/null +++ b/web/admin/src/composables/useTopProgress.ts @@ -0,0 +1,94 @@ +import { ref, nextTick } from 'vue' + +/** + * 顶部加载进度条(模块级单例) + * + * 参考 NProgress 的精髓:start() 后用指数收敛缓慢爬升、永远到不了 100%, + * 营造「加载中」的视觉反馈;done() 跳到 100% 再淡出。 + * + * 状态放在模块作用域(非函数内),保证多处调用共享同一份进度。 + */ + +const TICK_MS = 100 +/** 指数收敛时间常数:越大爬升越慢 */ +const TIME_CONSTANT = 1500 +/** 运行中进度上限,永远到不了 100% */ +const MAX_PROGRESS = 90 +/** done() 后让宽度先滑到 100% 再淡出的过渡时间 */ +const HIDE_DELAY_MS = 300 +/** 绝对超时兜底:防止 done() 因故未被调用导致进度条卡死 */ +const TIMEOUT_MS = 15_000 + +const progress = ref(0) +const visible = ref(false) +/** 是否启用宽度过渡;重置帧临时关闭,避免归零时从右向左缩回 */ +const smooth = ref(true) + +let tickTimer: ReturnType | null = null +let hideTimer: ReturnType | null = null +let startTime = 0 + +function clearTickTimer(): void { + if (tickTimer !== null) { + clearInterval(tickTimer) + tickTimer = null + } +} + +function clearHideTimer(): void { + if (hideTimer !== null) { + clearTimeout(hideTimer) + hideTimer = null + } +} + +function start(): void { + // 清掉上一次 done() 排程的隐藏,避免「刚 start 就被旧定时器隐藏」 + clearHideTimer() + + // 幂等:已在运行则直接返回,不重置进度(支持重定向链中途多次 start) + if (visible.value) return + + // 关闭宽度过渡,让重置(上次残留的 100% → 0)瞬间完成、不可见 + smooth.value = false + progress.value = 0 + visible.value = true + + // 下一帧恢复过渡并起步爬升 + nextTick(() => { + smooth.value = true + progress.value = 8 // 即时起步反馈,避免前 100ms 空白 + startTime = Date.now() + + clearTickTimer() + tickTimer = setInterval(() => { + const elapsed = Date.now() - startTime + // 绝对超时兜底 + if (elapsed > TIMEOUT_MS) { + done() + return + } + // 用 elapsed 而非 tick 累加,避免后台 tab 定时器被 throttle 后进度漂移 + const next = MAX_PROGRESS * (1 - Math.exp(-elapsed / TIME_CONSTANT)) + progress.value = Math.min(next, 99) + }, TICK_MS) + }) +} + +function done(): void { + clearTickTimer() + // 幂等:已隐藏则直接返回(会被 afterEach / onError / 超时三处调用) + if (!visible.value) return + + progress.value = 100 + clearHideTimer() + hideTimer = setTimeout(() => { + visible.value = false + // 不归零 progress,保持 100% 满宽度淡出;下次 start 时再无过渡重置 + hideTimer = null + }, HIDE_DELAY_MS) +} + +export function useTopProgress() { + return { progress, visible, smooth, start, done } +} diff --git a/web/tenant/src/composables/useTopProgress.ts b/web/tenant/src/composables/useTopProgress.ts new file mode 100644 index 0000000..e4cbc4c --- /dev/null +++ b/web/tenant/src/composables/useTopProgress.ts @@ -0,0 +1,94 @@ +import { ref, nextTick } from 'vue' + +/** + * 顶部加载进度条(模块级单例) + * + * 参考 NProgress 的精髓:start() 后用指数收敛缓慢爬升、永远到不了 100%, + * 营造「加载中」的视觉反馈;done() 跳到 100% 再淡出。 + * + * 状态放在模块作用域(非函数内),保证多处调用共享同一份进度。 + */ + +const TICK_MS = 100 +/** 指数收敛时间常数:越大爬升越慢 */ +const TIME_CONSTANT = 1500 +/** 运行中进度上限,永远到不了 100% */ +const MAX_PROGRESS = 90 +/** done() 后让宽度先滑到 100% 再淡出的过渡时间 */ +const HIDE_DELAY_MS = 300 +/** 绝对超时兜底:防止 done() 因故未被调用导致进度条卡死 */ +const TIMEOUT_MS = 15_000 + +const progress = ref(0) +const visible = ref(false) +/** 是否启用宽度过渡;重置帧临时关闭,避免归零时从右向左缩回 */ +const smooth = ref(true) + +let tickTimer: ReturnType | null = null +let hideTimer: ReturnType | null = null +let startTime = 0 + +function clearTickTimer(): void { + if (tickTimer !== null) { + clearInterval(tickTimer) + tickTimer = null + } +} + +function clearHideTimer(): void { + if (hideTimer !== null) { + clearTimeout(hideTimer) + hideTimer = null + } +} + +function start(): void { + // 清掉上一次 done() 排程的隐藏,避免「刚 start 就被旧定时器隐藏」 + clearHideTimer() + + // 幂等:已在运行则直接返回,不重置进度(支持重定向链中途多次 start) + if (visible.value) return + + // 关闭宽度过渡,让重置(上次残留的 100% → 0)瞬间完成、不可见 + smooth.value = false + progress.value = 0 + visible.value = true + + // 下一帧恢复过渡并起步爬升 + nextTick(() => { + smooth.value = true + progress.value = 8 // 即时起步反馈,避免前 100ms 空白 + startTime = Date.now() + + clearTickTimer() + tickTimer = setInterval(() => { + const elapsed = Date.now() - startTime + // 绝对超时兜底 + if (elapsed > TIMEOUT_MS) { + done() + return + } + // 用 elapsed 而非 tick 累加,避免后台 tab 定时器被 throttle 后进度漂移 + const next = MAX_PROGRESS * (1 - Math.exp(-elapsed / TIME_CONSTANT)) + progress.value = Math.min(next, 99) + }, TICK_MS) + }) +} + +function done(): void { + clearTickTimer() + // 幂等:已隐藏则直接返回(会被 afterEach / onError / 超时三处调用) + if (!visible.value) return + + progress.value = 100 + clearHideTimer() + hideTimer = setTimeout(() => { + visible.value = false + // 不归零 progress,保持 100% 满宽度淡出;下次 start 时再无过渡重置 + hideTimer = null + }, HIDE_DELAY_MS) +} + +export function useTopProgress() { + return { progress, visible, smooth, start, done } +} diff --git a/web/tenant/src/utils/payment.ts b/web/tenant/src/utils/payment.ts new file mode 100644 index 0000000..1826fc6 --- /dev/null +++ b/web/tenant/src/utils/payment.ts @@ -0,0 +1,44 @@ +/** + * 以 POST 表单方式把支付参数提交到支付网关。 + * + * 易支付 submit.php 的「页面跳转支付」标准做法:后端只返回 submit.php 的 action + * 地址 + 签名后的参数,前端构造一个隐藏表单 POST 提交。 + * 相比把 sign/订单参数拼进 GET URL,更安全(不落浏览器历史与服务器日志), + * 也规避 URL 长度限制和中文/特殊字符的编码边界问题。 + * + * @param url 表单 action 地址(submit.php) + * @param params 表单隐藏域参数(含 sign / sign_type) + */ +export function submitPaymentForm(url: string, params: Record): void { + if (!url) return + const form = document.createElement('form') + form.action = url + form.method = 'POST' + Object.entries(params || {}).forEach(([key, value]) => { + const input = document.createElement('input') + input.type = 'hidden' + input.name = key + input.value = value + form.appendChild(input) + }) + document.body.appendChild(form) + form.submit() + document.body.removeChild(form) +} + +/** + * 处理后端返回的支付结果:有 form 参数时走 POST 表单提交,否则普通跳转。 + * 兼容未来返回纯跳转 URL 的支付渠道(如 Stripe Checkout)。 + */ +export function dispatchPayment(result: { + payment_url?: string + params?: Record +}): void { + const { payment_url, params } = result || {} + if (!payment_url) return + if (params && Object.keys(params).length > 0) { + submitPaymentForm(payment_url, params) + } else { + window.location.href = payment_url + } +}