index.vue 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826
  1. <template>
  2. <div class="checkout">
  3. <!-- ① 载入中:占位的形状照着真内容量(商户名 / 标签 / 金额 / 键盘块),不给任何文案
  4. —— 顾客看到的第一次「有内容」必须是真内容 -->
  5. <template v-if="phase === 'loading'">
  6. <header class="panel">
  7. <div class="skeleton skeleton--name"></div>
  8. <div class="skeleton skeleton--label"></div>
  9. <div class="skeleton skeleton--amount"></div>
  10. </header>
  11. <div class="pad-skeleton"></div>
  12. </template>
  13. <!-- ② 链接不可用:未知 / 空 / 已停用**共用同一句**(服务端就是这么回的,
  14. 不告诉打开链接的人「这条链接存在但被停用」)。这一步**没有任何操作**可做,
  15. 所以不放按钮 —— 放一个点了没用的按钮比不放更糟。 -->
  16. <template v-else-if="phase === 'invalid'">
  17. <header class="panel">
  18. <h1 class="panel__name">收款</h1>
  19. </header>
  20. <div class="notice">
  21. <p class="notice__title">收款链接无效或已停用</p>
  22. <p class="notice__hint">请向收款方重新获取链接,或直接扫描对方出示的收款码。</p>
  23. </div>
  24. </template>
  25. <!-- ③ 正常:商户名 + 金额 + 键盘(最右那一格就是「付款」)。
  26. 顾客第一眼要确认的是「钱付给谁」,所以商户名排在最上面一行。 -->
  27. <template v-else>
  28. <header class="panel">
  29. <!-- ★ 商户名可能为 null(发链接时那一行没填名字)⇒ 用兜底标题,
  30. 不显示一个空白标题。文案取自后端 LsyCheckoutInfoVO 的注释。 -->
  31. <h1 class="panel__name">{{ merchantName || "收款" }}</h1>
  32. <p class="panel__label">金额</p>
  33. <!-- 金额那行同时是**显示**:顾客不需要点它。`inputmode="none"` —— 点它也不弹系统
  34. 键盘(键盘就在下面),而桌面上的实体键盘照样能直接打字。 -->
  35. <div class="amount" :class="{ 'amount--error': !!hint, 'amount--shake': shaking }">
  36. <span class="amount__symbol">¥</span>
  37. <input
  38. ref="amountEl"
  39. v-model="amountText"
  40. class="amount__input"
  41. type="text"
  42. inputmode="none"
  43. autocomplete="off"
  44. aria-label="付款金额(元)"
  45. placeholder="请输入金额"
  46. :disabled="submitting || redirecting"
  47. @input="onInput"
  48. @keyup.enter="submit"
  49. />
  50. </div>
  51. </header>
  52. <!-- 垫在中间的那块空白:把键盘压到屏幕下沿,金额与键盘之间不插别的东西 -->
  53. <div class="spacer"></div>
  54. <p class="hint" :class="{ 'hint--error': !!hint }">
  55. {{ hint || `最低 ${MIN_FEN / 100} 元` }}
  56. </p>
  57. <!-- 键盘:4 列,最右一列上面是退格、下面是纵跨三行的「付款」。
  58. ★ 付款键**长在键盘里**,不再另起一个通栏按钮 —— 顾客本来就要做两件事
  59. (输金额、付款),两件事就长在同一块板上,中间不插第二个可点的东西。
  60. ★ 「0」横跨两格:这是国内收银台键盘的固定形状,照做即可。 -->
  61. <div class="pad" role="group" aria-label="金额键盘">
  62. <button
  63. v-for="k in PAD_KEYS"
  64. :key="k.key"
  65. type="button"
  66. class="pad__key"
  67. :class="{ 'pad__key--pay': k.pay, 'pad__key--wide': k.wide }"
  68. :disabled="submitting || redirecting"
  69. :aria-label="
  70. k.key === BACKSPACE ? '退格' : k.pay ? '付款' : k.key === '.' ? '小数点' : k.key
  71. "
  72. @click="press(k.key)"
  73. >
  74. <!-- 退格用内联 SVG,不用 `⌫` 那个码位:它在部分安卓字体里会掉成方框 -->
  75. <svg v-if="k.key === BACKSPACE" class="pad__icon" viewBox="0 0 24 24" aria-hidden="true">
  76. <path
  77. d="M9 5h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5.6-7z"
  78. fill="none"
  79. stroke="currentColor"
  80. stroke-width="1.7"
  81. stroke-linejoin="round"
  82. />
  83. <path
  84. d="M11.4 9.6l4.8 4.8m0-4.8l-4.8 4.8"
  85. fill="none"
  86. stroke="currentColor"
  87. stroke-width="1.7"
  88. stroke-linecap="round"
  89. />
  90. </svg>
  91. <template v-else>{{ k.pay ? (submitting ? "…" : "付款") : k.key }}</template>
  92. </button>
  93. </div>
  94. </template>
  95. <!-- 跳转中:盖满整屏。顾客此刻已经不该再碰键盘了 —— 键本来就 disabled,
  96. 再留一个看得见的键盘只会诱他去点。顾客还能看到「付多少」,
  97. 跳走之后这页就不归我们管了。 -->
  98. <div v-if="redirecting" class="redirect">
  99. <div class="redirect__spinner"></div>
  100. <p class="redirect__text">正在前往支付页面,应付 <strong>¥{{ fenToYuan(paidFen) }}</strong></p>
  101. <p v-if="paidOutTradeNo" class="redirect__no">订单号 {{ paidOutTradeNo }}</p>
  102. </div>
  103. </div>
  104. </template>
  105. <script setup lang="ts">
  106. /**
  107. * 顾客页 —— 收款链接 `/#/checkout/<token>` 的落点。**免登录**。
  108. *
  109. * 【它做什么】三件事,顺序固定:显示商户名 → 顾客输一个金额 → 发起支付并跳到上游收银台。
  110. * 没有第四件。「不含其他功能」是需求原文:这一屏面对的是正要付钱的人,
  111. * 每多一个可点的东西就多一次「我该按哪个」的犹豫。
  112. *
  113. * 【为什么不用 `@/utils/request`】那个实例的两个拦截器都是给**管理端**写的,在顾客页会闯祸:
  114. * · 请求拦截器会自动附上 `Authorization`(这台浏览器里可能存着一个管理端令牌 ——
  115. * 顾客页是免登录的,带上它没有意义,徒增一个「谁的令牌」的变量);
  116. * · 响应拦截器用 `ElMessage` **弹窗**报错,且对非 GET 的成功响应弹「操作成功」。
  117. * 顾客页的错误必须**写在页面上**(顾客不会去读一个 3 秒后消失的浮层,
  118. * 而那正是他付款失败的那一刻)。
  119. * 所以这里自建一个实例,**不挂任何拦截器**,每个响应都自己看 `code`。
  120. *
  121. * 【为什么是 hash 路由】`createWebHashHistory` —— 后端拼的链接形如
  122. * `<base>/#/checkout/<token>`(`LsyCheckoutLinkService#buildUrl` 的 `PATH_PREFIX`)。
  123. * 拼成 `/checkout/<token>` 会命中静态路由表末尾的通配 404。
  124. */
  125. defineOptions({
  126. name: "Checkout",
  127. inheritAttrs: false,
  128. });
  129. import { onMounted, onUnmounted, ref } from "vue";
  130. import { useRoute } from "vue-router";
  131. import axios from "axios";
  132. /**
  133. * 收款金额下限,单位**分**。与后端 `LsyAmountFloor.MIN` 是同一个数 ——
  134. * **改一处必须改另一处**(同 `collection/cashier/index.vue` 的 `MIN_FEN`)。
  135. *
  136. * 【为什么顾客页也要拦一道】后端那道是真的闸(这条腿谁都能调),这里只是把话说在
  137. * 顾客按下按钮**之前**:不拦的话他收到的是一句报文措辞
  138. * (`amount 不能低于 1 元(100 分)`),而那是一位正要付钱的人。
  139. */
  140. const MIN_FEN = 100;
  141. /**
  142. * 顾客页的浏览器标题。**有意与后台不同**。
  143. *
  144. * 【为什么不能靠全局那支】全站标题由 `main.ts` 的 `setTitleAndFavicon()` 从
  145. * `sys_web_title`(库里的系统配置)取,值是**管理端**的名字(「公司管理」)——
  146. * 那是给操作员看的,不该出现在一位正要付钱的顾客的标签页上。
  147. * 而且那个函数是**异步**的(要先等一次配置请求),谁先跑完不确定 ⇒
  148. * 光在这里写还不够,`main.ts` 那侧也加了一道同名判据(见那里的注释)。
  149. */
  150. const PAGE_TITLE = "Payment Platform";
  151. type Phase = "loading" | "invalid" | "ready";
  152. /** 只看 `code` 的最小信封形状。`Result.ok` 的成功码是 **0**(不是 200)。 */
  153. interface Envelope<T> {
  154. code?: number;
  155. msg?: string;
  156. data?: T;
  157. }
  158. /**
  159. * 顾客页专用实例:相对基址(与页面同源),**无拦截器**。
  160. * ★ 超时给了 20 秒:这一步背后是「我们建单 + 上游下单」两次网络往返,
  161. * 而顾客多半在移动网络下。
  162. */
  163. const http = axios.create({
  164. baseURL: import.meta.env.VITE_APP_BASE_API,
  165. timeout: 20000,
  166. headers: { "Content-Type": "application/json;charset=utf-8" },
  167. });
  168. const route = useRoute();
  169. const token = String(route.params.token ?? "");
  170. const phase = ref<Phase>("loading");
  171. const merchantName = ref<string | null>(null);
  172. const amountText = ref("");
  173. const hint = ref("");
  174. const submitting = ref(false);
  175. const redirecting = ref(false);
  176. const shaking = ref(false);
  177. const paidFen = ref(0);
  178. const paidOutTradeNo = ref("");
  179. const amountEl = ref<HTMLInputElement>();
  180. // ────────────────────────────── 金额 ──────────────────────────────
  181. /**
  182. * 键盘的键序与摆位。`BACKSPACE` / `PAY` 是两种**非数字**键,各自单独给一个值,
  183. * 模板据此换成图标或文案;`wide` / `pay` 只影响栅格占位(见 .pad)。
  184. *
  185. * ★ 这两样(`0` 横跨两格、`付款` 纵跨最右三行)不是排版偏好,是国内收银台键盘的
  186. * 固定形状 —— 形状对了,顾客不用读文案就知道怎么按。
  187. */
  188. const BACKSPACE = "back";
  189. const PAY = "pay";
  190. interface PadKey {
  191. key: string;
  192. /** 「0」横跨两格 */
  193. wide?: boolean;
  194. /** 纵跨最右三行的「付款」键 */
  195. pay?: boolean;
  196. }
  197. const PAD_KEYS: PadKey[] = [
  198. { key: "1" },
  199. { key: "2" },
  200. { key: "3" },
  201. { key: BACKSPACE },
  202. { key: "4" },
  203. { key: "5" },
  204. { key: "6" },
  205. { key: PAY, pay: true },
  206. { key: "7" },
  207. { key: "8" },
  208. { key: "9" },
  209. { key: "0", wide: true },
  210. { key: "." },
  211. ];
  212. /**
  213. * 整数位上限。**键盘让这件事变成必然**:按住一个键不放,位数会一直被推上去,
  214. * 而金额那行的字号是跟视口放大的 —— 位数一多,那行字就顶出纸面(输入框自己会横向滚,
  215. * 顾客看到的是一个被裁掉头部的金额)。
  216. * 7 位 = 999 万元,够任何线下收款;这个数还与 `--mono` 的字宽对得上(见 .amount__input)。
  217. * 上限只写在 `onInput` 这一处 —— 纸键盘与实体键盘共用同一条过滤。
  218. */
  219. const MAX_INT_DIGITS = 7;
  220. /**
  221. * 输入过滤:纸键盘与物理键盘**唯一**的入口。
  222. * 只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错);
  223. * 顺手去掉多余的整数前导零("05" → "5"),免得金额行印出个 `05`;
  224. * 再把开头那一个孤零零的小数点补成 `0.` —— 顾客先按 `.` 再按数字,要的是 `0.5` 而不是 `.5`
  225. * (`yuanToFen` 本来就吃得下 `.5`,但金额行上顶着一个光秃秃的 `.` 是不对的)。
  226. */
  227. function onInput() {
  228. const cleaned = amountText.value
  229. .replace(/[^\d.]/g, "")
  230. .replace(/^(\d*\.?\d{0,2}).*$/, "$1")
  231. .replace(/^0+(?=\d)/, "")
  232. .replace(/^\./, "0.");
  233. const [intPart = "", decPart] = cleaned.split(".");
  234. amountText.value =
  235. intPart.length > MAX_INT_DIGITS
  236. ? intPart.slice(0, MAX_INT_DIGITS) + (decPart === undefined ? "" : `.${decPart}`)
  237. : cleaned;
  238. hint.value = "";
  239. // 顾客一动键盘,上一条错误提示的手感就结束了(见 reject 的注释)
  240. shaking.value = false;
  241. }
  242. /**
  243. * 键盘按下了一格:数字键接一位、退格键删一位、付款键就是提交。
  244. *
  245. * ★ 过滤与上限**只有 `onInput` 一处** —— 两边各写一份,迟早出现
  246. * 「物理键盘打不进去、点键盘能打进去」这类只在一种输入下成立的缺陷。
  247. */
  248. function press(key: string) {
  249. if (submitting.value || redirecting.value) {
  250. return;
  251. }
  252. if (key === PAY) {
  253. submit();
  254. return;
  255. }
  256. amountText.value =
  257. key === BACKSPACE ? amountText.value.slice(0, -1) : amountText.value + key;
  258. onInput();
  259. }
  260. /**
  261. * 校验没过:把话说在页面上,并让金额行抖一下。
  262. *
  263. * ★ 「抖」不能靠每次失败都重挂一次 CSS 动画(同一个类连着挂两次,动画不会重放)——
  264. * 这里只负责**置真**,由 `onInput()`(顾客的下一次动作)置假:顾客不动它就一直为真,
  265. * 也不会再抖第二下,正好。
  266. */
  267. function reject(message: string) {
  268. hint.value = message;
  269. shaking.value = true;
  270. amountEl.value?.focus();
  271. }
  272. /**
  273. * 元 → 分。**按字符串拆,不做浮点乘法。**
  274. *
  275. * ★ `Math.round(Number("19.99") * 100)` 在这里恰好也对(误差远小于 0.5),但它是**碰巧**对:
  276. * 这条式子对「多少分」这个整数结果的正确性,依赖 IEEE754 那条误差上界,而
  277. * `"1.005"` 这类三位小数的结果就取决于先 round 还是先乘。拆字符串没有这个问题,
  278. * 而且顺手把「不是合法金额」的输入在**同一个函数**里判掉(`null`)。
  279. *
  280. * @returns 分;`null` = 输入不是合法金额(空、多个小数点、超过两位小数)
  281. */
  282. function yuanToFen(text: string): number | null {
  283. // 容忍「打到一半」的输入,不把它当错误:末尾只挂一个小数点("5.")。
  284. // 也吃省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了),
  285. // 但那条**不来自界面** —— `onInput` 会把开头的小数点补成 `0.`,只有直接调用本函数的
  286. // 代码才可能递进来。留着它是因为这里本来就是个纯函数,不必依赖调用方的形态。
  287. const s = text.trim().replace(/\.$/, "");
  288. if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
  289. return null;
  290. }
  291. const [intPart, decPart = ""] = s.split(".");
  292. const fen = Number(intPart) * 100 + Number((decPart + "00").slice(0, 2));
  293. // 17 位数字以上已经超出安全整数 —— 这种输入只可能是误触,别拿它去建单
  294. return Number.isSafeInteger(fen) ? fen : null;
  295. }
  296. /** 分 → 元(显示用,固定两位小数)。 */
  297. function fenToYuan(fen: number): string {
  298. return (fen / 100).toFixed(2);
  299. }
  300. // ────────────────────────────── 两跳 ──────────────────────────────
  301. /** 开屏:拿商户显示名。拿不到(404 / 网络错)都进 `invalid`,**不区分** —— 见模板上的注释。 */
  302. async function loadInfo() {
  303. try {
  304. const res = await http.get<Envelope<{ merchant_name?: string | null }>>(
  305. `/payment/checkout/${encodeURIComponent(token)}`
  306. );
  307. if (res.data?.code !== 0) {
  308. phase.value = "invalid";
  309. return;
  310. }
  311. merchantName.value = res.data.data?.merchant_name ?? null;
  312. phase.value = "ready";
  313. // ★ 这里**故意不**自动聚焦金额框:键盘已经印在纸上了,自动聚焦只会在手机上
  314. // 弹出一块系统软键盘,正好盖住刚画好的那半张纸。
  315. // (聚焦还剩一个用途:校验没过时把光标放回去,好让桌面上的实体键盘能接着打字。)
  316. } catch {
  317. // ★ 网络不通也归到 `invalid` 是**有意的取舍**:这条腿的失败里,
  318. // 「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作
  319. // (找收款方)。给一句「网络异常,请重试」会让他在一个**永远不会成功**的
  320. // 链接上反复重试。真·网络问题在支付那一步会给出可照做的提示。
  321. phase.value = "invalid";
  322. }
  323. }
  324. /** 按下「确认付款」:建单 → 上游下单 → 把浏览器送到上游给的链接。 */
  325. async function submit() {
  326. if (submitting.value || redirecting.value) {
  327. return;
  328. }
  329. const fen = yuanToFen(amountText.value);
  330. if (fen === null) {
  331. reject("请输入正确的金额,最多两位小数");
  332. return;
  333. }
  334. if (fen < MIN_FEN) {
  335. reject(`收款金额最低 ${MIN_FEN / 100} 元`);
  336. return;
  337. }
  338. submitting.value = true;
  339. hint.value = "";
  340. try {
  341. const res = await http.post<
  342. Envelope<{ out_trade_no?: string; code_url?: string; total_amount?: number }>
  343. >(`/payment/checkout/${encodeURIComponent(token)}/orders`, { amount: fen });
  344. const codeUrl = res.data?.data?.code_url;
  345. if (res.data?.code !== 0 || !codeUrl) {
  346. // 服务端的话是可以照做的(例如「支付发起失败,请稍后重试」),原样显示。
  347. // 拿不到 code_url 而 code 是 0 属于契约被破坏,同样当失败处理 —— 否则顾客会停在
  348. // 一个「按钮点了没反应」的页面上,而控制台里什么都没有。
  349. hint.value = res.data?.msg || "支付发起失败,请稍后重试";
  350. return;
  351. }
  352. // ★ 跳转前把金额与单号显示出来:这是顾客核对金额的最后一眼,
  353. // 单号是他「付了但页面没反应」时报给客服的凭据(见 LsyCheckoutOrderVO 的注释)。
  354. paidFen.value = res.data.data?.total_amount ?? fen;
  355. paidOutTradeNo.value = res.data.data?.out_trade_no ?? "";
  356. redirecting.value = true;
  357. // ★ 直接送到上游给的链接,**不包短链、不做中转页** —— 多一跳就多一处能把顾客卡住的地方。
  358. // 给一帧让上面的提示先渲染出来(否则这一句谁也看不见,页面直接白了)。
  359. await new Promise((r) => window.setTimeout(r, 80));
  360. window.location.href = codeUrl;
  361. } catch {
  362. hint.value = "网络异常,请检查网络后重试";
  363. } finally {
  364. submitting.value = false;
  365. }
  366. }
  367. /**
  368. * 进页面时换成顾客页自己的标题,**离开时还原**。
  369. *
  370. * ★ 还原不是洁癖:这是一个 SPA,浏览器后退键会把人送回后台那一屏,
  371. * 而 `main.ts` 只在应用启动时设一次标题 —— 不还原的话,后台的标签页会一直挂着
  372. * 「Payment Platform」,而那个名字对操作员没有任何意义。
  373. * ★ 自己之前的标题由**自己**存一份再写回(不是写死一个常量回填):将来若全站标题
  374. * 改成从别处来,这里不需要跟着改。
  375. */
  376. let titleBefore: string | null = null;
  377. onMounted(() => {
  378. titleBefore = document.title;
  379. document.title = PAGE_TITLE;
  380. loadInfo();
  381. });
  382. onUnmounted(() => {
  383. if (titleBefore !== null) {
  384. document.title = titleBefore;
  385. }
  386. });
  387. </script>
  388. <style lang="scss" scoped>
  389. /* ══════════════════════════════════════════════════════════════════════
  390. 顾客页的视觉参照是**国内收银台**(微信/支付宝那一路的扫码付款页)。
  391. 2026-09-30 按用户给的码实扫了一页当基准(`https://p.eycard.cn/p?qr=rE3Erq`,
  392. 要用微信内置浏览器的 UA 才给渲染),390×844 下量到的读数:
  393. · 底色 rgba(0,0,0,.02);商户名 16.6px/700 #333
  394. · 「金额」标签 14.6px/700;¥ 27px/700;占位文字 30px/500 #dedede
  395. · 金额数字 35.4px/700;键 87×52、圆角 5.2px、白底
  396. · 键投影 0 2px 2px rgb(0 0 0 / 18%);键上的数字 20.8px/700 #000
  397. · 「付款」键 87×166 —— 纵跨最右三行;「0」横跨两格;退格在右上角
  398. 前几版走的是「一张收据」:深桌面、撕口、条码、编号、作废印章。那些都是**比喻**,
  399. 每个比喻都要额外画一样东西,加起来就成了「设计过的海报」,而不是一个收银台。
  400. 这一版照抄参照页的是**比例与克制**,不是它的配色:主色仍是我们自己的 --brand。
  401. ══════════════════════════════════════════════════════════════════════ */
  402. .checkout {
  403. --brand: #4080ff; /* = settings.ts 的 themeColor。整页只有「付款」键用它 */
  404. --ink: #333; /* 主文字(照参照页量的 #333,不是纯黑) */
  405. --ink-soft: #8a8f99; /* 次要文字:提示行 */
  406. --placeholder: #dedede;
  407. --page: #f2f3f5; /* 底。参照页是 rgba(0,0,0,.02)≈#fafafa,这里压深一点:
  408. 白块压在 #fafafa 上几乎分不出边界 */
  409. --danger: #e34d59; /* 校验失败。与后台 Element Plus 的 danger 同色 */
  410. --key-shadow: 0 2px 2px rgb(0 0 0 / 18%), 0 1px 1px rgb(0 0 0 / 16%);
  411. /* 键行高。定成变量是因为**两处**要用它:键盘自己,和加载态骨架
  412. (骨架高度必须等于键区高度,否则回复一到整页会跳一下)。
  413. 宽屏那段会覆盖它 —— 见文件末尾的 @media。 */
  414. --key-row: 66px;
  415. /* ★ 宽度必须在这里定死,不能留给内容去决定。祖先链 html/body/#app 三层**全是
  416. display:flex** ⇒ 这一层是个 flex 项,而 flex 项的 min-width:auto 落在 min-content 上:
  417. 内容一宽就把它顶出去,在 390 的手机上实测 scrollWidth 410(右侧半个按钮在屏幕外)。
  418. width:100% 给定尺(这个页面本来就该铺满),min-width:0 允许它跟着容器收缩。 */
  419. width: 100%;
  420. min-width: 0;
  421. display: flex;
  422. flex-direction: column;
  423. box-sizing: border-box;
  424. min-height: 100vh;
  425. /* 中文用系统字体 —— 顾客多半在移动网络下,不引外链字体 */
  426. font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
  427. "Microsoft YaHei", "Noto Sans SC", sans-serif;
  428. color: var(--ink);
  429. background: var(--page);
  430. /* ── 宽屏:整页收成一根手机宽的柱子居中 ──
  431. 这一屏真正的入口是扫码,顾客一定在手机上;但**商户自己会拿电脑点开这条链接试**,
  432. 而不管的话 1440 下键盘就铺满整屏(实测键宽 350px),一眼就是「没管过桌面」。
  433. 柱子宽度取 480:比任何手机的竖屏都宽,所以 390~430 这一段**一个像素都不变**
  434. (实测 390 下 panel/pad/键 的框与未加这段时逐格相同,scrollWidth 仍是 390)。
  435. 祖先链 html/body/#app 全是 flex ⇒ 本层是 flex 项,align-items:center 是它的交叉轴对齐,
  436. 子项横向居中;柱子给子项而不是给自己,页面底色才是出血的(自己收窄会在两侧留一道白边)。 */
  437. align-items: center;
  438. > *:not(.redirect) {
  439. width: 100%;
  440. max-width: 480px;
  441. }
  442. }
  443. /* ── 顶部信息块:商户名 +「金额」+ 金额行,三样压在同一块白底上 ──
  444. 参照页也是这么排的:商户名下面直接跟「金额」标签,中间不加分隔线。 */
  445. .panel {
  446. padding: 14px 16px 18px;
  447. background: #fff;
  448. }
  449. .panel__name {
  450. margin: 0;
  451. font-size: 17px;
  452. font-weight: 700;
  453. line-height: 1.35;
  454. word-break: break-all;
  455. }
  456. .panel__label {
  457. margin: 18px 0 0;
  458. font-size: 15px;
  459. font-weight: 700;
  460. }
  461. /* ── 金额行:¥ 小一号、数字大,左对齐 ── */
  462. .amount {
  463. display: flex;
  464. align-items: center;
  465. gap: 8px;
  466. margin-top: 6px;
  467. min-height: 62px;
  468. }
  469. .amount__symbol {
  470. font-size: 32px;
  471. font-weight: 700;
  472. }
  473. /* ★ 数字用 tabular-nums(**不是**等宽字体栈):位数变化时字宽不变,光标不跳。
  474. 参照页的金额也是系统字体加粗 —— 等宽字体是前几版「收据」那个比喻带进来的。 */
  475. .amount__input {
  476. flex: 1;
  477. min-width: 0;
  478. padding: 0;
  479. font-family: inherit;
  480. font-size: 46px;
  481. font-weight: 700;
  482. color: var(--ink);
  483. font-variant-numeric: tabular-nums;
  484. background: transparent;
  485. border: none;
  486. outline: none;
  487. /* 占位比真值小一号、细一档 —— 参照页就是这么分的(30px/500 对 35px/700,
  488. 这里是它那个比例放大后的 36px/500 对 46px/700) */
  489. &::placeholder {
  490. font-size: 36px;
  491. font-weight: 500;
  492. color: var(--placeholder);
  493. }
  494. /* iOS 上 disabled 会把字压成灰的,而这一格是顾客刚输入的金额,不许变淡 */
  495. &:disabled {
  496. color: var(--ink);
  497. -webkit-text-fill-color: var(--ink);
  498. opacity: 1;
  499. }
  500. }
  501. /* 校验没过:¥ 与金额一起变红。只红这一行 —— 键盘不参与报错。 */
  502. .amount--error .amount__symbol,
  503. .amount--error .amount__input {
  504. color: var(--danger);
  505. }
  506. /* 垫在中间的那块空白:把键盘压到屏幕下沿 */
  507. .spacer {
  508. flex: 1;
  509. min-height: 16px;
  510. }
  511. /* 提示行:安静时是「最低 1 元」,出错时是被拒的原因。
  512. 位置就在键盘正上方 —— 与参照页那行品牌字同格,顾客的眼从键盘往上一格就能读到。 */
  513. .hint {
  514. min-height: 19px;
  515. margin: 0 16px 10px;
  516. font-size: 13px;
  517. text-align: center;
  518. color: var(--ink-soft);
  519. }
  520. .hint--error {
  521. color: var(--danger);
  522. }
  523. /* ── 键盘:4 列 × 4 行,最右一列上面退格、下面「付款」 ──
  524. ★ 白键压在同色底上、彼此留缝:键是**浮起来的卡片**,不是格线。
  525. 前几版画的是出血的表格线,那是「印在纸上的表」那个比喻带来的;
  526. 国内收银台一律是这种方块键。 */
  527. .pad {
  528. display: grid;
  529. grid-template-columns: repeat(4, 1fr);
  530. grid-auto-rows: var(--key-row);
  531. gap: 6px;
  532. padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
  533. }
  534. .pad__key {
  535. display: flex;
  536. align-items: center;
  537. justify-content: center;
  538. padding: 0;
  539. font-family: inherit;
  540. font-size: 24px;
  541. font-weight: 700;
  542. color: #000;
  543. cursor: pointer;
  544. background: #fff;
  545. border: none;
  546. border-radius: 5px;
  547. box-shadow: var(--key-shadow);
  548. /* 触摸端:去掉系统那层灰色高光(它会在键上留一块脏印)、去掉长按选中,
  549. 也去掉双击缩放带来的那 300ms 迟疑 */
  550. -webkit-tap-highlight-color: transparent;
  551. touch-action: manipulation;
  552. user-select: none;
  553. transition: background 0.12s;
  554. &:active:not(:disabled) {
  555. background: #f0f0f0;
  556. }
  557. &:disabled {
  558. cursor: default;
  559. opacity: 0.5;
  560. }
  561. &:focus-visible {
  562. outline: 2px solid var(--brand);
  563. outline-offset: -2px;
  564. }
  565. }
  566. .pad__key--wide {
  567. grid-column: span 2;
  568. }
  569. /* 「付款」:纵跨最右三行。整页唯一的亮色块,也是唯一一个非白键。 */
  570. .pad__key--pay {
  571. grid-column: 4;
  572. grid-row: 2 / span 3;
  573. font-size: 21px;
  574. color: #fff;
  575. background: var(--brand);
  576. /* 禁用态不用 opacity:蓝底压到 50% 会变成一块脏灰蓝。参照页的做法是换成同一个色的
  577. 浅版(它那个绿按钮禁用时就是浅绿)。 */
  578. &:disabled {
  579. background: #a8c4ff;
  580. opacity: 1;
  581. }
  582. &:active:not(:disabled) {
  583. background: #3572e6;
  584. }
  585. }
  586. .pad__icon {
  587. width: 26px;
  588. height: 26px;
  589. color: #333;
  590. }
  591. /* ── 宽屏:空白交给键盘吃掉 ──
  592. 这一屏在桌面上没有「拇指够不够得着」这回事,中间那块空白唯一的用处就是「别太挤」;
  593. 可不管它的话它会跟着显示器一起长 —— 柱子收到 480 之后,1440×900 实测空出 428px,
  594. 整页像一条被拉长的收据。让它长在键盘上:键一大,这块空自然就短了。
  595. 行高取 clamp 而不是定值。定 112 的话,1440×700 这种半屏窗口上内容会到 675 而窗口
  596. 只有 700 —— 再矮一点就平白长出滚动条;12vh 让它跟着窗口缩。下限 66 就是手机那一档
  597. (窗口再矮也不小于手机),上限 112 = 键宽(键 112 宽时正好见方,再高就拉长了)。
  598. 键上的字要一起放大,否则一格 112 里蹲着个 24px 的数字,看着发虚。
  599. ★ 这一段只挂在 ≥600px。390×844 下逐格不变:键仍 89×66、scrollHeight 仍 844
  600. (实测,改前改后同一批读数)。 */
  601. @media (min-width: 600px) {
  602. .checkout {
  603. --key-row: clamp(66px, 12vh, 112px);
  604. }
  605. .pad__key {
  606. font-size: 30px;
  607. }
  608. .pad__key--pay {
  609. font-size: 26px;
  610. }
  611. .pad__icon {
  612. width: 34px;
  613. height: 34px;
  614. }
  615. }
  616. /* ── 跳转中:盖满整屏 ── */
  617. .redirect {
  618. position: fixed;
  619. inset: 0;
  620. display: flex;
  621. flex-direction: column;
  622. align-items: center;
  623. justify-content: center;
  624. padding: 0 32px;
  625. text-align: center;
  626. background: #fff;
  627. }
  628. .redirect__spinner {
  629. width: 22px;
  630. height: 22px;
  631. margin-bottom: 12px;
  632. border: 2px solid #d8e4ff;
  633. border-top-color: var(--brand);
  634. border-radius: 50%;
  635. animation: spin 0.8s linear infinite;
  636. }
  637. @keyframes spin {
  638. to {
  639. transform: rotate(360deg);
  640. }
  641. }
  642. .redirect__text {
  643. margin: 0;
  644. font-size: 14px;
  645. color: var(--ink);
  646. }
  647. .redirect__no {
  648. margin: 8px 0 0;
  649. font-size: 12px;
  650. color: var(--ink-soft);
  651. word-break: break-all;
  652. }
  653. /* ── 链接不可用:一句话 + 一条可照做的建议。没有别的可点 ── */
  654. .notice {
  655. margin: 40px 24px 0;
  656. text-align: center;
  657. }
  658. .notice__title {
  659. margin: 0 0 10px;
  660. font-size: 17px;
  661. font-weight: 600;
  662. }
  663. .notice__hint {
  664. margin: 0;
  665. font-size: 13px;
  666. line-height: 1.7;
  667. color: var(--ink-soft);
  668. }
  669. /* ── 载入占位:形状照着真内容量(名字 / 标签 / 金额 / 键盘块)。
  670. 占位与真内容差得远的话,回复一到整页会跳一下。 ── */
  671. .skeleton {
  672. background: #eef0f2;
  673. border-radius: 3px;
  674. animation: shimmer 1.6s ease-in-out infinite;
  675. }
  676. .skeleton--name {
  677. width: 52%;
  678. height: 17px;
  679. }
  680. .skeleton--label {
  681. width: 12%;
  682. height: 15px;
  683. margin-top: 18px;
  684. }
  685. .skeleton--amount {
  686. width: 42%;
  687. height: 46px;
  688. margin-top: 10px;
  689. }
  690. /* 键盘那块占位压在屏幕下沿(margin-top:auto)。高度对齐的是**键区**、不是整个键盘块:
  691. 4 行 + 3 个 6px 缝 = 4×行高 + 18,正好是真键盘第一排键的上沿到最后一排键的下沿。
  692. 键盘块自己的上下各 8px 是贴着页面底色的留白,占位不该连它一起画 —— 那样会差 16px。
  693. 用 `var(--key-row)` 而不是写死:宽屏下行高会变,写死的话骨架与真键区就对不上了。 */
  694. .pad-skeleton {
  695. height: calc(var(--key-row) * 4 + 18px);
  696. margin: auto 8px calc(8px + env(safe-area-inset-bottom));
  697. background: #e9ebee;
  698. border-radius: 5px;
  699. animation: shimmer 1.6s ease-in-out infinite;
  700. }
  701. @keyframes shimmer {
  702. 50% {
  703. opacity: 0.5;
  704. }
  705. }
  706. /* 校验没过:金额行左右抖一下。用动画而不是 JS 逐帧 —— 它在 reduced-motion 下被一并
  707. 关掉,而信息不丢:那时金额变红、提示那句照样写在键盘上方。 */
  708. @keyframes shake {
  709. 20% {
  710. transform: translateX(-5px);
  711. }
  712. 45% {
  713. transform: translateX(4px);
  714. }
  715. 70% {
  716. transform: translateX(-3px);
  717. }
  718. 90% {
  719. transform: translateX(2px);
  720. }
  721. }
  722. .amount--shake {
  723. animation: shake 0.36s cubic-bezier(0.36, 0.07, 0.19, 0.97);
  724. }
  725. /* ★ 减少动效:只关掉动的东西,元素停在**静态形态**(占位是满不透明的),不是消失
  726. —— 这一点靠的是动画只写在关键帧里,而不是另写一套覆盖规则。 */
  727. @media (prefers-reduced-motion: reduce) {
  728. .amount--shake,
  729. .skeleton,
  730. .pad-skeleton,
  731. .redirect__spinner {
  732. animation: none;
  733. }
  734. }
  735. </style>