feat(web): 设计规范与 uhd_web 同步(截至 2026-10-08)
- 材质统一边光:slab / float / pill / pill-selected / seg-thumb / chrome-side 改为左上亮右下暗的 1px 边光 + 外侧暗线;新增 lens / field / chip 参数与 pg-float-modal - 主按钮:转动光谱环改为静态左上光谱高光,悬停沿边扫过一小段;次按钮同为 lens 玻璃 - Input / Textarea / SelectTrigger 挂 .field-glass,原生下拉与实体选择器同步;对话框按钮行 gap-3 - 提示条改 pg-float(状态色只在图标、展开不叠放);新增 GlassTooltip 接管 title 提示 - 触屏判断 input-modality.ts、touch / hover 自定义变体;animate-pg-pop 改 backwards;scroll-fade-end - 控制台外壳:侧栏无硬分隔线、折叠开关进标志行;顶栏去磨砂,右侧控件收进玻璃胶囊 - 登录页居中单卡 auth-card;详情头图「订阅」改回普通主按钮,删除 pill-solid;发现页海报角标改 glass-chip-media - 引入 simple-liquid-glass(liquid-surface.tsx);更新 docs/design 三份规范与 CHANGELOG Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
d842bb7fb4
commit
09a2adb637
38 files changed
+941
-356
No files matched your search
@@ -29,7 +29,6 @@ export const DESIGN_TOKENS = {
|
||||
"notfound-code",
|
||||
"pricing-hero",
|
||||
"pricing-title",
|
||||
"auth-title",
|
||||
"landing-hero",
|
||||
"landing-platforms-title",
|
||||
"landing-immersive-title",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
/**
|
||||
* 当前主输入是触屏还是鼠标,写在 <html data-input="touch|mouse"> 上,CSS 用 `touch:` 变体读它,
|
||||
* React 用 useTouchInput() 读它。
|
||||
*
|
||||
* 只看 (pointer: coarse) 不够:车机、触屏电视盒、一体机这类设备常跑桌面 Linux / 桌面版 Chromium,
|
||||
* 报告的是精细指针和可悬停(按 PC 处理),实际只有触屏,悬停展开、音量簇、点按呼出控制条就全错了。
|
||||
* 通用判断分三层,前一层认不出时后一层兜底:
|
||||
*
|
||||
* 1. 系统 / 浏览器报告:粗指针,或有触点却没有能悬停的指针 → 触屏。UA 只作提示(已知车机直接从触屏起步)。
|
||||
* 2. 实际输入:
|
||||
* - 手指 / 笔按下(pointerType touch / pen)→ 触屏;
|
||||
* - 触屏被系统模拟成鼠标时(Linux 车机常见),按下前没有移动轨迹、光标「瞬移」到按下位置;
|
||||
* 连续两次瞬移按下 → 触屏;
|
||||
* - 真鼠标连续移动一段 → 回到鼠标(带触屏的笔记本、平板接鼠标)。
|
||||
* 3. 手动:地址带 ?input=touch|mouse|auto,记在 localStorage,优先于自动判断。只作排障口子,界面上不给用户选。
|
||||
*
|
||||
* 迁自 uhd_web app/lib/input-modality.ts(2026-10-08)。
|
||||
*/
|
||||
export type InputMode = "touch" | "mouse";
|
||||
export type InputPreference = "auto" | InputMode;
|
||||
|
||||
const STORAGE_KEY = "umt-input-override";
|
||||
// 已知跑桌面浏览器内核的车机 / 车载系统 UA 片段(只作初始提示,不锁定)
|
||||
const TOUCH_UA_HINTS =
|
||||
/\b(Tesla|QtCarBrowser|Automotive|AAOS|MBUX|Rivian|Lucid|Polestar|NIO|XPENG|LiAuto|HarmonyOS.*Car)\b/i;
|
||||
// 真鼠标点击前的移动是连续的小步。瞬移判定:按下点离上一次移动位置超过这个距离,
|
||||
// 或按下前紧挨着的那次移动本身就是一次大跳(触屏模拟的鼠标会先把光标跳到按下点再按下)。
|
||||
// 原地不动再点不算:电脑用户手放着连点两下不能被误判成触屏
|
||||
const TELEPORT_PX = 24;
|
||||
const JUMP_BEFORE_DOWN_MS = 120;
|
||||
const TELEPORTS_TO_TOUCH = 2;
|
||||
// 连续这么多次小步移动才算真鼠标回来了(模拟鼠标在按下前通常只有 0~1 次移动)
|
||||
const STEADY_MOVES_TO_MOUSE = 6;
|
||||
|
||||
let auto: InputMode = "mouse";
|
||||
let pref: InputPreference = "auto";
|
||||
let started = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function effective(): InputMode {
|
||||
return pref === "auto" ? auto : pref;
|
||||
}
|
||||
|
||||
function publish() {
|
||||
document.documentElement.dataset.input = effective();
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
function setAuto(next: InputMode) {
|
||||
if (next === auto) return;
|
||||
const before = effective();
|
||||
auto = next;
|
||||
if (effective() !== before) publish();
|
||||
}
|
||||
|
||||
function mq(q: string) {
|
||||
return window.matchMedia?.(q).matches ?? false;
|
||||
}
|
||||
|
||||
function detect(): InputMode {
|
||||
if (mq("(pointer: coarse)")) return "touch";
|
||||
if (navigator.maxTouchPoints > 0 && !mq("(any-hover: hover)")) return "touch";
|
||||
if (TOUCH_UA_HINTS.test(navigator.userAgent)) return "touch";
|
||||
return "mouse";
|
||||
}
|
||||
|
||||
function readPreference(): InputPreference {
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get("input");
|
||||
if (fromUrl === "auto" || fromUrl === "touch" || fromUrl === "mouse") {
|
||||
localStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved === "touch" || saved === "mouse") return saved;
|
||||
} catch {
|
||||
// 隐私模式等拿不到存储时按自动处理
|
||||
}
|
||||
return "auto";
|
||||
}
|
||||
|
||||
/** 在客户端入口调用一次;重复调用无副作用 */
|
||||
export function initInputModality() {
|
||||
if (started || typeof window === "undefined") return;
|
||||
started = true;
|
||||
pref = readPreference();
|
||||
auto = detect();
|
||||
document.documentElement.dataset.input = effective();
|
||||
|
||||
let lastMove: { x: number; y: number; t: number; jumped: boolean } | null = null;
|
||||
let teleports = 0;
|
||||
let steadyMoves = 0;
|
||||
|
||||
window.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "touch" || event.pointerType === "pen") {
|
||||
teleports = 0;
|
||||
steadyMoves = 0;
|
||||
setAuto("touch");
|
||||
return;
|
||||
}
|
||||
if (event.pointerType !== "mouse") return;
|
||||
const jumped =
|
||||
!lastMove ||
|
||||
Math.hypot(event.clientX - lastMove.x, event.clientY - lastMove.y) > TELEPORT_PX ||
|
||||
(lastMove.jumped && event.timeStamp - lastMove.t < JUMP_BEFORE_DOWN_MS);
|
||||
steadyMoves = 0;
|
||||
if (jumped) {
|
||||
teleports += 1;
|
||||
if (teleports >= TELEPORTS_TO_TOUCH) setAuto("touch");
|
||||
} else {
|
||||
teleports = 0;
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
const prev = lastMove;
|
||||
const step = prev ? Math.hypot(event.clientX - prev.x, event.clientY - prev.y) : Infinity;
|
||||
lastMove = { x: event.clientX, y: event.clientY, t: event.timeStamp, jumped: step > TELEPORT_PX };
|
||||
// 按住拖动(滑动进度条、触屏拖拽被模拟成鼠标)不算「真鼠标回来了」
|
||||
if (event.buttons !== 0 || !prev) return;
|
||||
if (step > 0 && step <= TELEPORT_PX && event.timeStamp - prev.t < 200) {
|
||||
steadyMoves += 1;
|
||||
if (steadyMoves >= STEADY_MOVES_TO_MOUSE) {
|
||||
teleports = 0;
|
||||
setAuto("mouse");
|
||||
}
|
||||
} else {
|
||||
steadyMoves = 0;
|
||||
}
|
||||
},
|
||||
{ capture: true, passive: true },
|
||||
);
|
||||
|
||||
window.matchMedia?.("(pointer: coarse)").addEventListener("change", () => setAuto(detect()));
|
||||
}
|
||||
|
||||
export function getInputMode(): InputMode {
|
||||
return effective();
|
||||
}
|
||||
|
||||
/**
|
||||
* 这次指针事件按触屏还是鼠标处理。手指 / 笔一律算触屏;鼠标类型的事件在全站判定为触屏模式时
|
||||
* 也算触屏 —— 车机等把触屏报告成鼠标的设备,播放器手势、进度条、点按显隐靠它才能生效。
|
||||
*/
|
||||
export function pointerKind(event: { pointerType: string }): InputMode {
|
||||
if (event.pointerType === "touch" || event.pointerType === "pen") return "touch";
|
||||
return effective();
|
||||
}
|
||||
|
||||
function subscribe(fn: () => void) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** 主输入是否为触屏。SSR 阶段返回 false,水合后按实际校正 */
|
||||
export function useTouchInput(): boolean {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
() => effective() === "touch",
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user