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
@@ -17,7 +17,7 @@ import {
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||
import { errorMessage, LOGIN_PATH } from "~/lib/api.ts";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { cn, formatDateTime } from "~/lib/utils.ts";
|
||||
import { FormField, zodFieldErrors } from "./form-field.tsx";
|
||||
|
||||
const PasswordForm = PasswordChangeRequest.extend({ confirm: z.string() }).refine((v) => v.newPassword === v.confirm, {
|
||||
@@ -107,7 +107,8 @@ function ChangePasswordDialog({ open, onOpenChange }: { open: boolean; onOpenCha
|
||||
}
|
||||
|
||||
/** 右上角账户弹层:当前身份、会话到期、改密、二次验证、登出 */
|
||||
export function AccountMenu({ session }: { session: SessionInfo }) {
|
||||
/** bare:放在顶栏的玻璃胶囊里时触发器不自带玻璃 */
|
||||
export function AccountMenu({ session, bare = false }: { session: SessionInfo; bare?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pwdOpen, setPwdOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@@ -128,7 +129,15 @@ export function AccountMenu({ session }: { session: SessionInfo }) {
|
||||
<>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="max-w-40" aria-label="账户">
|
||||
<Button
|
||||
variant={bare ? "ghost" : "outline"}
|
||||
size="sm"
|
||||
className={cn(
|
||||
"max-w-40",
|
||||
bare && "data-[state=open]:bg-foreground/[0.08] data-[state=open]:text-foreground",
|
||||
)}
|
||||
aria-label="账户"
|
||||
>
|
||||
<UserRound />
|
||||
<span className="hidden truncate sm:inline">{actor?.name ?? "账户"}</span>
|
||||
</Button>
|
||||
|
||||
@@ -71,7 +71,8 @@ function Group({
|
||||
);
|
||||
}
|
||||
|
||||
export function InboxBell() {
|
||||
/** bare:放在顶栏的玻璃胶囊里时触发器不自带玻璃 */
|
||||
export function InboxBell({ bare = false }: { bare?: boolean }) {
|
||||
const q = useAttention();
|
||||
const [open, setOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
@@ -83,7 +84,13 @@ export function InboxBell() {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="icon-sm" className="relative" aria-label={label} title={label}>
|
||||
<Button
|
||||
variant={bare ? "ghost" : "outline"}
|
||||
size="icon-sm"
|
||||
className={cn("relative", bare && "data-[state=open]:bg-foreground/[0.08] data-[state=open]:text-foreground")}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
{total ? (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-4 rounded-full bg-primary px-1 text-center text-micro leading-4 font-semibold text-primary-foreground tabular-nums">
|
||||
|
||||
@@ -14,10 +14,13 @@ const TONE: Record<StatusTone, { box: string; dot: string }> = {
|
||||
/** 状态徽章:文字必填,不只靠颜色传达状态 */
|
||||
export function StatusBadge({
|
||||
tone = "neutral",
|
||||
onMedia = false,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
tone?: StatusTone;
|
||||
/** 压在海报 / 剧照上:换成烟色小玻璃(glass-chip-media)白字,状态色只留在圆点上 */
|
||||
onMedia?: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -25,12 +28,15 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-meta font-medium whitespace-nowrap",
|
||||
t.box,
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-meta font-medium whitespace-nowrap",
|
||||
onMedia ? "glass-chip-media" : ["border", t.box],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className={cn("h-1.5 w-1.5 rounded-full", t.dot)} />
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-1.5 w-1.5 rounded-full", onMedia && tone === "neutral" ? "bg-current opacity-70" : t.dot)}
|
||||
/>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -54,7 +54,7 @@ export function DiscoverCard({
|
||||
</span>
|
||||
)}
|
||||
{item.rating ? (
|
||||
<span className="absolute top-1.5 left-1.5 inline-flex items-center gap-0.5 rounded-full bg-background/80 px-1.5 py-0.5 text-micro font-medium text-foreground backdrop-blur">
|
||||
<span className="glass-chip-media absolute top-1.5 left-1.5 inline-flex items-center gap-0.5 px-1.5 py-0.5 text-micro font-medium">
|
||||
<Star className="h-3 w-3" aria-hidden />
|
||||
{item.rating.toFixed(1)}
|
||||
</span>
|
||||
@@ -62,7 +62,7 @@ export function DiscoverCard({
|
||||
{badges.length ? (
|
||||
<span className="absolute inset-x-1.5 bottom-1.5 flex flex-wrap gap-1">
|
||||
{badges.map((b) => (
|
||||
<StatusBadge key={b.label} tone={b.tone} className="bg-background/85 backdrop-blur">
|
||||
<StatusBadge key={b.label} tone={b.tone} onMedia>
|
||||
{b.label}
|
||||
</StatusBadge>
|
||||
))}
|
||||
|
||||
@@ -616,7 +616,7 @@ function HoverOps({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-0.5 rounded-full bg-background/95 p-0.5 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
|
||||
"pg-float pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-0.5 rounded-full p-0.5 opacity-0 transition-opacity duration-micro",
|
||||
"group-hover/row:pointer-events-auto group-hover/row:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100",
|
||||
active && "group-focus-visible/grid:pointer-events-auto group-focus-visible/grid:opacity-100",
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { getInputMode } from "~/lib/input-modality.ts";
|
||||
|
||||
/**
|
||||
* 全站悬停提示:把浏览器原生的 title 提示换成站里的玻璃气泡(pg-float),样式与其他悬浮层一致。
|
||||
* 在根组件挂一次,所有带 title 的元素自动生效,调用处照旧写 title。
|
||||
*
|
||||
* 做法:鼠标停在带 title 的元素上 500ms 后显示;显示期间把 title 暂存到 data-glass-title,
|
||||
* 避免原生提示同时弹出,离开时还原。只处理鼠标(触屏没有悬停)。
|
||||
* 播放器全屏时 body 上的节点看不见,所以气泡挂在 document.fullscreenElement 里。
|
||||
*/
|
||||
const SHOW_DELAY = 500;
|
||||
const GAP = 8;
|
||||
const EDGE = 8;
|
||||
|
||||
type Tip = { text: string; x: number; y: number; below: boolean };
|
||||
|
||||
export function GlassTooltip() {
|
||||
const [tip, setTip] = useState<Tip | null>(null);
|
||||
const targetRef = useRef<HTMLElement | null>(null);
|
||||
const timerRef = useRef<number | undefined>(undefined);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
// 按下过的元素:直到鼠标离开它都不再弹提示(点完按钮不该冒出气泡,尤其是点击后提示文字变了的切换类按钮)
|
||||
const pressedRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const restore = () => {
|
||||
const el = targetRef.current;
|
||||
if (el?.dataset.glassTitle !== undefined) {
|
||||
// 显示期间组件自己换了提示文字(如 暂停 ↔ 播放)时,React 已经写回了新的 title,不能用旧文字覆盖
|
||||
if (!el.hasAttribute("title")) el.setAttribute("title", el.dataset.glassTitle);
|
||||
delete el.dataset.glassTitle;
|
||||
}
|
||||
targetRef.current = null;
|
||||
};
|
||||
const hide = () => {
|
||||
window.clearTimeout(timerRef.current);
|
||||
restore();
|
||||
setTip(null);
|
||||
};
|
||||
|
||||
const onOver = (event: PointerEvent) => {
|
||||
// 只给鼠标:车机这类触屏点按时可能报成 mouse 指针,按全站的触屏判断再拦一次
|
||||
if (event.pointerType !== "mouse" || getInputMode() === "touch") return;
|
||||
const el = (event.target as Element | null)?.closest<HTMLElement>("[title], [data-glass-title]");
|
||||
if (!el || el === targetRef.current || el === pressedRef.current) return;
|
||||
hide();
|
||||
const text = el.getAttribute("title")?.trim();
|
||||
if (!text) return;
|
||||
targetRef.current = el;
|
||||
el.dataset.glassTitle = text;
|
||||
el.removeAttribute("title");
|
||||
timerRef.current = window.setTimeout(() => {
|
||||
if (targetRef.current !== el || !el.isConnected) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
// 元素在一块液态玻璃里(底栏、顶栏胶囊)时,上下以那块玻璃的边为准,气泡不压在玻璃上
|
||||
const body = el.parentElement?.closest<HTMLElement>("[data-liquid-glass]")?.getBoundingClientRect();
|
||||
const top = body?.top ?? r.top;
|
||||
const bottom = body?.bottom ?? r.bottom;
|
||||
const below = top < 48;
|
||||
setTip({ text, x: r.left + r.width / 2, y: below ? bottom + GAP : top - GAP, below });
|
||||
}, SHOW_DELAY);
|
||||
};
|
||||
|
||||
const onOut = (event: PointerEvent) => {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
const pressed = pressedRef.current;
|
||||
if (pressed && !(next && pressed.contains(next))) pressedRef.current = null;
|
||||
const el = targetRef.current;
|
||||
if (!el) return;
|
||||
if (next && el.contains(next)) return;
|
||||
hide();
|
||||
};
|
||||
const onDown = (event: PointerEvent) => {
|
||||
pressedRef.current =
|
||||
(event.target as Element | null)?.closest<HTMLElement>("[title], [data-glass-title]") ?? null;
|
||||
hide();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerover", onOver, true);
|
||||
document.addEventListener("pointerout", onOut, true);
|
||||
document.addEventListener("pointerdown", onDown, true);
|
||||
document.addEventListener("keydown", hide, true);
|
||||
window.addEventListener("scroll", hide, true);
|
||||
window.addEventListener("blur", hide);
|
||||
return () => {
|
||||
hide();
|
||||
document.removeEventListener("pointerover", onOver, true);
|
||||
document.removeEventListener("pointerout", onOut, true);
|
||||
document.removeEventListener("pointerdown", onDown, true);
|
||||
document.removeEventListener("keydown", hide, true);
|
||||
window.removeEventListener("scroll", hide, true);
|
||||
window.removeEventListener("blur", hide);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 量出气泡宽度后夹在视口内,不贴边出屏
|
||||
useEffect(() => {
|
||||
const node = bubbleRef.current;
|
||||
if (!tip || !node) return;
|
||||
const half = node.offsetWidth / 2;
|
||||
const x = Math.min(Math.max(tip.x, half + EDGE), window.innerWidth - half - EDGE);
|
||||
node.style.left = `${x}px`;
|
||||
}, [tip]);
|
||||
|
||||
if (!tip || typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
// 外层只管定位(transform 居中),内层放材质与弹出动画:动画的 transform 会盖掉同一元素上的定位
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
role="tooltip"
|
||||
className="pointer-events-none fixed z-[300] w-max max-w-xs"
|
||||
style={{
|
||||
left: tip.x,
|
||||
top: tip.y,
|
||||
transform: tip.below ? "translateX(-50%)" : "translate(-50%, -100%)",
|
||||
}}
|
||||
>
|
||||
<div className="pg-float rounded-xl px-2.5 py-1.5 text-caption text-popover-foreground animate-pg-pop">
|
||||
{tip.text}
|
||||
</div>
|
||||
</div>,
|
||||
document.fullscreenElement ?? document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { LiquidGlass, type LiquidGlassProps } from "simple-liquid-glass";
|
||||
import { useTouchInput } from "~/lib/input-modality.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
/**
|
||||
* 液态玻璃面:simple-liquid-glass 的薄封装,材质按 meniscus 的尺寸分档。
|
||||
* - lens:小控件(图标键、头像),几乎不糊,只在边带折射;
|
||||
* - regular:栏(顶栏胶囊、播放器底栏);
|
||||
* - frost:大块、字多的悬浮层(菜单)。只有真模糊,不做折射(见下方 effectMode)。
|
||||
* 折射只有 Chromium 桌面有(SVG 位移进 backdrop-filter);Safari / Firefox / 触屏设备走包自带的磨砂。
|
||||
* 只放在压着图片、视频、海报的层上;背后没细节的内容卡继续用 pg-slab。
|
||||
*/
|
||||
export type LiquidTier = "lens" | "regular" | "frost";
|
||||
|
||||
const TIERS: Record<
|
||||
LiquidTier,
|
||||
{
|
||||
blur: number;
|
||||
displacement: number;
|
||||
fill: { dark: string; light: string };
|
||||
}
|
||||
> = {
|
||||
lens: { blur: 1, displacement: 6, fill: { dark: "rgba(0,0,0,0.14)", light: "rgba(255,255,255,0.08)" } },
|
||||
regular: { blur: 5, displacement: 10, fill: { dark: "rgba(10,11,14,0.30)", light: "rgba(255,255,255,0.16)" } },
|
||||
frost: { blur: 20, displacement: 8, fill: { dark: "rgba(14,15,19,0.58)", light: "rgba(255,255,255,0.5)" } },
|
||||
};
|
||||
|
||||
const FROST_BLUR = "blur(24px) saturate(160%)";
|
||||
// 模糊之上再压一层底色,浅色字(次要说明、退出登录)压在暖色画面上也读得清
|
||||
const FROST_FILL = { dark: "rgba(14,15,19,0.55)", light: "rgba(255,255,255,0.6)" };
|
||||
|
||||
interface Props extends Omit<LiquidGlassProps, "radius"> {
|
||||
tier?: LiquidTier;
|
||||
radius: number;
|
||||
/** 播放器等恒暗区域固定用夜间参数 */
|
||||
forceDark?: boolean;
|
||||
/** 不画包自带的边光(主按钮的棱镜高光就是它的边光) */
|
||||
rimless?: boolean;
|
||||
/** 折射边缘带色散:背后画面在棱边被拆成彩色(只在 Chromium 桌面、背后有画面时看得到) */
|
||||
dispersive?: boolean;
|
||||
/** 只要玻璃外观与模糊,不做折射(测速页:实时曲线在重绘,不加 SVG 滤镜开销) */
|
||||
noRefraction?: boolean;
|
||||
}
|
||||
|
||||
export function LiquidSurface({
|
||||
tier = "regular",
|
||||
radius,
|
||||
forceDark,
|
||||
rimless,
|
||||
dispersive,
|
||||
noRefraction,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const dark = forceDark || resolvedTheme !== "light";
|
||||
// 触屏设备(手机、平板、车机)不跑 SVG 折射,退回同参数的磨砂:车机这类 Linux 触屏会被包当成桌面
|
||||
const touch = useTouchInput();
|
||||
// 不折射、只模糊的情况:包在这个模式下不铺 glassColor,还会在顶部画一道 50% 白的光泽(夜间压住第一行字),
|
||||
// 底色由这里铺,光泽在 app.css 里按档收淡 / 去掉
|
||||
const blurOnly = touch || noRefraction || tier === "frost";
|
||||
const t = TIERS[tier];
|
||||
return (
|
||||
<LiquidGlass
|
||||
mode="custom"
|
||||
refraction="lens"
|
||||
lensProfile="material"
|
||||
quality={dispersive ? "high" : "standard"}
|
||||
radius={radius}
|
||||
blur={t.blur}
|
||||
displacementScale={dispersive ? t.displacement + 4 : t.displacement}
|
||||
saturation={150}
|
||||
dispersion={50}
|
||||
aberrationIntensity={dispersive ? 0.4 : 0.06}
|
||||
frost={0}
|
||||
// frost 档(字多的悬浮层)不做折射:Chromium 里同一个 backdrop-filter 同时有 blur 和 SVG 位移时,
|
||||
// 模糊不生效,菜单底下的画面原样透出来、字看不清。frost 只要真模糊,折射留给栏和按钮
|
||||
effectMode={blurOnly ? "blur" : "auto"}
|
||||
glassColor={dark ? t.fill.dark : t.fill.light}
|
||||
borderColor={rimless ? "rgba(255,255,255,0)" : dark ? "rgba(255,255,255,0.22)" : "rgba(255,255,255,0.65)"}
|
||||
className={cn("liquid-surface", `liquid-surface-${tier}`, blurOnly && "liquid-surface-blur", className)}
|
||||
style={{
|
||||
// frost 档(菜单、搜索结果等字多的悬浮层)自己垫一层固定模糊:包退回磨砂时只给 8~11px,
|
||||
// 菜单压在画面上看不清,这里统一保证 24px
|
||||
...(tier === "frost"
|
||||
? {
|
||||
backdropFilter: FROST_BLUR,
|
||||
WebkitBackdropFilter: FROST_BLUR,
|
||||
backgroundColor: dark ? FROST_FILL.dark : FROST_FILL.light,
|
||||
}
|
||||
: blurOnly
|
||||
? { backgroundColor: dark ? t.fill.dark : t.fill.light }
|
||||
: null),
|
||||
// 1px 暗边线在亮棱外侧,投影只留贴边一层
|
||||
boxShadow: dark
|
||||
? "0 0 0 1px rgba(0,0,0,0.28), 0 8px 24px -14px rgba(0,0,0,0.5)"
|
||||
: "0 0 0 1px rgba(20,22,28,0.08), 0 8px 24px -16px rgba(20,22,28,0.22)",
|
||||
...style,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</LiquidGlass>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 给压在大图上的按钮加真折射:外面套一层 lens 玻璃,按钮本身挂 .btn-liquid 去掉自己的底和模糊,
|
||||
* 只留字、图标和主按钮的彩虹环。radius 取按钮高度的一半。
|
||||
*/
|
||||
export function LiquidLens({
|
||||
radius,
|
||||
primary,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
radius: number;
|
||||
primary?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<LiquidSurface
|
||||
tier="lens"
|
||||
radius={radius}
|
||||
rimless={primary}
|
||||
dispersive={primary}
|
||||
className={className}
|
||||
style={{ width: "auto", height: "auto" }}
|
||||
>
|
||||
{children}
|
||||
</LiquidSurface>
|
||||
);
|
||||
}
|
||||
@@ -70,13 +70,13 @@ describe("MediaHero 头图", () => {
|
||||
expect(row).toContain("正常");
|
||||
});
|
||||
|
||||
it("操作按钮并进头图区、在外链之后;只有订阅是实心主操作,其余是描边次操作,没有棱镜描边", () => {
|
||||
it("操作按钮并进头图区、在外链之后;只有订阅是主操作,其余是次操作,没有实心按钮", () => {
|
||||
const html = render({
|
||||
facts: facts(),
|
||||
actions: createElement(
|
||||
Fragment,
|
||||
null,
|
||||
createElement(Button, { variant: "solid", size: "sm" }, "订阅"),
|
||||
createElement(Button, { size: "sm" }, "订阅"),
|
||||
createElement(Button, { variant: "outline", size: "sm" }, "搜索资源"),
|
||||
createElement(Button, { variant: "outline", size: "sm" }, "查看订阅"),
|
||||
),
|
||||
@@ -84,10 +84,10 @@ describe("MediaHero 头图", () => {
|
||||
const actions = slotHtml(html, "media-hero-actions");
|
||||
expect(actions).not.toBeNull();
|
||||
expect(html.indexOf('data-slot="media-hero-actions"')).toBeGreaterThan(html.indexOf("TMDB"));
|
||||
expect(actions!.match(/pill-solid/g)).toHaveLength(1);
|
||||
expect(actions!.match(/pill-primary/g)).toHaveLength(1);
|
||||
expect(actions!.match(/pill-secondary/g)).toHaveLength(2);
|
||||
expect(actions).not.toContain("pill-primary");
|
||||
expect(actions!.indexOf("pill-solid")).toBeLessThan(actions!.indexOf("订阅"));
|
||||
expect(actions).not.toContain("pill-solid");
|
||||
expect(actions!.indexOf("pill-primary")).toBeLessThan(actions!.indexOf("订阅"));
|
||||
});
|
||||
|
||||
it("没有操作时不渲染操作行;标语有值才显示", () => {
|
||||
|
||||
@@ -144,8 +144,9 @@ export function EntityPicker({
|
||||
type="button"
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
"flex h-8 w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-left text-caption",
|
||||
value ? "border-input" : invalid ? "border-warning/60 text-warning" : "border-input text-muted-foreground",
|
||||
// 长得像下拉选择:材质用 .field-glass(出错红线由 aria-invalid 给)
|
||||
"field-glass flex h-8 w-full min-w-0 items-center gap-1.5 rounded-xl px-2.5 text-left text-caption",
|
||||
!value && (invalid ? "text-warning" : "text-muted-foreground"),
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -17,7 +17,8 @@ export function SearchTrigger({ className }: { className?: string }) {
|
||||
aria-label="搜索"
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
className={cn(
|
||||
"hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full border border-border/80 bg-foreground/[0.03] px-3 text-caption text-muted-foreground transition-colors duration-micro outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 md:flex lg:w-72",
|
||||
// 长得像输入框,材质与输入框同一套(.field-glass)
|
||||
"field-glass hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full px-3 text-caption text-muted-foreground outline-none hover:text-foreground md:flex lg:w-72",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -25,7 +25,8 @@ import { Textarea } from "~/components/ui/textarea.tsx";
|
||||
import { formatDateTime } from "~/lib/utils.ts";
|
||||
import { SaveBar, SettingsBlock } from "./settings-block.tsx";
|
||||
|
||||
const selectClass = "w-full rounded-lg border border-border bg-background px-3 py-2 text-caption";
|
||||
// 原生下拉:材质与输入框同一套(.field-glass),这里只给尺寸
|
||||
const selectClass = "field-glass w-full rounded-xl px-3 py-2 text-caption";
|
||||
function Field({ label, children }: { label: string; children: ReactElement<{ id?: string }> }) {
|
||||
const id = useId();
|
||||
return (
|
||||
|
||||
@@ -85,7 +85,7 @@ export function SettingsSearch({ className }: { className?: string }) {
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label="搜索结果"
|
||||
className="pg-float absolute inset-x-0 top-full z-30 mt-1.5 max-h-80 animate-pg-pop overflow-y-auto rounded-xl p-1"
|
||||
className="pg-float absolute inset-x-0 top-full z-30 mt-1.5 max-h-80 animate-pg-pop overflow-y-auto rounded-2xl p-1"
|
||||
>
|
||||
{hits.length === 0 ? (
|
||||
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的设置</p>
|
||||
|
||||
@@ -81,9 +81,11 @@ export function HistoryChart({
|
||||
<Tooltip
|
||||
labelFormatter={(v) => new Date(String(v)).toLocaleString("zh-CN", { hour12: false })}
|
||||
formatter={(v) => [`${Math.round(Number(v) * 10) / 10} ${unit}`]}
|
||||
// 悬浮层材质与 pg-float 同一组参数
|
||||
contentStyle={{
|
||||
background: "var(--color-popover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--mat-float-bg)",
|
||||
border: "1px solid var(--mat-float-border)",
|
||||
boxShadow: "var(--mat-float-shadow)",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
color: "var(--color-foreground)",
|
||||
|
||||
@@ -65,9 +65,11 @@ export function Sparkline({
|
||||
<Tooltip
|
||||
labelFormatter={(_, p) => (p?.[0]?.payload?.at ? clock(String(p[0].payload.at)) : "")}
|
||||
formatter={(v, name) => [format(Number(v)), String(name)]}
|
||||
// 悬浮层材质与 pg-float 同一组参数
|
||||
contentStyle={{
|
||||
background: "var(--color-popover)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--mat-float-bg)",
|
||||
border: "1px solid var(--mat-float-border)",
|
||||
boxShadow: "var(--mat-float-shadow)",
|
||||
borderRadius: 12,
|
||||
fontSize: 12,
|
||||
color: "var(--color-foreground)",
|
||||
|
||||
@@ -6,7 +6,8 @@ import { flushSync } from "react-dom";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export function ThemeToggle({ className }: { className?: string }) {
|
||||
/** bare:放在顶栏的玻璃胶囊里时不自带玻璃,只留图标与悬停淡底 */
|
||||
export function ThemeToggle({ className, bare = false }: { className?: string; bare?: boolean }) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
@@ -41,7 +42,7 @@ export function ThemeToggle({ className }: { className?: string }) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant={bare ? "ghost" : "outline"}
|
||||
size="icon-sm"
|
||||
aria-label={isDark ? "切换到亮色主题" : "切换到暗色主题"}
|
||||
title={isDark ? "切换到亮色主题" : "切换到暗色主题"}
|
||||
|
||||
@@ -42,7 +42,7 @@ function AlertDialogContent({
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float p-6 duration-base data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||
"group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float pg-float-modal p-6 duration-base data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[size=default]:sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -69,7 +69,7 @@ function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">)
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
"flex flex-col-reverse gap-3 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -12,11 +12,9 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// 主操作:半透明胶囊 + 沿边框流转的棱镜光谱。样式见 app.css 的 .pill-primary(--mat-pill-primary-*、--prism-*)
|
||||
// 主操作:lens 玻璃胶囊 + 左上棱边的光谱高光(静止,悬停沿边扫过一小段)。样式见 app.css 的 .pill-primary(--mat-lens-*、--prism-*)
|
||||
default: "pill-primary",
|
||||
// 实心主操作:只给详情页头图区的唯一主操作(订阅)用,见 app.css 的 .pill-solid(--mat-pill-solid-*)
|
||||
solid: "pill-solid",
|
||||
// 次操作:描边胶囊。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
|
||||
// 次操作:lens 玻璃胶囊(边光 + 外侧细线)。outline 与 secondary 是同一个样子,保留两个名字只为兼容旧调用
|
||||
outline: "pill-secondary",
|
||||
secondary: "pill-secondary",
|
||||
// 危险操作:同一胶囊,淡红底红字
|
||||
|
||||
@@ -47,7 +47,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float p-6 duration-base outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-3xl pg-float pg-float-modal p-6 duration-base outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -88,7 +88,11 @@ function DialogFooter({
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
className={cn(
|
||||
// 12px:按钮带外侧细线与光谱高光,8px 会挤在一起
|
||||
"flex flex-col-reverse gap-3 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -8,9 +8,8 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// 材质由 .field-glass 统一给(底、边、聚焦光晕、出错红线),调用处只给尺寸与内边距
|
||||
"field-glass file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground h-9 w-full min-w-0 rounded-xl px-3 py-1 text-base outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -29,7 +29,8 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
// 材质由 .field-glass 统一给(底、边、聚焦 / 展开光晕、出错红线),调用处只给尺寸与内边距
|
||||
"field-glass flex w-fit items-center justify-between gap-2 rounded-xl px-3 py-2 text-sm whitespace-nowrap outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -49,7 +49,7 @@ function SheetContent({
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"pg-float data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-layout data-[state=open]:duration-slow",
|
||||
"pg-float pg-float-modal data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 transition ease-in-out data-[state=closed]:duration-layout data-[state=open]:duration-slow",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
@@ -79,7 +79,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-2 p-4", className)} {...props} />;
|
||||
return <div data-slot="sheet-footer" className={cn("mt-auto flex flex-col gap-3 p-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import { buttonVariants } from "~/components/ui/button.tsx";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
@@ -26,6 +27,21 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
// 提示条是 pg-float 玻璃,状态色只在图标上(不开 richColors)
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast:
|
||||
"pg-float rounded-2xl w-full flex items-start gap-2.5 px-3.5 py-2.5 text-caption text-popover-foreground",
|
||||
title: "font-medium leading-relaxed",
|
||||
description: "mt-1 text-caption text-muted-foreground",
|
||||
content: "min-w-0 flex-1",
|
||||
icon: "mt-0.5 shrink-0 flex items-center justify-center",
|
||||
error: "[&_[data-icon]]:text-destructive",
|
||||
warning: "[&_[data-icon]]:text-warning",
|
||||
success: "[&_[data-icon]]:text-success",
|
||||
actionButton: buttonVariants({ size: "sm" }),
|
||||
cancelButton: buttonVariants({ variant: "outline", size: "sm" }),
|
||||
},
|
||||
style: { pointerEvents: "auto" },
|
||||
}}
|
||||
{...props}
|
||||
|
||||
@@ -7,9 +7,8 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground dark:bg-input/30 border-input flex min-h-16 w-full min-w-0 rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
// 材质同 Input,由 .field-glass 统一给
|
||||
"field-glass placeholder:text-muted-foreground flex min-h-16 w-full min-w-0 rounded-xl px-3 py-2 text-base outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in new issue
Block a user