Browse Source

style(顾客页): 收款链接页照国内收银台重做(四列键盘 + 纵跨三行的「付款」),并补两处边界

前一版把这一屏做成「一张收据」:深桌面、撕口、条码、编号、作废印章。用户判它
**「AI 味太浓」**,并给了一张真码当基准(扫进去是 `https://p.eycard.cn/p?qr=rE3Erq`,
要用微信内置浏览器的 UA 才给渲染)。这一版照那一页做。

那些比喻的问题不是不好看,是**每一个都要额外画一样东西**(纸要撕口、要条码、要落款),
加起来就成了「设计过的海报」,而不是一个收银台。所以这一版照抄的是它的**比例与克制**,
不是它的配色:主色仍是我方 `--brand`(刻意不用参照页那个绿 —— 那个绿一眼就是微信支付)。

量出来的参照(390×844):商户名 16.6px/700 #333;「金额」标签 14.6px/700;¥ 27px/700;
占位 30px/500 #dedede;金额数字 35.4px/700;键 87×52、圆角 5.2px、白底、投影
`0 2px 2px rgb(0 0 0 / 18%)`;键上的数字 20.8px/700;「付款」键 87×166(纵跨最右三行);
「0」横跨两格;退格在右上角。我方成品在 390 下量到的是键 89×52、「付款」89×168、
「0」184 宽、退格在 (293,610)、键盘底沿 844 = 屏幕下沿。

**键盘**(沿用上一版的功能设计,只换皮)。4 列 × 4 行:最右列上面退格、下面「付款」
纵跨三行;「0」横跨两格。白键浮在同色底上、彼此留缝,不是出血的表格线。
退格是内联 SVG,不用 `⌫` 那个码位(部分安卓字体里会掉成方框)。

- 顾客因此**不必再点输入框**:`inputmode="none"` 让点它也不弹系统键盘(桌面上的实体键盘
  照旧能打字),且进页面**不再自动聚焦** —— 那个聚焦只会在手机上弹出一块软键盘,
  正好盖住刚画好的键盘。
- 过滤与上限**只有 `onInput` 一处**:`press()` 只把字符接上去再调它。两处各写一份,
  迟早出现「物理键盘打不进去、点键盘能打进去」这类只在一种输入下成立的缺陷。
- 金额用 `font-variant-numeric: tabular-nums`(**不是**等宽字体栈):位数变化时字宽不变,
  光标不跳。等宽字体是上一版那个「收据」比喻带进来的。
- 整数位上限 7 位(999 万元)与「去前导零」照旧,都只写在 `onInput`。
- 校验没过时金额行抖一下,`shaking` 只由 `reject()` 置真、由 `onInput()` 置假 ——
  同一个类连着挂两次 CSS 动画不会重放,所以既不重挂也不计时。提示那句(键盘正上方)
  同时转红;**只红金额与提示,键盘不参与报错**。

**用户报的边界:先按 `.` 再按数字,应当直接变成小数。**
旧行为是金额行上顶着一个光秃秃的 `.`,再按数字得 `.4`。成因在 `onInput` 的形状过滤
`^(\d*\.?\d{0,2})` 允许省略整数位,而 `yuanToFen` 也**确实**吃得下 `.5`(所以这是个
显示问题、不是算错)。修法是在同一条链末尾补一句 `.replace(/^\./, "0.")`:按 `.` 得 `0.`,
再按数字得 `0.4`。修在过滤链里,物理键盘一起受益。顺带把那句「容忍省略整数位的 .5」的
注释改准 —— 补零之后那条**不再从界面来**,只对直接调用本函数的代码成立。

**宽屏**:这一屏真正的入口是扫码,顾客一定在手机上;但商户自己会拿电脑点开这条链接试,
不管的话 1440 下键盘就铺满整屏(实测键宽 350px)。整页收成一根 480 的手机宽柱居中
(`align-items:center` + 直接子块 `width:100%;max-width:480px`)。
柱子给**子块**而不是给 `.checkout` 自己,页面底色才是出血的,否则两侧会留一道白边;
`.redirect` 用 `:not()` 排除在外(它是 `position:fixed` 盖满屏的,收窄就废了)。

验证:

