|
|
@@ -0,0 +1,485 @@
|
|
|
+<template>
|
|
|
+ <div class="checkout">
|
|
|
+ <div class="checkout__card">
|
|
|
+ <!-- ① 载入中:只占位,不给任何文案 —— 顾客看到的第一次「有内容」必须是真内容 -->
|
|
|
+ <div v-if="phase === 'loading'" class="skeleton">
|
|
|
+ <div class="skeleton__line skeleton__line--title"></div>
|
|
|
+ <div class="skeleton__line skeleton__line--box"></div>
|
|
|
+ <div class="skeleton__line skeleton__line--btn"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ② 链接不可用:未知 / 空 / 已停用**共用同一句**(服务端就是这么回的,
|
|
|
+ 不告诉打开链接的人「这条链接存在但被停用」)。这一步**没有任何操作**可做,
|
|
|
+ 所以不放按钮 —— 放一个点了没用的按钮比不放更糟。 -->
|
|
|
+ <div v-else-if="phase === 'invalid'" class="state">
|
|
|
+ <div class="state__icon state__icon--warn">!</div>
|
|
|
+ <h2 class="state__title">收款链接无效或已停用</h2>
|
|
|
+ <p class="state__hint">请向收款方重新获取链接,或直接扫描对方出示的收款码。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- ③ 正常:商户名 + 金额 + 一个按钮。顾客页只有这一屏、只有这一个输入框。 -->
|
|
|
+ <template v-else>
|
|
|
+ <header class="head">
|
|
|
+ <!-- ★ 商户名可能为 null(发链接时那一行没填名字)⇒ 用兜底标题,
|
|
|
+ 不显示一个空白标题。文案取自后端 LsyCheckoutInfoVO 的注释。 -->
|
|
|
+ <p class="head__label">付款给</p>
|
|
|
+ <h1 class="head__name">{{ merchantName || "收款" }}</h1>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ <div class="amount" :class="{ 'amount--error': !!hint }">
|
|
|
+ <span class="amount__symbol">¥</span>
|
|
|
+ <input
|
|
|
+ ref="amountEl"
|
|
|
+ v-model="amountText"
|
|
|
+ class="amount__input"
|
|
|
+ type="text"
|
|
|
+ inputmode="decimal"
|
|
|
+ autocomplete="off"
|
|
|
+ placeholder="0.00"
|
|
|
+ :disabled="submitting || redirecting"
|
|
|
+ @input="onInput"
|
|
|
+ @keyup.enter="submit"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <p class="hint" :class="{ 'hint--error': !!hint }">
|
|
|
+ {{ hint || `最低 ${MIN_FEN / 100} 元` }}
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <button class="pay" :disabled="submitting || redirecting" @click="submit">
|
|
|
+ {{ submitting ? "正在发起支付…" : "确认付款" }}
|
|
|
+ </button>
|
|
|
+
|
|
|
+ <!-- 跳转前的最后一屏:顾客在此刻还能看到「付多少」,跳走之后页面就不归我们管了 -->
|
|
|
+ <div v-if="redirecting" class="redirect">
|
|
|
+ <div class="redirect__spinner"></div>
|
|
|
+ <p class="redirect__text">
|
|
|
+ 正在前往支付页面,应付
|
|
|
+ <strong>¥{{ fenToYuan(paidFen) }}</strong>
|
|
|
+ </p>
|
|
|
+ <p v-if="paidOutTradeNo" class="redirect__no">订单号 {{ paidOutTradeNo }}</p>
|
|
|
+ </div>
|
|
|
+ </template>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <p class="checkout__foot">本页由收款方通过支付平台生成</p>
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+/**
|
|
|
+ * 顾客页 —— 收款链接 `/#/checkout/<token>` 的落点。**免登录**。
|
|
|
+ *
|
|
|
+ * 【它做什么】三件事,顺序固定:显示商户名 → 顾客输一个金额 → 发起支付并跳到上游收银台。
|
|
|
+ * 没有第四件。「不含其他功能」是需求原文:这一屏面对的是正要付钱的人,
|
|
|
+ * 每多一个可点的东西就多一次「我该按哪个」的犹豫。
|
|
|
+ *
|
|
|
+ * 【为什么不用 `@/utils/request`】那个实例的两个拦截器都是给**管理端**写的,在顾客页会闯祸:
|
|
|
+ * · 请求拦截器会自动附上 `Authorization`(这台浏览器里可能存着一个管理端令牌 ——
|
|
|
+ * 顾客页是免登录的,带上它没有意义,徒增一个「谁的令牌」的变量);
|
|
|
+ * · 响应拦截器用 `ElMessage` **弹窗**报错,且对非 GET 的成功响应弹「操作成功」。
|
|
|
+ * 顾客页的错误必须**写在页面上**(顾客不会去读一个 3 秒后消失的浮层,
|
|
|
+ * 而那正是他付款失败的那一刻)。
|
|
|
+ * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
|
|
|
+ *
|
|
|
+ * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
|
|
|
+ * `<base>/#/checkout/<token>`(`LsyCheckoutLinkService#buildUrl` 的 `PATH_PREFIX`)。
|
|
|
+ * 拼成 `/checkout/<token>` 会命中静态路由表末尾的通配 404。
|
|
|
+ */
|
|
|
+defineOptions({
|
|
|
+ name: "Checkout",
|
|
|
+ inheritAttrs: false,
|
|
|
+});
|
|
|
+
|
|
|
+import { onMounted, ref } from "vue";
|
|
|
+import { useRoute } from "vue-router";
|
|
|
+import axios from "axios";
|
|
|
+
|
|
|
+/**
|
|
|
+ * 收款金额下限,单位**分**。与后端 `LsyAmountFloor.MIN` 是同一个数 ——
|
|
|
+ * **改一处必须改另一处**(同 `collection/cashier/index.vue` 的 `MIN_FEN`)。
|
|
|
+ *
|
|
|
+ * 【为什么顾客页也要拦一道】后端那道是真的闸(这条腿谁都能调),这里只是把话说在
|
|
|
+ * 顾客按下按钮**之前**:不拦的话他收到的是一句报文措辞
|
|
|
+ * (`amount 不能低于 1 元(100 分)`),而那是一位正要付钱的人。
|
|
|
+ */
|
|
|
+const MIN_FEN = 100;
|
|
|
+
|
|
|
+type Phase = "loading" | "invalid" | "ready";
|
|
|
+
|
|
|
+/** 只看 `code` 的最小信封形状。`Result.ok` 的成功码是 **0**(不是 200)。 */
|
|
|
+interface Envelope<T> {
|
|
|
+ code?: number;
|
|
|
+ msg?: string;
|
|
|
+ data?: T;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 顾客页专用实例:相对基址(与页面同源),**无拦截器**。
|
|
|
+ * ★ 超时给了 20 秒:这一步背后是「我们建单 + 上游下单」两次网络往返,
|
|
|
+ * 而顾客多半在移动网络下。
|
|
|
+ */
|
|
|
+const http = axios.create({
|
|
|
+ baseURL: import.meta.env.VITE_APP_BASE_API,
|
|
|
+ timeout: 20000,
|
|
|
+ headers: { "Content-Type": "application/json;charset=utf-8" },
|
|
|
+});
|
|
|
+
|
|
|
+const route = useRoute();
|
|
|
+const token = String(route.params.token ?? "");
|
|
|
+
|
|
|
+const phase = ref<Phase>("loading");
|
|
|
+const merchantName = ref<string | null>(null);
|
|
|
+const amountText = ref("");
|
|
|
+const hint = ref("");
|
|
|
+const submitting = ref(false);
|
|
|
+const redirecting = ref(false);
|
|
|
+const paidFen = ref(0);
|
|
|
+const paidOutTradeNo = ref("");
|
|
|
+const amountEl = ref<HTMLInputElement>();
|
|
|
+
|
|
|
+// ────────────────────────────── 金额 ──────────────────────────────
|
|
|
+
|
|
|
+/** 输入过滤:只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错)。 */
|
|
|
+function onInput() {
|
|
|
+ const cleaned = amountText.value
|
|
|
+ .replace(/[^\d.]/g, "")
|
|
|
+ .replace(/^(\d*\.?\d{0,2}).*$/, "$1");
|
|
|
+ amountText.value = cleaned;
|
|
|
+ hint.value = "";
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 元 → 分。**按字符串拆,不做浮点乘法。**
|
|
|
+ *
|
|
|
+ * ★ `Math.round(Number("19.99") * 100)` 在这里恰好也对(误差远小于 0.5),但它是**碰巧**对:
|
|
|
+ * 这条式子对「多少分」这个整数结果的正确性,依赖 IEEE754 那条误差上界,而
|
|
|
+ * `"1.005"` 这类三位小数的结果就取决于先 round 还是先乘。拆字符串没有这个问题,
|
|
|
+ * 而且顺手把「不是合法金额」的输入在**同一个函数**里判掉(`null`)。
|
|
|
+ *
|
|
|
+ * @returns 分;`null` = 输入不是合法金额(空、多个小数点、超过两位小数)
|
|
|
+ */
|
|
|
+function yuanToFen(text: string): number | null {
|
|
|
+ // 容忍两种「打到一半」的输入,不把它们当错误:末尾一个小数点("5.")、
|
|
|
+ // 省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了)。
|
|
|
+ const s = text.trim().replace(/\.$/, "");
|
|
|
+ if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ const [intPart, decPart = ""] = s.split(".");
|
|
|
+ const fen = Number(intPart) * 100 + Number((decPart + "00").slice(0, 2));
|
|
|
+ // 17 位数字以上已经超出安全整数 —— 这种输入只可能是误触,别拿它去建单
|
|
|
+ return Number.isSafeInteger(fen) ? fen : null;
|
|
|
+}
|
|
|
+
|
|
|
+/** 分 → 元(显示用,固定两位小数)。 */
|
|
|
+function fenToYuan(fen: number): string {
|
|
|
+ return (fen / 100).toFixed(2);
|
|
|
+}
|
|
|
+
|
|
|
+// ────────────────────────────── 两跳 ──────────────────────────────
|
|
|
+
|
|
|
+/** 开屏:拿商户显示名。拿不到(404 / 网络错)都进 `invalid`,**不区分** —— 见模板上的注释。 */
|
|
|
+async function loadInfo() {
|
|
|
+ try {
|
|
|
+ const res = await http.get<Envelope<{ merchant_name?: string | null }>>(
|
|
|
+ `/payment/checkout/${encodeURIComponent(token)}`
|
|
|
+ );
|
|
|
+ if (res.data?.code !== 0) {
|
|
|
+ phase.value = "invalid";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ merchantName.value = res.data.data?.merchant_name ?? null;
|
|
|
+ phase.value = "ready";
|
|
|
+ // 键盘弹不弹由浏览器决定(iOS 上非用户手势触发时会被忽略,那不是错误)
|
|
|
+ amountEl.value?.focus();
|
|
|
+ } catch {
|
|
|
+ // ★ 网络不通也归到 `invalid` 是**有意的取舍**:这条腿的失败里,
|
|
|
+ // 「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作
|
|
|
+ // (找收款方)。给一句「网络异常,请重试」会让他在一个**永远不会成功**的
|
|
|
+ // 链接上反复重试。真·网络问题在支付那一步会给出可照做的提示。
|
|
|
+ phase.value = "invalid";
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 按下「确认付款」:建单 → 上游下单 → 把浏览器送到上游给的链接。 */
|
|
|
+async function submit() {
|
|
|
+ if (submitting.value || redirecting.value) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const fen = yuanToFen(amountText.value);
|
|
|
+ if (fen === null) {
|
|
|
+ hint.value = "请输入正确的金额,最多两位小数";
|
|
|
+ amountEl.value?.focus();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (fen < MIN_FEN) {
|
|
|
+ hint.value = `收款金额最低 ${MIN_FEN / 100} 元`;
|
|
|
+ amountEl.value?.focus();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ submitting.value = true;
|
|
|
+ hint.value = "";
|
|
|
+ try {
|
|
|
+ const res = await http.post<
|
|
|
+ Envelope<{ out_trade_no?: string; code_url?: string; total_amount?: number }>
|
|
|
+ >(`/payment/checkout/${encodeURIComponent(token)}/orders`, { amount: fen });
|
|
|
+
|
|
|
+ const codeUrl = res.data?.data?.code_url;
|
|
|
+ if (res.data?.code !== 0 || !codeUrl) {
|
|
|
+ // 服务端的话是可以照做的(例如「支付发起失败,请稍后重试」),原样显示。
|
|
|
+ // 拿不到 code_url 而 code 是 0 属于契约被破坏,同样当失败处理 —— 否则顾客会停在
|
|
|
+ // 一个「按钮点了没反应」的页面上,而控制台里什么都没有。
|
|
|
+ hint.value = res.data?.msg || "支付发起失败,请稍后重试";
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ★ 跳转前把金额与单号显示出来:这是顾客核对金额的最后一眼,
|
|
|
+ // 单号是他「付了但页面没反应」时报给客服的凭据(见 LsyCheckoutOrderVO 的注释)。
|
|
|
+ paidFen.value = res.data.data?.total_amount ?? fen;
|
|
|
+ paidOutTradeNo.value = res.data.data?.out_trade_no ?? "";
|
|
|
+ redirecting.value = true;
|
|
|
+
|
|
|
+ // ★ 直接送到上游给的链接,**不包短链、不做中转页** —— 多一跳就多一处能把顾客卡住的地方。
|
|
|
+ // 给一帧让上面的提示先渲染出来(否则这一句谁也看不见,页面直接白了)。
|
|
|
+ await new Promise((r) => window.setTimeout(r, 80));
|
|
|
+ window.location.href = codeUrl;
|
|
|
+ } catch {
|
|
|
+ hint.value = "网络异常,请检查网络后重试";
|
|
|
+ } finally {
|
|
|
+ submitting.value = false;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+onMounted(loadInfo);
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+/* 移动优先:这一屏绝大多数时候出现在手机上。桌面端只是把它居中,不做第二套布局。 */
|
|
|
+.checkout {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ box-sizing: border-box;
|
|
|
+ min-height: 100vh;
|
|
|
+ padding: 24px 20px calc(24px + env(safe-area-inset-bottom));
|
|
|
+ background: linear-gradient(168deg, #f5f7fb 0%, #e9eef7 100%);
|
|
|
+}
|
|
|
+
|
|
|
+.checkout__card {
|
|
|
+ box-sizing: border-box;
|
|
|
+ width: 100%;
|
|
|
+ max-width: 420px;
|
|
|
+ padding: 32px 24px;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 20px;
|
|
|
+ box-shadow: 0 14px 44px rgb(16 24 40 / 8%);
|
|
|
+}
|
|
|
+
|
|
|
+.checkout__foot {
|
|
|
+ margin: 20px 0 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #98a2b3;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 载入占位 ── */
|
|
|
+.skeleton__line {
|
|
|
+ background: #eef1f6;
|
|
|
+ border-radius: 8px;
|
|
|
+ animation: shimmer 1.2s ease-in-out infinite;
|
|
|
+}
|
|
|
+
|
|
|
+.skeleton__line--title {
|
|
|
+ width: 45%;
|
|
|
+ height: 20px;
|
|
|
+ margin-bottom: 28px;
|
|
|
+}
|
|
|
+
|
|
|
+.skeleton__line--box {
|
|
|
+ height: 60px;
|
|
|
+ margin-bottom: 20px;
|
|
|
+}
|
|
|
+
|
|
|
+.skeleton__line--btn {
|
|
|
+ height: 50px;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes shimmer {
|
|
|
+ 50% {
|
|
|
+ opacity: 0.45;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 链接不可用 ── */
|
|
|
+.state {
|
|
|
+ padding: 12px 0;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.state__icon--warn {
|
|
|
+ display: flex;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ width: 52px;
|
|
|
+ height: 52px;
|
|
|
+ margin: 0 auto 18px;
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 700;
|
|
|
+ color: #b54708;
|
|
|
+ background: #fef0c7;
|
|
|
+ border-radius: 50%;
|
|
|
+}
|
|
|
+
|
|
|
+.state__title {
|
|
|
+ margin: 0 0 10px;
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2939;
|
|
|
+}
|
|
|
+
|
|
|
+.state__hint {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 13px;
|
|
|
+ line-height: 1.7;
|
|
|
+ color: #667085;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 商户名 ── */
|
|
|
+.head {
|
|
|
+ margin-bottom: 28px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.head__label {
|
|
|
+ margin: 0 0 6px;
|
|
|
+ font-size: 13px;
|
|
|
+ color: #98a2b3;
|
|
|
+}
|
|
|
+
|
|
|
+.head__name {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 21px;
|
|
|
+ font-weight: 600;
|
|
|
+ line-height: 1.4;
|
|
|
+ color: #1d2939;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 金额 ── */
|
|
|
+.amount {
|
|
|
+ display: flex;
|
|
|
+ align-items: baseline;
|
|
|
+ gap: 6px;
|
|
|
+ padding-bottom: 10px;
|
|
|
+ border-bottom: 2px solid #eaecf0;
|
|
|
+ transition: border-color 0.2s;
|
|
|
+
|
|
|
+ &:focus-within {
|
|
|
+ border-color: #4080ff; /* = settings.ts 的 themeColor,与后台同一支主色 */
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.amount--error {
|
|
|
+ border-color: #f04438;
|
|
|
+}
|
|
|
+
|
|
|
+.amount__symbol {
|
|
|
+ font-size: 26px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2939;
|
|
|
+}
|
|
|
+
|
|
|
+.amount__input {
|
|
|
+ flex: 1;
|
|
|
+ min-width: 0;
|
|
|
+ padding: 0;
|
|
|
+ font-family: inherit;
|
|
|
+ font-size: 40px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #1d2939;
|
|
|
+ background: transparent;
|
|
|
+ border: none;
|
|
|
+ outline: none;
|
|
|
+
|
|
|
+ &::placeholder {
|
|
|
+ font-weight: 400;
|
|
|
+ color: #d0d5dd;
|
|
|
+ }
|
|
|
+
|
|
|
+ /* 数字等宽 + 右对齐的观感:金额从左往右打,光标不跳 */
|
|
|
+ font-variant-numeric: tabular-nums;
|
|
|
+}
|
|
|
+
|
|
|
+.hint {
|
|
|
+ min-height: 20px;
|
|
|
+ margin: 10px 0 22px;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #98a2b3;
|
|
|
+}
|
|
|
+
|
|
|
+.hint--error {
|
|
|
+ color: #f04438;
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 按钮 ── */
|
|
|
+.pay {
|
|
|
+ width: 100%;
|
|
|
+ height: 50px;
|
|
|
+ font-family: inherit;
|
|
|
+ font-size: 17px;
|
|
|
+ font-weight: 600;
|
|
|
+ color: #fff;
|
|
|
+ cursor: pointer;
|
|
|
+ background: #4080ff;
|
|
|
+ border: none;
|
|
|
+ border-radius: 12px;
|
|
|
+ transition:
|
|
|
+ background 0.2s,
|
|
|
+ transform 0.1s;
|
|
|
+
|
|
|
+ &:active:not(:disabled) {
|
|
|
+ transform: scale(0.99);
|
|
|
+ }
|
|
|
+
|
|
|
+ &:disabled {
|
|
|
+ cursor: default;
|
|
|
+ opacity: 0.6;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/* ── 跳转中 ── */
|
|
|
+.redirect {
|
|
|
+ margin-top: 20px;
|
|
|
+ text-align: center;
|
|
|
+}
|
|
|
+
|
|
|
+.redirect__spinner {
|
|
|
+ width: 22px;
|
|
|
+ height: 22px;
|
|
|
+ margin: 0 auto 10px;
|
|
|
+ border: 2px solid #d1e0ff;
|
|
|
+ border-top-color: #4080ff;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 0.8s linear infinite;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes spin {
|
|
|
+ to {
|
|
|
+ transform: rotate(360deg);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.redirect__text {
|
|
|
+ margin: 0;
|
|
|
+ font-size: 14px;
|
|
|
+ color: #475467;
|
|
|
+}
|
|
|
+
|
|
|
+.redirect__no {
|
|
|
+ margin: 6px 0 0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #98a2b3;
|
|
|
+ word-break: break-all;
|
|
|
+}
|
|
|
+</style>
|