fix(web): iPhone 键盘收起后顶栏复位,积分块与盘面芯片同高
Independent Staging Quality Gate / validate (push) Successful in 11m21s
Independent Staging Quality Gate / publish (push) Successful in 10m46s

This commit is contained in:
jesse-ux
2026-09-17 13:13:39 +08:00
parent 77e3626a5c
commit 1b508d5e9a
11 changed files with 345 additions and 10 deletions
+50 -8
View File
@@ -882,7 +882,51 @@ button:disabled { cursor: default; opacity: .45; }
content: "";
}
.chat-header-actions button > svg { width: 14px; height: 14px; }
.credit-button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2); padding: 0 var(--space-3); cursor: pointer; font-size: var(--type-caption); font-variant-numeric: tabular-nums; transition: background-color 120ms ease-out, transform 120ms ease-out; min-width: 64px; border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); color: var(--color-ink-secondary); font-weight: 500; }
/* Header chips share one size. `.chat-header-actions button` is 32px and
more specific than `.credit-button`, which is why the credit pill looked
shorter than 「当前盘面」(BUG-921). */
.chat-header-actions .credit-button,
.chat-header-rectification .rectification-board-peek {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
height: 40px;
min-height: 40px;
padding: 0 var(--space-3);
gap: var(--space-2);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
font-weight: 500;
font-variant-numeric: tabular-nums;
cursor: pointer;
transition: background-color 120ms ease-out, transform 120ms ease-out;
}
.chat-header-actions .credit-button::before,
.chat-header-rectification .rectification-board-peek::before {
position: absolute;
inset: -2px;
content: "";
}
.credit-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 0 var(--space-3);
cursor: pointer;
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
transition: background-color 120ms ease-out, transform 120ms ease-out;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas-soft);
color: var(--color-ink-secondary);
font-weight: 500;
}
.conversation { min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding-bottom: var(--composer-reserve); background: var(--color-canvas); }
/* Empty state. The greeting is bottom-aligned inside the conversation row and
@@ -1892,8 +1936,9 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
[data-sidebar="sidebar"][data-mobile-open="true"] { visibility: visible; transform: translateX(0); }
.sidebar-scrim { position: fixed; z-index: 11; inset: 0; display: block; border: 0; background: var(--color-scrim); opacity: 1; visibility: visible; }
[data-sidebar="rail"] { display: none; }
.chat-panel { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: calc(64px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto; }
.chat-panel.is-rectification { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr); }
.chat-panel:has(.starter-balance) { grid-template-rows: calc(52px + env(safe-area-inset-top)) minmax(0, 1fr) auto minmax(0, 1fr); }
.chat-header { gap: var(--space-3); padding: max(0px, env(safe-area-inset-top)) var(--space-3) 0; }
.conversation.is-empty {
display: block;
@@ -2962,15 +3007,10 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
display: none;
}
.chat-header-rectification .rectification-board-peek {
display: inline-flex;
align-items: center;
justify-content: flex-end;
width: auto;
max-width: min(11.5rem, 42vw);
min-height: 44px;
margin: 0;
padding: 0 var(--space-3);
gap: var(--space-2);
}
.chat-header-rectification .rectification-board-peek strong,
.chat-header-rectification .rectification-board-peek span {
@@ -2979,6 +3019,8 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: inherit;
font-variant-numeric: tabular-nums;
}
@media (max-width: 480px) {
.chat-header-rectification .rectification-board-peek span {
+2
View File
@@ -3,6 +3,7 @@ import localFont from "next/font/local";
import Script from "next/script";
import { Toaster } from "@/components/ui/sonner";
import { StaleClientRecovery } from "@/components/stale-client-recovery";
import { ViewportScrollLock } from "@/components/viewport-scroll-lock";
import { themePreferenceBootScript } from "@/lib/theme-preference";
// Vendored Inter latin variable (OFL). Do not switch back to next/font/google;
@@ -53,6 +54,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
</head>
<body>
<StaleClientRecovery />
<ViewportScrollLock />
{children}
<Toaster />
</body>
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { installViewportScrollLock } from "@/lib/viewport-scroll-lock";
export function ViewportScrollLock() {
useEffect(() => {
return installViewportScrollLock(window as unknown as Parameters<typeof installViewportScrollLock>[0]);
}, []);
return null;
}
+85
View File
@@ -0,0 +1,85 @@
/**
* iOS Safari scrolls `window` to keep a focused input in view. `html, body
* { overflow: hidden }` then blocks the user from dragging back, and reload
* restores that offset. Reset window scroll only after the keyboard is gone.
*/
export const KEYBOARD_OPEN_GAP_PX = 100;
export const KEYBOARD_CLOSED_SLACK_PX = 1;
export function isKeyboardOpen(visualHeight: number, innerHeight: number): boolean {
return visualHeight < innerHeight - KEYBOARD_OPEN_GAP_PX;
}
export function shouldResetWindowScroll(input: {
scrollY: number;
innerHeight: number;
visualViewportHeight: number | null;
}): boolean {
if (input.scrollY <= 0) return false;
if (input.visualViewportHeight == null) return true;
if (isKeyboardOpen(input.visualViewportHeight, input.innerHeight)) return false;
return input.visualViewportHeight >= input.innerHeight - KEYBOARD_CLOSED_SLACK_PX;
}
type ViewportLike = {
height: number;
addEventListener: (type: "resize" | "scroll", listener: () => void) => void;
removeEventListener: (type: "resize" | "scroll", listener: () => void) => void;
};
export type ViewportScrollLockHost = {
matchMedia?: (query: string) => { matches: boolean };
visualViewport?: ViewportLike | null;
innerHeight: number;
scrollY: number;
scrollTo: (x: number, y: number) => void;
history: { scrollRestoration: string };
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
document: {
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
};
setTimeout?: (fn: () => void, ms: number) => unknown;
};
export function installViewportScrollLock(host: ViewportScrollLockHost): () => void {
const coarse = host.matchMedia?.("(pointer: coarse)").matches === true;
const viewport = host.visualViewport ?? null;
if (!coarse && !viewport) return () => undefined;
host.history.scrollRestoration = "manual";
host.scrollTo(0, 0);
const resetIfSettled = () => {
if (!shouldResetWindowScroll({
scrollY: host.scrollY,
innerHeight: host.innerHeight,
visualViewportHeight: viewport?.height ?? null,
})) return;
host.scrollTo(0, 0);
};
const onPageShow = () => {
host.scrollTo(0, 0);
};
const onFocusOut = () => {
const later = host.setTimeout ?? ((fn: () => void) => fn());
later(resetIfSettled, 300);
};
viewport?.addEventListener("resize", resetIfSettled);
viewport?.addEventListener("scroll", resetIfSettled);
host.addEventListener("pageshow", onPageShow);
host.addEventListener("orientationchange", resetIfSettled);
host.document.addEventListener("focusout", onFocusOut);
return () => {
viewport?.removeEventListener("resize", resetIfSettled);
viewport?.removeEventListener("scroll", resetIfSettled);
host.removeEventListener("pageshow", onPageShow);
host.removeEventListener("orientationchange", resetIfSettled);
host.document.removeEventListener("focusout", onFocusOut);
};
}