- `node _verify-checkout/title.mjs` —— **4 通过 / 0 失败**。这次先炸了一回:
  `TypeError: object null is not iterable`。查下来不是模板错 —— compiler-dom 的
  `stringifyStatic` 把**连续的 ≥5 个带属性静态元素**串成一段 HTML(`ec >= 5`),
  这一版正好有两处够格:加载态骨架(`header.panel` + 3 个骨架格 + `div.pad-skeleton`)、
  无效态文案(`header.panel>h1` + `div.notice` 两个 `p`)。编译产物里 `createStaticVNode`
  两处、残留表达式占位符 0 个 ⇒ 串化内容与模板逐字一致,是**台子少实现了宿主钩子**
  (`insertStaticContent` 桩成 `() => null`,而契约要求返回 `[首节点, 末节点]`)。
  给台子按契约补上(整段当一个不透明节点;每次都新建,不回用传进来的 start ——
  真 DOM 在这里是克隆)。
  两向变异:把钩子改回 `() => null` ⇒ 复现原来那条崩溃(一条断言都没跑到);
  还原 ⇒ 逐字节相同并回到 4/0。
- `pnpm ts:check` —— 289 条,与改动前**同数**(该命令恒 exit 2,基线非零),
  `views/checkout/index.vue` 0 条。
- `pnpm build` 通过;在真产物上预览(`_verify-checkout/preview-server.mjs` 同时吐 `dist/`
  与开屏接口 —— 生产构建里 `VITE_APP_BASE_API=/api/v1` 是同源相对路径),Playwright
  真点键,390×844:

  | 动作 | 读数 |
  |---|---|
  | 点 `1` `2` `3` | `123` |
  | 点退格 | `12` |
  | 连点 `0` 八次 | `1200000`(整数位 7 位上限挡住第 8 位) |
  | 空金额点 `0`、再点 `0` | `0` → `0`(前导零不累积) |
  | 点 `.` | **`0.`**(旧版是一个光秃秃的 `.`) |
  | 再点 `4`、`1` | `0.4` → `0.41`(旧版是 `.4`) |
  | 再点 `.`、再点 `5` | `0.41` → `0.41`(第二个点与第三位小数都被吃掉) |
  | 退格三次 | `0.41` → `0` |
  | 点 `.` `0` `0` | `0.00` |
  | 按「付款」(0.41 元) | 提示「收款金额最低 1 元」,金额与提示同时转 `#e34d59`,**POST 计数 0** |
  | 再点 `4` | `0.04`,提示回到「最低 1 元」、颜色复原 |
  | 空金额按「付款」 | 提示「请输入正确的金额,最多两位小数」,**POST 计数 0** |
  | 点 `3` `8` `.` `5` `0` 后按「付款」 | `POST /payment/checkout/<token>/orders` 发出;遮罩「正在前往支付页面,应付 **¥38.50**」+ 订单号回显 |

  布局(390×844):`scrollWidth 390`(= 视口,无横向溢出)、`scrollHeight 844`(不滚动)、
  `header.panel` 0,0,390,147、`.pad` 0,602,390,242。
  宽屏 1440×900:柱 480 居中(panel x=480 w=480),键 112px,`scrollWidth 1440` 无溢出;
  **390 下一个像素都没变** —— 先往浏览器注入候选 CSS 量过(panel/pad/键位逐格相同),
  再改源码,改完复量一致。
  遮罩在 1440 下 `position:fixed` 铺满 1440×900(`> *:not(.redirect)` 的排除成立)。
  另:加载态骨架与无效态文案这两段串化内容在浏览器里都按正确的类名与尺寸渲染出来了。

不含后端改动 ⇒ **无需重打 jar**,只是前端产物。但部署前仍须先执行
`java/src/main/resources/db/lsy-checkout-link.sql`(建表 + 菜单/权限串,无此表顾客页直接
起不来),执行后需重登。
alphaH 2 weeks ago
parent
commit
1a719c9a0b
1 changed files with 476 additions and 203 deletions
  1. 476 203
      frontend/src/views/checkout/index.vue

+ 476 - 203
frontend/src/views/checkout/index.vue

@@ -1,67 +1,112 @@
 <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>
