|
|
@@ -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>
|
|
|
+
|