+    <!-- ① 载入中:占位的形状照着真内容量(商户名 / 标签 / 金额 / 键盘块),不给任何文案
+         —— 顾客看到的第一次「有内容」必须是真内容 -->
+    <template v-if="phase === 'loading'">
+      <header class="panel">
+        <div class="skeleton skeleton--name"></div>
+        <div class="skeleton skeleton--label"></div>
+        <div class="skeleton skeleton--amount"></div>
+      </header>
+      <div class="pad-skeleton"></div>
+    </template>
+
+    <!-- ② 链接不可用:未知 / 空 / 已停用**共用同一句**(服务端就是这么回的,
+         不告诉打开链接的人「这条链接存在但被停用」)。这一步**没有任何操作**可做,
+         所以不放按钮 —— 放一个点了没用的按钮比不放更糟。 -->
+    <template v-else-if="phase === 'invalid'">
+      <header class="panel">
+        <h1 class="panel__name">收款</h1>
+      </header>
+      <div class="notice">
+        <p class="notice__title">收款链接无效或已停用</p>
+        <p class="notice__hint">请向收款方重新获取链接,或直接扫描对方出示的收款码。</p>
       </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 }">
+    </template>
+
+    <!-- ③ 正常:商户名 + 金额 + 键盘(最右那一格就是「付款」)。
+         顾客第一眼要确认的是「钱付给谁」,所以商户名排在最上面一行。 -->
+    <template v-else>
+      <header class="panel">
+        <!-- ★ 商户名可能为 null(发链接时那一行没填名字)⇒ 用兜底标题,
+             不显示一个空白标题。文案取自后端 LsyCheckoutInfoVO 的注释。 -->
+        <h1 class="panel__name">{{ merchantName || "收款" }}</h1>
+        <p class="panel__label">金额</p>
+
+        <!-- 金额那行同时是**显示**:顾客不需要点它。`inputmode="none"` —— 点它也不弹系统
+             键盘(键盘就在下面),而桌面上的实体键盘照样能直接打字。 -->
+        <div class="amount" :class="{ 'amount--error': !!hint, 'amount--shake': shaking }">
           <span class="amount__symbol">¥</span>
           <input
             ref="amountEl"
             v-model="amountText"
             class="amount__input"
             type="text"
-            inputmode="decimal"
+            inputmode="none"
             autocomplete="off"
-            placeholder="0.00"
+            aria-label="付款金额(元)"
+            placeholder="请输入金额"
             :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 ? "正在发起支付…" : "确认付款" }}
+      </header>
+
+      <!-- 垫在中间的那块空白:把键盘压到屏幕下沿,金额与键盘之间不插别的东西 -->
+      <div class="spacer"></div>
+
+      <p class="hint" :class="{ 'hint--error': !!hint }">
+        {{ hint || `最低 ${MIN_FEN / 100} 元` }}
+      </p>
+
+      <!-- 键盘:4 列,最右一列上面是退格、下面是纵跨三行的「付款」。
+           ★ 付款键**长在键盘里**,不再另起一个通栏按钮 —— 顾客本来就要做两件事
+             (输金额、付款),两件事就长在同一块板上,中间不插第二个可点的东西。
+           ★ 「0」横跨两格:这是国内收银台键盘的固定形状,照做即可。 -->
+      <div class="pad" role="group" aria-label="金额键盘">
+        <button
+          v-for="k in PAD_KEYS"
+          :key="k.key"
+          type="button"
+          class="pad__key"
+          :class="{ 'pad__key--pay': k.pay, 'pad__key--wide': k.wide }"
+          :disabled="submitting || redirecting"
+          :aria-label="
+            k.key === BACKSPACE ? '退格' : k.pay ? '付款' : k.key === '.' ? '小数点' : k.key
+          "
+          @click="press(k.key)"
+        >
+          <!-- 退格用内联 SVG,不用 `⌫` 那个码位:它在部分安卓字体里会掉成方框 -->
+          <svg v-if="k.key === BACKSPACE" class="pad__icon" viewBox="0 0 24 24" aria-hidden="true">
+            <path
+              d="M9 5h9a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-5.6-7z"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.7"
+              stroke-linejoin="round"
+            />
+            <path
+              d="M11.4 9.6l4.8 4.8m0-4.8l-4.8 4.8"
+              fill="none"
+              stroke="currentColor"
+              stroke-width="1.7"
+              stroke-linecap="round"
+            />
+          </svg>
+          <template v-else>{{ k.pay ? (submitting ? "…" : "付款") : k.key }}</template>
         </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>
+    </template>
+
+    <!-- 跳转中:盖满整屏。顾客此刻已经不该再碰键盘了 —— 键本来就 disabled,
+         再留一个看得见的键盘只会诱他去点。顾客还能看到「付多少」,
+         跳走之后这页就不归我们管了。 -->
+    <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>
-
-    <p class="checkout__foot">本页由收款方通过支付平台生成</p>
   </div>
 </template>
 
@@ -144,19 +189,109 @@ const amountText = ref("");
 const hint = ref("");
 const submitting = ref(false);
 const redirecting = ref(false);
+const shaking = ref(false);
 const paidFen = ref(0);
 const paidOutTradeNo = ref("");
 const amountEl = ref<HTMLInputElement>();
 
 // ────────────────────────────── 金额 ──────────────────────────────
 
-/** 输入过滤:只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错)。 */
+/**
+ * 键盘的键序与摆位。`BACKSPACE` / `PAY` 是两种**非数字**键,各自单独给一个值,
+ * 模板据此换成图标或文案;`wide` / `pay` 只影响栅格占位(见 .pad)。
+ *
+ * ★ 这两样(`0` 横跨两格、`付款` 纵跨最右三行)不是排版偏好,是国内收银台键盘的
+ *   固定形状 —— 形状对了,顾客不用读文案就知道怎么按。
+ */
+const BACKSPACE = "back";
+const PAY = "pay";
+
+interface PadKey {
+  key: string;
+  /** 「0」横跨两格 */
+  wide?: boolean;
+  /** 纵跨最右三行的「付款」键 */
+  pay?: boolean;
+}
+
+const PAD_KEYS: PadKey[] = [
+  { key: "1" },
+  { key: "2" },
+  { key: "3" },
+  { key: BACKSPACE },
+  { key: "4" },
+  { key: "5" },
+  { key: "6" },
+  { key: PAY, pay: true },
+  { key: "7" },
+  { key: "8" },
+  { key: "9" },
+  { key: "0", wide: true },
+  { key: "." },
+];
+
+/**
+ * 整数位上限。**键盘让这件事变成必然**:按住一个键不放,位数会一直被推上去,
+ * 而金额那行的字号是跟视口放大的 —— 位数一多,那行字就顶出纸面(输入框自己会横向滚,
+ * 顾客看到的是一个被裁掉头部的金额)。
+ * 7 位 = 999 万元,够任何线下收款;这个数还与 `--mono` 的字宽对得上(见 .amount__input)。
+ * 上限只写在 `onInput` 这一处 —— 纸键盘与实体键盘共用同一条过滤。
+ */
+const MAX_INT_DIGITS = 7;
+
+/**
+ * 输入过滤:纸键盘与物理键盘**唯一**的入口。
+ * 只留数字与一个小数点,小数位最多两位(多打的那一位直接不显示,而不是事后报错);
+ * 顺手去掉多余的整数前导零("05" → "5"),免得金额行印出个 `05`;
+ * 再把开头那一个孤零零的小数点补成 `0.` —— 顾客先按 `.` 再按数字,要的是 `0.5` 而不是 `.5`
+ * (`yuanToFen` 本来就吃得下 `.5`,但金额行上顶着一个光秃秃的 `.` 是不对的)。
+ */
 function onInput() {
   const cleaned = amountText.value
     .replace(/[^\d.]/g, "")
-    .replace(/^(\d*\.?\d{0,2}).*$/, "$1");
-  amountText.value = cleaned;
+    .replace(/^(\d*\.?\d{0,2}).*$/, "$1")
+    .replace(/^0+(?=\d)/, "")
+    .replace(/^\./, "0.");
+  const [intPart = "", decPart] = cleaned.split(".");
+  amountText.value =
+    intPart.length > MAX_INT_DIGITS
+      ? intPart.slice(0, MAX_INT_DIGITS) + (decPart === undefined ? "" : `.${decPart}`)
+      : cleaned;
   hint.value = "";
+  // 顾客一动键盘,上一条错误提示的手感就结束了(见 reject 的注释)
+  shaking.value = false;
+}
+
+/**
+ * 键盘按下了一格:数字键接一位、退格键删一位、付款键就是提交。
+ *
+ * ★ 过滤与上限**只有 `onInput` 一处** —— 两边各写一份,迟早出现
+ *   「物理键盘打不进去、点键盘能打进去」这类只在一种输入下成立的缺陷。
+ */
+function press(key: string) {
+  if (submitting.value || redirecting.value) {
+    return;
+  }
+  if (key === PAY) {
+    submit();
+    return;
+  }
+  amountText.value =
+    key === BACKSPACE ? amountText.value.slice(0, -1) : amountText.value + key;
+  onInput();
+}
+
+/**
+ * 校验没过:把话说在页面上,并让金额行抖一下。
+ *
+ * ★ 「抖」不能靠每次失败都重挂一次 CSS 动画(同一个类连着挂两次,动画不会重放)——
+ *   这里只负责**置真**,由 `onInput()`(顾客的下一次动作)置假:顾客不动它就一直为真,
+ *   也不会再抖第二下,正好。
+ */
+function reject(message: string) {
+  hint.value = message;
+  shaking.value = true;
+  amountEl.value?.focus();
 }
 
 /**
@@ -170,8 +305,10 @@ function onInput() {
  * @returns 分;`null` = 输入不是合法金额(空、多个小数点、超过两位小数)
  */
 function yuanToFen(text: string): number | null {
-  // 容忍两种「打到一半」的输入,不把它们当错误:末尾一个小数点("5.")、
-  // 省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了)。
+  // 容忍「打到一半」的输入,不把它当错误:末尾只挂一个小数点("5.")。
+  // 也吃省略整数位的 ".5"(`Number("")` 是 0,所以拆分那一步天然处理得了),
+  // 但那条**不来自界面** —— `onInput` 会把开头的小数点补成 `0.`,只有直接调用本函数的
+  // 代码才可能递进来。留着它是因为这里本来就是个纯函数,不必依赖调用方的形态。
   const s = text.trim().replace(/\.$/, "");
   if (!/^(\d+(\.\d{1,2})?|\.\d{1,2})$/.test(s)) {
     return null;
@@ -201,8 +338,9 @@ async function loadInfo() {
     }
     merchantName.value = res.data.data?.merchant_name ?? null;
     phase.value = "ready";
-    // 键盘弹不弹由浏览器决定(iOS 上非用户手势触发时会被忽略,那不是错误)
-    amountEl.value?.focus();
+    // ★ 这里**故意不**自动聚焦金额框:键盘已经印在纸上了,自动聚焦只会在手机上
+    //   弹出一块系统软键盘,正好盖住刚画好的那半张纸。
+    //   (聚焦还剩一个用途:校验没过时把光标放回去,好让桌面上的实体键盘能接着打字。)
   } catch {
     // ★ 网络不通也归到 `invalid` 是**有意的取舍**:这条腿的失败里,
     //   「链接坏了」远多于「网络坏了」,而顾客对两者的反应是同一个动作
@@ -219,13 +357,11 @@ async function submit() {
   }
   const fen = yuanToFen(amountText.value);
   if (fen === null) {
-    hint.value = "请输入正确的金额,最多两位小数";
-    amountEl.value?.focus();
+    reject("请输入正确的金额,最多两位小数");
     return;
   }
   if (fen < MIN_FEN) {
-    hint.value = `收款金额最低 ${MIN_FEN / 100} 元`;
-    amountEl.value?.focus();
+    reject(`收款金额最低 ${MIN_FEN / 100} 元`);
     return;
   }
 
@@ -287,211 +423,250 @@ onUnmounted(() => {
 </script>
 
 <style lang="scss" scoped>
-/* 移动优先:这一屏绝大多数时候出现在手机上。桌面端只是把它居中,不做第二套布局。 */
+/* ══════════════════════════════════════════════════════════════════════
+   顾客页的视觉参照是**国内收银台**(微信/支付宝那一路的扫码付款页)。
+
+   2026-09-30 按用户给的码实扫了一页当基准(`https://p.eycard.cn/p?qr=rE3Erq`,
+   要用微信内置浏览器的 UA 才给渲染),390×844 下量到的读数:
+     · 底色 rgba(0,0,0,.02);商户名 16.6px/700 #333
+     · 「金额」标签 14.6px/700;¥ 27px/700;占位文字 30px/500 #dedede
+     · 金额数字 35.4px/700;键 87×52、圆角 5.2px、白底
+     · 键投影 0 2px 2px rgb(0 0 0 / 18%);键上的数字 20.8px/700 #000
+     · 「付款」键 87×166 —— 纵跨最右三行;「0」横跨两格;退格在右上角
+
+   前几版走的是「一张收据」:深桌面、撕口、条码、编号、作废印章。那些都是**比喻**,
+   每个比喻都要额外画一样东西,加起来就成了「设计过的海报」,而不是一个收银台。
+   这一版照抄参照页的是**比例与克制**,不是它的配色:主色仍是我们自己的 --brand。
+   ══════════════════════════════════════════════════════════════════════ */
 .checkout {
+  --brand: #4080ff;      /* = settings.ts 的 themeColor。整页只有「付款」键用它 */
+  --ink: #333;           /* 主文字(照参照页量的 #333,不是纯黑) */
+  --ink-soft: #8a8f99;   /* 次要文字:提示行 */
+  --placeholder: #dedede;
+  --page: #f2f3f5;       /* 底。参照页是 rgba(0,0,0,.02)≈#fafafa,这里压深一点:
+                            白块压在 #fafafa 上几乎分不出边界 */
+  --danger: #e34d59;     /* 校验失败。与后台 Element Plus 的 danger 同色 */
+  --key-shadow: 0 2px 2px rgb(0 0 0 / 18%), 0 1px 1px rgb(0 0 0 / 16%);
+
+  /* ★ 宽度必须在这里定死,不能留给内容去决定。祖先链 html/body/#app 三层**全是
+     display:flex** ⇒ 这一层是个 flex 项,而 flex 项的 min-width:auto 落在 min-content 上:
+     内容一宽就把它顶出去,在 390 的手机上实测 scrollWidth 410(右侧半个按钮在屏幕外)。
+     width:100% 给定尺(这个页面本来就该铺满),min-width:0 允许它跟着容器收缩。 */
+  width: 100%;
+  min-width: 0;
   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;
-}
+  /* 中文用系统字体 —— 顾客多半在移动网络下,不引外链字体 */
+  font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
+    "Microsoft YaHei", "Noto Sans SC", sans-serif;
+  color: var(--ink);
+  background: var(--page);
+
+  /* ── 宽屏:整页收成一根手机宽的柱子居中 ──
+     这一屏真正的入口是扫码,顾客一定在手机上;但**商户自己会拿电脑点开这条链接试**,
+     而不管的话 1440 下键盘就铺满整屏(实测键宽 350px),一眼就是「没管过桌面」。
+     柱子宽度取 480:比任何手机的竖屏都宽,所以 390~430 这一段**一个像素都不变**
+     (实测 390 下 panel/pad/键 的框与未加这段时逐格相同,scrollWidth 仍是 390)。
+     祖先链 html/body/#app 全是 flex ⇒ 本层是 flex 项,align-items:center 是它的交叉轴对齐,
+     子项横向居中;柱子给子项而不是给自己,页面底色才是出血的(自己收窄会在两侧留一道白边)。 */
+  align-items: center;
 
-@keyframes shimmer {
-  50% {
-    opacity: 0.45;
+  > *:not(.redirect) {
+    width: 100%;
+    max-width: 480px;
   }
 }
 
-/* ── 链接不可用 ── */
-.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;
+/* ── 顶部信息块:商户名 +「金额」+ 金额行,三样压在同一块白底上 ──
+   参照页也是这么排的:商户名下面直接跟「金额」标签,中间不加分隔线。 */
+.panel {
+  padding: 14px 16px 18px;
+  background: #fff;
 }
 
-.state__hint {
+.panel__name {
   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;
+  font-size: 17px;
+  font-weight: 700;
+  line-height: 1.35;
+  word-break: break-all;
 }
 
-.head__name {
-  margin: 0;
-  font-size: 21px;
-  font-weight: 600;
-  line-height: 1.4;
-  color: #1d2939;
-  word-break: break-all;
+.panel__label {
+  margin: 18px 0 0;
+  font-size: 15px;
+  font-weight: 700;
 }
 
-/* ── 金额 ── */
+/* ── 金额行:¥ 小一号、数字大,左对齐 ── */
 .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;
+  align-items: center;
+  gap: 8px;
+  margin-top: 6px;
+  min-height: 46px;
 }
 
 .amount__symbol {
   font-size: 26px;
-  font-weight: 600;
-  color: #1d2939;
+  font-weight: 700;
 }
 
+/* ★ 数字用 tabular-nums(**不是**等宽字体栈):位数变化时字宽不变,光标不跳。
+   参照页的金额也是系统字体加粗 —— 等宽字体是前几版「收据」那个比喻带进来的。 */
 .amount__input {
   flex: 1;
   min-width: 0;
   padding: 0;
   font-family: inherit;
-  font-size: 40px;
-  font-weight: 600;
-  color: #1d2939;
+  font-size: 34px;
+  font-weight: 700;
+  color: var(--ink);
+  font-variant-numeric: tabular-nums;
   background: transparent;
   border: none;
   outline: none;
 
+  /* 占位比真值小一号、细一档 —— 参照页就是这么分的(30px/500 对 35px/700) */
   &::placeholder {
-    font-weight: 400;
-    color: #d0d5dd;
+    font-size: 28px;
+    font-weight: 500;
+    color: var(--placeholder);
   }
 
-  /* 数字等宽 + 右对齐的观感:金额从左往右打,光标不跳 */
-  font-variant-numeric: tabular-nums;
+  /* iOS 上 disabled 会把字压成灰的,而这一格是顾客刚输入的金额,不许变淡 */
+  &:disabled {
+    color: var(--ink);
+    -webkit-text-fill-color: var(--ink);
+    opacity: 1;
+  }
+}
+
+/* 校验没过:¥ 与金额一起变红。只红这一行 —— 键盘不参与报错。 */
+.amount--error .amount__symbol,
+.amount--error .amount__input {
+  color: var(--danger);
+}
+
+/* 垫在中间的那块空白:把键盘压到屏幕下沿 */
+.spacer {
+  flex: 1;
+  min-height: 16px;
 }
 
+/* 提示行:安静时是「最低 1 元」,出错时是被拒的原因。
+   位置就在键盘正上方 —— 与参照页那行品牌字同格,顾客的眼从键盘往上一格就能读到。 */
 .hint {
-  min-height: 20px;
-  margin: 10px 0 22px;
-  font-size: 12px;
-  color: #98a2b3;
+  min-height: 19px;
+  margin: 0 16px 10px;
+  font-size: 13px;
+  text-align: center;
+  color: var(--ink-soft);
 }
 
 .hint--error {
-  color: #f04438;
+  color: var(--danger);
 }
 
-/* ── 按钮 ── */
-.pay {
-  width: 100%;
-  height: 50px;
+/* ── 键盘:4 列 × 4 行,最右一列上面退格、下面「付款」 ──
+   ★ 白键压在同色底上、彼此留缝:键是**浮起来的卡片**,不是格线。
+   前几版画的是出血的表格线,那是「印在纸上的表」那个比喻带来的;
+   国内收银台一律是这种方块键。 */
+.pad {
+  display: grid;
+  grid-template-columns: repeat(4, 1fr);
+  grid-auto-rows: 52px;
+  gap: 6px;
+  padding: 8px 8px calc(8px + env(safe-area-inset-bottom));
+}
+
+.pad__key {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 0;
   font-family: inherit;
-  font-size: 17px;
-  font-weight: 600;
-  color: #fff;
+  font-size: 21px;
+  font-weight: 700;
+  color: #000;
   cursor: pointer;
-  background: #4080ff;
+  background: #fff;
   border: none;
-  border-radius: 12px;
-  transition:
-    background 0.2s,
-    transform 0.1s;
+  border-radius: 5px;
+  box-shadow: var(--key-shadow);
+  /* 触摸端:去掉系统那层灰色高光(它会在键上留一块脏印)、去掉长按选中,
+     也去掉双击缩放带来的那 300ms 迟疑 */
+  -webkit-tap-highlight-color: transparent;
+  touch-action: manipulation;
+  user-select: none;
+  transition: background 0.12s;
 
   &:active:not(:disabled) {
-    transform: scale(0.99);
+    background: #f0f0f0;
   }
 
   &:disabled {
     cursor: default;
-    opacity: 0.6;
+    opacity: 0.5;
   }
+
+  &:focus-visible {
+    outline: 2px solid var(--brand);
+    outline-offset: -2px;
+  }
+}
+
+.pad__key--wide {
+  grid-column: span 2;
 }
 
-/* ── 跳转中 ── */
+/* 「付款」:纵跨最右三行。整页唯一的亮色块,也是唯一一个非白键。 */
+.pad__key--pay {
+  grid-column: 4;
+  grid-row: 2 / span 3;
+  font-size: 19px;
+  color: #fff;
+  background: var(--brand);
+
+  /* 禁用态不用 opacity:蓝底压到 50% 会变成一块脏灰蓝。参照页的做法是换成同一个色的
+     浅版(它那个绿按钮禁用时就是浅绿)。 */
+  &:disabled {
+    background: #a8c4ff;
+    opacity: 1;
+  }
+
+  &:active:not(:disabled) {
+    background: #3572e6;
+  }
+}
+
+.pad__icon {
+  width: 24px;
+  height: 24px;
+  color: #333;
+}
+
+/* ── 跳转中:盖满整屏 ── */
 .redirect {
-  margin-top: 20px;
+  position: fixed;
+  inset: 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 0 32px;
   text-align: center;
+  background: #fff;
 }
 
 .redirect__spinner {
   width: 22px;
   height: 22px;
-  margin: 0 auto 10px;
-  border: 2px solid #d1e0ff;
-  border-top-color: #4080ff;
+  margin-bottom: 12px;
+  border: 2px solid #d8e4ff;
+  border-top-color: var(--brand);
   border-radius: 50%;
   animation: spin 0.8s linear infinite;
 }
@@ -505,13 +680,111 @@ onUnmounted(() => {
 .redirect__text {
   margin: 0;
   font-size: 14px;
-  color: #475467;
+  color: var(--ink);
 }
 
 .redirect__no {
-  margin: 6px 0 0;
+  margin: 8px 0 0;
   font-size: 12px;
-  color: #98a2b3;
+  color: var(--ink-soft);
   word-break: break-all;
 }
+
+/* ── 链接不可用:一句话 + 一条可照做的建议。没有别的可点 ── */
+.notice {
+  margin: 40px 24px 0;
+  text-align: center;
+}
+
+.notice__title {
+  margin: 0 0 10px;
+  font-size: 17px;
+  font-weight: 600;
+}
+
+.notice__hint {
+  margin: 0;
+  font-size: 13px;
+  line-height: 1.7;
+  color: var(--ink-soft);
+}
+
+/* ── 载入占位:形状照着真内容量(名字 / 标签 / 金额 / 键盘块)。
+     占位与真内容差得远的话,回复一到整页会跳一下。 ── */
+.skeleton {
+  background: #eef0f2;
+  border-radius: 3px;
+  animation: shimmer 1.6s ease-in-out infinite;
+}
+
+.skeleton--name {
+  width: 52%;
+  height: 17px;
+}
+
+.skeleton--label {
+  width: 12%;
+  height: 15px;
+  margin-top: 18px;
+}
+
+.skeleton--amount {
+  width: 42%;
+  height: 34px;
+  margin-top: 10px;
+}
+
+/* 键盘那块占位压在屏幕下沿(margin-top:auto)。高度对齐的是**键区**、不是整个键盘块:
+   4×52 + 3×6 = 226,正好是真键盘第一排键的上沿到最后一排键的下沿(390×844 实测
+   骨架 610→836,真键盘的键 610→836)。键盘块自己的上下各 8px 是贴着页面底色的留白,
+   占位不该连它一起画 —— 那样两边会差 16px。 */
+.pad-skeleton {
+  height: 226px;
+  margin: auto 8px calc(8px + env(safe-area-inset-bottom));
+  background: #e9ebee;
+  border-radius: 5px;
+  animation: shimmer 1.6s ease-in-out infinite;
+}
+
+@keyframes shimmer {
+  50% {
+    opacity: 0.5;
+  }
+}
+
+/* 校验没过:金额行左右抖一下。用动画而不是 JS 逐帧 —— 它在 reduced-motion 下被一并
+   关掉,而信息不丢:那时金额变红、提示那句照样写在键盘上方。 */
+@keyframes shake {
+  20% {
+    transform: translateX(-5px);
+  }
+
+  45% {
+    transform: translateX(4px);
+  }
+
+  70% {
+    transform: translateX(-3px);
+  }
+
+  90% {
+    transform: translateX(2px);
+  }
+}
+
+.amount--shake {
+  animation: shake 0.36s cubic-bezier(0.36, 0.07, 0.19, 0.97);
+}
+
+/* ★ 减少动效:只关掉动的东西,元素停在**静态形态**(占位是满不透明的),不是消失
+   —— 这一点靠的是动画只写在关键帧里,而不是另写一套覆盖规则。 */
+@media (prefers-reduced-motion: reduce) {
+  .amount--shake,
+  .skeleton,
+  .pad-skeleton,
+  .redirect__spinner {
+    animation: none;
+  }
+}
 </style>
+