BLOCKS / 3
区块
可直接安装的完整区块。切换设备宽度查看布局,或打开代码,把需要的部分带进你的项目。
正在加载…
"use client"
import { Auth01 } from "@/components/auth-01/auth-01"
/**
* 登录页入口:把下面几个回调换成你自己的认证实现。
* 回调返回 Promise;失败时 throw new Error("中文原因"),组件会显示在表单上方。
* 登录成功后的跳转请在 onSignIn / onSocialSignIn 里自行处理,例如 router.push("/")。
*/
export default function LoginPage() {
return (
<Auth01
brand={{ name: "Acme" }}
onSignIn={async (values) => {
// TODO: 调用你的认证服务,例如 authClient.signIn.email(...)
console.info("sign in", values)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
onSignUp={async (values) => {
console.info("sign up", values)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
onForgotPassword={async (email) => {
console.info("forgot password", email)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
onSendCode={async (phone) => {
console.info("send code", phone)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
onSocialSignIn={async (provider) => {
console.info("social sign in", provider)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
/>
)
}
"use client"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { useState } from "react"
import { cn } from "@/lib/utils"
import { defaultBrand, type BrandConfig, type BrandFeature } from "./brand"
import { BrandPanel, MobileBrand } from "./brand-panel"
import type { Auth01Labels } from "./config"
import { ForgotPasswordForm } from "./forgot-password-form"
import { SignInForm, type SignInValues } from "./sign-in-form"
import { SignUpForm, type SignUpValues } from "./sign-up-form"
import type { SocialProvider } from "./social-buttons"
type AuthView = "sign-in" | "sign-up" | "forgot-password"
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// 未传回调时的示例实现:只在控制台打印,并模拟 0.8 秒的网络请求
const demo =
<Args extends unknown[]>(name: string) =>
async (...args: Args) => {
console.info(`[auth-01] ${name}`, ...args)
await wait(800)
}
/**
* 分屏式登录 / 注册页:左侧品牌面板 + 右侧表单,窄屏只显示表单。
* 回调返回 Promise;组件管理 loading,回调 throw 的 Error.message 会显示为表单错误。
*/
function Auth01({
brand,
defaultView = "sign-in",
socialProviders = ["github", "google"],
allowSignUp = true,
termsHref = "#",
privacyHref = "#",
onSignIn = demo<[SignInValues]>("onSignIn"),
onSignUp = demo<[SignUpValues]>("onSignUp"),
onForgotPassword = demo<[string]>("onForgotPassword"),
onSendCode = demo<[string]>("onSendCode"),
onSocialSignIn = demo<[SocialProvider]>("onSocialSignIn"),
labels,
className,
}: {
/** 品牌信息,逐项覆盖默认的示例品牌 */
brand?: Partial<BrandConfig>
defaultView?: AuthView
/** 启用的第三方登录,传空数组隐藏该区域 */
socialProviders?: readonly SocialProvider[]
/** 设为 false 隐藏「注册」入口(仅邀请制的产品) */
allowSignUp?: boolean
termsHref?: string
privacyHref?: string
onSignIn?: (values: SignInValues) => Promise<void>
onSignUp?: (values: SignUpValues) => Promise<void>
onForgotPassword?: (email: string) => Promise<void>
onSendCode?: (phone: string) => Promise<void>
onSocialSignIn?: (provider: SocialProvider) => Promise<void>
/** 覆盖默认文案,见 config.ts 的 DEFAULT_LABELS */
labels?: Partial<Auth01Labels>
className?: string
}) {
const [view, setView] = useState<AuthView>(defaultView)
const reducedMotion = useReducedMotion()
const resolved: BrandConfig = { ...defaultBrand, ...brand }
return (
<div data-slot="auth-01" className={cn("relative flex min-h-svh", className)}>
<BrandPanel brand={resolved} labels={labels} />
<main className="relative flex flex-1 items-center justify-center bg-background p-6 pb-16 sm:p-12 sm:pb-16">
<div className="w-full max-w-md">
<MobileBrand brand={resolved} />
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={view}
initial={{ opacity: 0, y: reducedMotion ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reducedMotion ? 0 : -8 }}
transition={{ duration: 0.18 }}
>
{view === "sign-in" && (
<SignInForm
socialProviders={socialProviders}
allowSignUp={allowSignUp}
onSignIn={onSignIn}
onSendCode={onSendCode}
onSocialSignIn={onSocialSignIn}
onForgotPassword={() => setView("forgot-password")}
onSwitchToSignUp={() => setView("sign-up")}
labels={labels}
/>
)}
{view === "sign-up" && (
<SignUpForm
termsHref={termsHref}
privacyHref={privacyHref}
onSignUp={onSignUp}
onSwitchToSignIn={() => setView("sign-in")}
labels={labels}
/>
)}
{view === "forgot-password" && (
<ForgotPasswordForm
onForgotPassword={onForgotPassword}
onSwitchToSignIn={() => setView("sign-in")}
labels={labels}
/>
)}
</motion.div>
</AnimatePresence>
</div>
<p className="absolute right-6 bottom-4 text-xs text-muted-foreground">
{resolved.copyright}
</p>
</main>
</div>
)
}
export { Auth01 }
export type {
Auth01Labels,
AuthView,
BrandConfig,
BrandFeature,
SignInValues,
SignUpValues,
SocialProvider,
}
/**
* 登录页所有用户可见的文案,包含表单校验提示(validation* 键)。
* 校验提示里的 {min}、{length} 由 schemas.ts 的规则常量填入;只改规则(密码策略、手机号格式)
* 不用动文案,想换提示语则覆盖对应的 validation* 键。
* 品牌名、标题、特性列表属于品牌数据,见 brand.ts。
*/
const DEFAULT_LABELS = {
// 品牌面板
brandPanelLabel: "{name} 品牌介绍",
// 登录
signInTitle: "欢迎回来",
signInDescription: "输入账号信息即可登录",
signInMethodLabel: "登录方式",
signInPasswordTab: "密码登录",
signInCodeTab: "验证码登录",
accountLabel: "用户名或邮箱",
accountPlaceholder: "请输入用户名或邮箱",
passwordLabel: "密码",
passwordPlaceholder: "请输入密码",
forgotPassword: "忘记密码?",
remember: "记住登录状态",
signInSubmit: "登录",
signInPending: "登录中…",
phoneLabel: "手机号",
phonePlaceholder: "请输入 11 位手机号",
codeLabel: "验证码",
codePlaceholder: "6 位数字验证码",
sendCode: "获取验证码",
sendCodePending: "发送中…",
resendCode: "{seconds}s 后重发",
socialDivider: "或",
noAccount: "还没有账号?",
signUpLink: "注册",
// 注册
signUpTitle: "创建账号",
signUpDescription: "填写以下信息即可完成注册",
nameLabel: "姓名",
namePlaceholder: "请输入姓名",
emailLabel: "邮箱",
emailPlaceholder: "name@example.com",
newPasswordPlaceholder: "至少 8 位,包含字母和数字",
confirmPasswordLabel: "确认密码",
confirmPasswordPlaceholder: "再次输入密码",
termsPrefix: "我已阅读并同意",
termsOfService: "服务条款",
termsConjunction: "和",
privacyPolicy: "隐私政策",
signUpSubmit: "注册",
signUpPending: "注册中…",
haveAccount: "已有账号?",
signInLink: "登录",
// 找回密码
forgotPasswordTitle: "找回密码",
forgotPasswordDescription: "输入注册时使用的邮箱,我们会发送重置链接",
sendResetLink: "发送重置链接",
sendResetLinkPending: "发送中…",
backToSignIn: "返回登录",
emailSentTitle: "邮件已发送",
emailSentDescription:
"我们已向 {email} 发送了重置密码的链接,请查收邮件。没收到?请检查垃圾邮件箱。",
// 输入框与密码强度
showPassword: "显示密码",
hidePassword: "隐藏密码",
passwordStrength: "强度:{level}",
passwordStrengthEmpty: "强度:—",
strengthWeak: "弱",
strengthFair: "一般",
strengthGood: "较强",
strengthStrong: "强",
// 校验提示(schemas.ts 使用,{min}、{length} 由规则常量填入)
validationAccountRequired: "请输入用户名或邮箱",
validationPasswordRequired: "请输入密码",
validationEmailRequired: "请输入邮箱",
validationEmailInvalid: "请输入有效的邮箱地址",
validationPhoneInvalid: "请输入有效的 {length} 位手机号",
validationCodeInvalid: "请输入 {length} 位数字验证码",
validationPasswordMin: "密码至少 {min} 位",
validationPasswordLetter: "密码需要包含字母",
validationPasswordDigit: "密码需要包含数字",
validationNameMin: "姓名至少 {min} 个字符",
validationConfirmRequired: "请再次输入密码",
validationTermsRequired: "请先阅读并同意服务条款",
validationPasswordMismatch: "两次输入的密码不一致",
// 通用
requestFailed: "操作失败,请稍后重试",
} as const
type Auth01Labels = { [Key in keyof typeof DEFAULT_LABELS]: string }
/** 合并用户传入的文案与默认文案。 */
function resolveLabels(labels?: Partial<Auth01Labels>): Auth01Labels {
return { ...DEFAULT_LABELS, ...labels }
}
/** 把文案里的 {key} 占位符替换成实际值,例如「强度:{level}」。 */
function formatLabel(
template: string,
values: Record<string, string | number>,
): string {
return Object.entries(values).reduce(
(text, [key, value]) => text.replaceAll(`{${key}}`, String(value)),
template,
)
}
/** 以 {key} 为界把文案拆成前后两段,便于把值渲染成强调元素。 */
function splitLabel(template: string, key: string): [string, string] {
const [before = template, after = ""] = template.split(`{${key}}`)
return [before, after]
}
export { DEFAULT_LABELS, formatLabel, resolveLabels, splitLabel }
export type { Auth01Labels }
import { Gauge, Hexagon, ShieldCheck, Zap } from "lucide-react"
import type { ReactNode } from "react"
type BrandFeature = {
icon: ReactNode
title: string
description: string
}
type BrandConfig = {
/** 产品名,显示在 Logo 旁 */
name: string
/** Logo 节点:建议用 currentColor 的 SVG,这样在品牌面板(反色)和表单上方(正常色)都能看清 */
logo: ReactNode
/** 品牌面板的大标题 */
title: string
description: string
/** 品牌面板上的特性列表,建议 2–3 条 */
features: BrandFeature[]
/** 右下角的版权文字 */
copyright: string
}
/** 示例品牌:换成自己的产品信息,或通过 Auth01 的 brand 属性逐项覆盖。 */
const defaultBrand: BrandConfig = {
name: "Acme",
logo: <Hexagon strokeWidth={1.5} />,
title: "把想法变成可用的产品",
description: "一个账号,打开全部工具。登录后继续上次的工作,随时随地保持同步。",
features: [
{
icon: <Zap />,
title: "快速上手",
description: "几分钟完成配置,立刻开始使用",
},
{
icon: <Gauge />,
title: "稳定可靠",
description: "全天候运行,关键数据实时同步",
},
{
icon: <ShieldCheck />,
title: "安全优先",
description: "加密传输,权限精细可控",
},
],
copyright: "© 2026 Acme. 保留所有权利。",
}
export { defaultBrand }
export type { BrandConfig, BrandFeature }
import { cn } from "@/lib/utils"
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
import { Ripple } from "@/components/ui/ripple/ripple"
import { TextReveal } from "@/components/ui/text-animate/text-animate"
import type { BrandConfig } from "./brand"
import { formatLabel, resolveLabels, type Auth01Labels } from "./config"
function Logo({
logo,
className,
}: {
logo: BrandConfig["logo"]
className: string
}) {
// 外层限定尺寸,内部的 svg / img 自动缩放到框内
return (
<span
data-slot="auth-01-brand-logo"
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center [&>*]:max-h-full [&>*]:max-w-full [&>svg]:size-full",
className,
)}
>
{logo}
</span>
)
}
/**
* 大屏左半边的品牌面板:主色渐变 + 模糊光斑 + 向外扩散的波纹 + 四周的字符矩阵。
* 亮色下是主色底配 primary-foreground 文字;暗色下 primary 会变成浅色,整块面板会变亮,
* 所以暗色改为 card 深底,只用 primary 的低透明度渐变染上主题色,文字回到 foreground。
* 光斑、波纹、字符矩阵、图标底共用 --panel-glow,随亮暗一起切换。
*/
function BrandPanel({
brand,
labels,
}: {
brand: BrandConfig
labels?: Partial<Auth01Labels>
}) {
const text = resolveLabels(labels)
return (
<aside
data-slot="auth-01-brand-panel"
aria-label={formatLabel(text.brandPanelLabel, { name: brand.name })}
className="relative hidden overflow-hidden bg-linear-to-br from-primary/90 via-primary to-primary/80 text-primary-foreground [--panel-glow:var(--primary-foreground)] lg:flex lg:w-1/2 dark:bg-card dark:from-primary/16 dark:via-primary/7 dark:to-primary/3 dark:text-foreground dark:[--panel-glow:var(--primary)]"
>
<div aria-hidden="true" className="absolute inset-0">
<div className="absolute top-20 left-10 size-72 rounded-full bg-(--panel-glow)/10 blur-3xl" />
<div className="absolute right-10 bottom-20 size-96 rounded-full bg-(--panel-glow)/5 blur-3xl" />
{/* 字符矩阵只铺在四周,中间留白给文字;GlyphField 自带底色,这里改成透明 */}
<GlyphField
fade="center"
glyphs="01"
tone="var(--panel-glow)"
density={0.4}
fontSize={13}
speed={0.02}
className="absolute inset-0 min-h-0 bg-transparent opacity-25"
/>
<Ripple
size={720}
rings={4}
ringGap={140}
baseSize={200}
duration={8}
background="none"
tone="var(--panel-glow)"
className="absolute inset-0 h-full"
/>
</div>
<div className="relative z-10 flex flex-col justify-center px-12 xl:px-20">
<div className="mb-8 flex items-center gap-3">
<Logo logo={brand.logo} className="size-14" />
<span className="text-2xl font-bold" translate="no">
{brand.name}
</span>
</div>
<p className="mb-6 text-2xl leading-tight font-bold text-balance xl:text-3xl">
<TextReveal by="word" trigger="mount" stagger={70}>
{brand.title}
</TextReveal>
</p>
<p className="mb-10 max-w-md text-lg leading-8 text-current/80">
{brand.description}
</p>
<ul className="flex flex-col gap-4">
{brand.features.map((feature) => (
<li key={feature.title} className="flex items-center gap-4">
<span
aria-hidden="true"
className="rounded-lg bg-(--panel-glow)/20 p-2 [&_svg]:size-5"
>
{feature.icon}
</span>
<div>
<div className="font-medium">{feature.title}</div>
<div className="text-sm text-current/70">
{feature.description}
</div>
</div>
</li>
))}
</ul>
</div>
</aside>
)
}
/** 窄屏下表单上方的 Logo + 名称(品牌面板此时被隐藏)。 */
function MobileBrand({ brand }: { brand: BrandConfig }) {
return (
<div
data-slot="auth-01-mobile-brand"
className="mb-8 flex items-center justify-center gap-2 lg:hidden"
>
<Logo logo={brand.logo} className="size-10" />
<span className="text-xl font-bold" translate="no">
{brand.name}
</span>
</div>
)
}
export { BrandPanel, MobileBrand }
"use client"
import { KeyRound, Lock, Smartphone, User } from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { formatLabel, resolveLabels, type Auth01Labels } from "./config"
import {
FormError,
LinkButton,
PasswordField,
SubmitButton,
TextField,
ViewHeading,
} from "./form-parts"
import { createCodeSignInSchema, createSignInSchema } from "./schemas"
import { SocialButtons, type SocialProvider } from "./social-buttons"
import { useAsyncAction, useAuthForm } from "./use-auth-form"
type SignInValues =
| { method: "password"; account: string; password: string; remember: boolean }
| { method: "code"; phone: string; code: string }
type SignInMethod = SignInValues["method"]
const CODE_COOLDOWN = 60
function PasswordSignIn({
onSignIn,
onForgotPassword,
labels,
}: {
onSignIn: (values: SignInValues) => Promise<void>
onForgotPassword: () => void
labels?: Partial<Auth01Labels>
}) {
// labels 引用不变时 text 与 schema 都不重建,避免每次输入都重新编译 zod schema
const text = useMemo(() => resolveLabels(labels), [labels])
const schema = useMemo(() => createSignInSchema(text), [text])
const form = useAuthForm(
schema,
{ account: "", password: "", remember: false },
{ fallbackError: text.requestFailed },
)
return (
<form
data-slot="auth-01-sign-in-password"
noValidate
onSubmit={form.handleSubmit((values) =>
onSignIn({ method: "password", ...values }),
)}
className="flex flex-col gap-5"
>
<FormError message={form.formError} />
<TextField
id="sign-in-account"
label={text.accountLabel}
icon={User}
autoComplete="username"
placeholder={text.accountPlaceholder}
value={form.values.account}
error={form.errors.account}
onChange={(event) => form.setValue("account", event.target.value)}
onBlur={() => form.blur("account")}
/>
<PasswordField
id="sign-in-password"
label={text.passwordLabel}
icon={Lock}
autoComplete="current-password"
placeholder={text.passwordPlaceholder}
value={form.values.password}
error={form.errors.password}
onChange={(event) => form.setValue("password", event.target.value)}
onBlur={() => form.blur("password")}
labels={labels}
labelAction={
<LinkButton className="text-xs" onClick={onForgotPassword}>
{text.forgotPassword}
</LinkButton>
}
/>
{/* 记住登录状态是即时生效的开关,不是需要同意的声明,所以用开关而不是复选框 */}
<ToggleSwitch
label={text.remember}
tone="primary"
checked={form.values.remember}
onCheckedChange={(checked) => form.setValue("remember", checked)}
/>
<SubmitButton pending={form.pending} pendingText={text.signInPending}>
{text.signInSubmit}
</SubmitButton>
</form>
)
}
function CodeSignIn({
onSignIn,
onSendCode,
labels,
}: {
onSignIn: (values: SignInValues) => Promise<void>
onSendCode: (phone: string) => Promise<void>
labels?: Partial<Auth01Labels>
}) {
const text = useMemo(() => resolveLabels(labels), [labels])
const schema = useMemo(() => createCodeSignInSchema(text), [text])
const form = useAuthForm(
schema,
{ phone: "", code: "" },
{ fallbackError: text.requestFailed },
)
const sender = useAsyncAction(text.requestFailed)
const [seconds, setSeconds] = useState(0)
useEffect(() => {
if (seconds <= 0) return
const timer = window.setTimeout(
() => setSeconds((value) => value - 1),
1000,
)
return () => window.clearTimeout(timer)
}, [seconds])
const sendCode = async () => {
if (!form.validateField("phone")) return
const sent = await sender.run(() => onSendCode(form.values.phone.trim()))
if (sent) setSeconds(CODE_COOLDOWN)
}
return (
<form
data-slot="auth-01-sign-in-code"
noValidate
onSubmit={form.handleSubmit((values) =>
onSignIn({ method: "code", ...values }),
)}
className="flex flex-col gap-5"
>
<FormError message={form.formError ?? sender.error} />
<TextField
id="sign-in-phone"
label={text.phoneLabel}
icon={Smartphone}
type="tel"
inputMode="numeric"
autoComplete="tel"
placeholder={text.phonePlaceholder}
value={form.values.phone}
error={form.errors.phone}
onChange={(event) => form.setValue("phone", event.target.value)}
onBlur={() => form.blur("phone")}
/>
<TextField
id="sign-in-code"
label={text.codeLabel}
icon={KeyRound}
inputMode="numeric"
maxLength={6}
autoComplete="one-time-code"
placeholder={text.codePlaceholder}
value={form.values.code}
error={form.errors.code}
onChange={(event) => form.setValue("code", event.target.value)}
onBlur={() => form.blur("code")}
adjacent={
<Button
type="button"
variant="outline"
size="lg"
className="w-28 shrink-0 tabular-nums"
disabled={sender.pending || seconds > 0}
onClick={sendCode}
>
{sender.pending
? text.sendCodePending
: seconds > 0
? formatLabel(text.resendCode, { seconds })
: text.sendCode}
</Button>
}
/>
<SubmitButton pending={form.pending} pendingText={text.signInPending}>
{text.signInSubmit}
</SubmitButton>
</form>
)
}
function SignInForm({
socialProviders,
allowSignUp,
onSignIn,
onSendCode,
onSocialSignIn,
onForgotPassword,
onSwitchToSignUp,
labels,
}: {
socialProviders: readonly SocialProvider[]
allowSignUp: boolean
onSignIn: (values: SignInValues) => Promise<void>
onSendCode: (phone: string) => Promise<void>
onSocialSignIn: (provider: SocialProvider) => Promise<void>
onForgotPassword: () => void
onSwitchToSignUp: () => void
labels?: Partial<Auth01Labels>
}) {
const text = resolveLabels(labels)
const [method, setMethod] = useState<SignInMethod>("password")
const methodOptions = [
{ value: "password", label: text.signInPasswordTab },
{ value: "code", label: text.signInCodeTab },
] as const
const social = useAsyncAction(text.requestFailed)
return (
<div data-slot="auth-01-sign-in" className="flex flex-col gap-5">
<ViewHeading
title={text.signInTitle}
description={text.signInDescription}
/>
<div className="flex flex-col gap-3">
<SegmentedControl
aria-label={text.signInMethodLabel}
size="lg"
options={methodOptions}
value={method}
onValueChange={(value) => setMethod(value as SignInMethod)}
/>
{method === "password" ? (
<PasswordSignIn
onSignIn={onSignIn}
onForgotPassword={onForgotPassword}
labels={labels}
/>
) : (
<CodeSignIn
onSignIn={onSignIn}
onSendCode={onSendCode}
labels={labels}
/>
)}
</div>
{socialProviders.length > 0 && (
<>
<div
role="separator"
className="flex items-center gap-3 text-xs text-muted-foreground before:h-px before:flex-1 before:bg-border after:h-px after:flex-1 after:bg-border"
>
{text.socialDivider}
</div>
<FormError message={social.error} />
<SocialButtons
providers={socialProviders}
pending={social.pending}
onSelect={(provider) => social.run(() => onSocialSignIn(provider))}
/>
</>
)}
{allowSignUp && (
<p className="text-center text-sm text-muted-foreground">
{text.noAccount}{" "}
<LinkButton onClick={onSwitchToSignUp}>{text.signUpLink}</LinkButton>
</p>
)}
</div>
)
}
export { SignInForm }
export type { SignInValues }
"use client"
import { Lock, Mail, User } from "lucide-react"
import { useMemo } from "react"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import { resolveLabels, type Auth01Labels } from "./config"
import {
FormError,
LinkButton,
PasswordField,
SubmitButton,
TextField,
ViewHeading,
} from "./form-parts"
import { PasswordStrength } from "./password-strength"
import { createSignUpSchema } from "./schemas"
import { useAuthForm } from "./use-auth-form"
type SignUpValues = { name: string; email: string; password: string }
function SignUpForm({
termsHref,
privacyHref,
onSignUp,
onSwitchToSignIn,
labels,
}: {
termsHref: string
privacyHref: string
onSignUp: (values: SignUpValues) => Promise<void>
onSwitchToSignIn: () => void
labels?: Partial<Auth01Labels>
}) {
// labels 引用不变时 text 与 schema 都不重建,避免每次输入都重新编译 zod schema
const text = useMemo(() => resolveLabels(labels), [labels])
const schema = useMemo(() => createSignUpSchema(text), [text])
const form = useAuthForm(
schema,
{
name: "",
email: "",
password: "",
confirm: "",
terms: false,
},
{ fallbackError: text.requestFailed },
)
return (
<div data-slot="auth-01-sign-up" className="flex flex-col gap-5">
<ViewHeading title={text.signUpTitle} description={text.signUpDescription} />
<form
noValidate
onSubmit={form.handleSubmit(({ name, email, password }) =>
onSignUp({ name: name.trim(), email: email.trim(), password }),
)}
className="flex flex-col gap-4"
>
<FormError message={form.formError} />
<TextField
id="sign-up-name"
label={text.nameLabel}
icon={User}
autoComplete="name"
placeholder={text.namePlaceholder}
value={form.values.name}
error={form.errors.name}
onChange={(event) => form.setValue("name", event.target.value)}
onBlur={() => form.blur("name")}
/>
<TextField
id="sign-up-email"
label={text.emailLabel}
icon={Mail}
type="email"
autoComplete="email"
placeholder={text.emailPlaceholder}
value={form.values.email}
error={form.errors.email}
onChange={(event) => form.setValue("email", event.target.value)}
onBlur={() => form.blur("email")}
/>
<div className="flex flex-col gap-2">
<PasswordField
id="sign-up-password"
label={text.passwordLabel}
icon={Lock}
autoComplete="new-password"
placeholder={text.newPasswordPlaceholder}
value={form.values.password}
error={form.errors.password}
onChange={(event) => form.setValue("password", event.target.value)}
onBlur={() => form.blur("password")}
labels={labels}
/>
<PasswordStrength password={form.values.password} labels={labels} />
</div>
<PasswordField
id="sign-up-confirm"
label={text.confirmPasswordLabel}
icon={Lock}
autoComplete="new-password"
placeholder={text.confirmPasswordPlaceholder}
value={form.values.confirm}
error={form.errors.confirm}
onChange={(event) => form.setValue("confirm", event.target.value)}
onBlur={() => form.blur("confirm")}
labels={labels}
/>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Checkbox
id="sign-up-terms"
checked={form.values.terms}
aria-invalid={form.errors.terms ? true : undefined}
aria-describedby={form.errors.terms ? "sign-up-terms-error" : undefined}
onCheckedChange={(checked) =>
form.setValue("terms", checked === true)
}
/>
<Label
htmlFor="sign-up-terms"
className="cursor-pointer leading-5 font-normal"
>
{text.termsPrefix}
<a
href={termsHref}
className="text-primary underline-offset-4 hover:underline"
>
{text.termsOfService}
</a>
{text.termsConjunction}
<a
href={privacyHref}
className="text-primary underline-offset-4 hover:underline"
>
{text.privacyPolicy}
</a>
</Label>
</div>
{form.errors.terms ? (
<p id="sign-up-terms-error" className="text-xs text-destructive">
{form.errors.terms}
</p>
) : null}
</div>
<SubmitButton pending={form.pending} pendingText={text.signUpPending}>
{text.signUpSubmit}
</SubmitButton>
</form>
<p className="text-center text-sm text-muted-foreground">
{text.haveAccount}{" "}
<LinkButton onClick={onSwitchToSignIn}>{text.signInLink}</LinkButton>
</p>
</div>
)
}
export { SignUpForm }
export type { SignUpValues }
"use client"
import { Mail, MailCheck } from "lucide-react"
import { useMemo, useState } from "react"
import { Button } from "@/components/ui/button"
import { Ripple } from "@/components/ui/ripple/ripple"
import { resolveLabels, splitLabel, type Auth01Labels } from "./config"
import { FormError, SubmitButton, TextField, ViewHeading } from "./form-parts"
import { createForgotPasswordSchema } from "./schemas"
import { useAuthForm } from "./use-auth-form"
function ForgotPasswordForm({
onForgotPassword,
onSwitchToSignIn,
labels,
}: {
onForgotPassword: (email: string) => Promise<void>
onSwitchToSignIn: () => void
labels?: Partial<Auth01Labels>
}) {
// labels 引用不变时 text 与 schema 都不重建,避免每次输入都重新编译 zod schema
const text = useMemo(() => resolveLabels(labels), [labels])
const schema = useMemo(() => createForgotPasswordSchema(text), [text])
const form = useAuthForm(
schema,
{ email: "" },
{ fallbackError: text.requestFailed },
)
const [sentTo, setSentTo] = useState<string | null>(null)
if (sentTo) {
const [before, after] = splitLabel(text.emailSentDescription, "email")
return (
<div
data-slot="auth-01-email-sent"
className="flex flex-col items-center gap-4 text-center"
role="status"
>
{/* 以邮件图标为圆心向外扩散的波纹,表达「已发出」;纯装饰,状态由下方文字播报 */}
<Ripple
size={192}
rings={3}
ringGap={44}
baseSize={56}
duration={3.5}
background="none"
tone="primary"
className="h-48 w-48 max-w-full"
>
<span className="absolute top-1/2 left-1/2 flex size-14 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-edge bg-background text-primary [&_svg]:size-7">
<MailCheck />
</span>
</Ripple>
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">
{text.emailSentTitle}
</h1>
<p className="text-sm leading-6 text-muted-foreground">
{before}
<span className="font-medium text-foreground">{sentTo}</span>
{after}
</p>
</div>
<Button size="lg" className="w-full" onClick={onSwitchToSignIn}>
{text.backToSignIn}
</Button>
</div>
)
}
return (
<div data-slot="auth-01-forgot-password" className="flex flex-col gap-5">
<ViewHeading
title={text.forgotPasswordTitle}
description={text.forgotPasswordDescription}
/>
<form
noValidate
onSubmit={form.handleSubmit(async ({ email }) => {
await onForgotPassword(email.trim())
setSentTo(email.trim())
})}
className="flex flex-col gap-5"
>
<FormError message={form.formError} />
<TextField
id="forgot-email"
label={text.emailLabel}
icon={Mail}
type="email"
autoComplete="email"
placeholder={text.emailPlaceholder}
value={form.values.email}
error={form.errors.email}
onChange={(event) => form.setValue("email", event.target.value)}
onBlur={() => form.blur("email")}
/>
<SubmitButton
pending={form.pending}
pendingText={text.sendResetLinkPending}
>
{text.sendResetLink}
</SubmitButton>
</form>
<Button variant="ghost" size="lg" onClick={onSwitchToSignIn}>
{text.backToSignIn}
</Button>
</div>
)
}
export { ForgotPasswordForm }
"use client"
import { Eye, EyeOff, Loader2, type LucideIcon } from "lucide-react"
import { useState, type ComponentProps, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { cn } from "@/lib/utils"
import { resolveLabels, type Auth01Labels } from "./config"
/** 带图标、错误提示的输入框;错误通过 aria-describedby 关联,读屏会一并读出。 */
function TextField({
id,
label,
icon: Icon,
error,
labelAction,
trailing,
adjacent,
className,
...props
}: Omit<ComponentProps<typeof Input>, "id"> & {
id: string
label: string
icon: LucideIcon
error?: string
/** 标签行右侧的操作,例如「忘记密码?」 */
labelAction?: ReactNode
/** 输入框内右侧的元素,例如显示/隐藏密码按钮 */
trailing?: ReactNode
/** 输入框外右侧的元素,例如「获取验证码」按钮 */
adjacent?: ReactNode
}) {
const errorId = `${id}-error`
return (
<div data-slot="auth-01-text-field" className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={id}>{label}</Label>
{labelAction}
</div>
<div className="flex gap-2">
<div className="relative min-w-0 flex-1">
<Icon
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
/>
<Input
id={id}
aria-invalid={error ? true : undefined}
aria-describedby={error ? errorId : undefined}
className={cn("h-9 pl-9", trailing && "pr-10", className)}
{...props}
/>
{trailing}
</div>
{adjacent}
</div>
{error ? (
<p id={errorId} className="text-xs text-destructive">
{error}
</p>
) : null}
</div>
)
}
/** 带显示/隐藏切换的密码框。 */
function PasswordField({
labels,
...props
}: Omit<ComponentProps<typeof TextField>, "type" | "trailing"> & {
labels?: Partial<Auth01Labels>
}) {
const text = resolveLabels(labels)
const [visible, setVisible] = useState(false)
return (
<TextField
{...props}
type={visible ? "text" : "password"}
trailing={
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setVisible((value) => !value)}
aria-label={visible ? text.hidePassword : text.showPassword}
aria-pressed={visible}
className="absolute inset-y-0 right-1.5 my-auto text-muted-foreground hover:text-foreground"
>
{visible ? <EyeOff aria-hidden="true" /> : <Eye aria-hidden="true" />}
</Button>
}
/>
)
}
/** 表单级错误(接口返回的失败原因等),出现时读屏会立即朗读。 */
function FormError({ message }: { message: string | null }) {
if (!message) return null
return (
<p
data-slot="auth-01-form-error"
role="alert"
className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive"
>
{message}
</p>
)
}
/** 提交按钮:pending 时显示转圈并禁用。 */
function SubmitButton({
pending,
pendingText,
children,
}: {
pending: boolean
pendingText: string
children: ReactNode
}) {
return (
<Button
data-slot="auth-01-submit-button"
type="submit"
size="lg"
className="w-full"
disabled={pending}
>
{pending ? (
<>
<Loader2 aria-hidden="true" className="animate-spin" />
{pendingText}
</>
) : (
children
)}
</Button>
)
}
/** 视图头部:标题 + 说明。每个视图只有一个 h1。 */
function ViewHeading({
title,
description,
}: {
title: string
description: string
}) {
return (
<div data-slot="auth-01-view-heading" className="flex flex-col gap-1">
<h1 className="text-2xl font-bold tracking-tight">{title}</h1>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
)
}
/** 文字链接样式的按钮,用于视图切换。 */
function LinkButton({ className, ...props }: ComponentProps<"button">) {
return (
<button
data-slot="auth-01-link-button"
type="button"
className={cn(
"rounded-sm font-medium text-primary underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
className,
)}
{...props}
/>
)
}
export { FormError, LinkButton, PasswordField, SubmitButton, TextField, ViewHeading }
import { cn } from "@/lib/utils"
import { formatLabel, resolveLabels, type Auth01Labels } from "./config"
// 四段强度条:颜色从 destructive 过渡到主色,跟随主题;文字在 DEFAULT_LABELS 里
const levels = [
{ label: "strengthWeak", bar: "bg-destructive" },
{ label: "strengthFair", bar: "bg-destructive/50" },
{ label: "strengthGood", bar: "bg-primary/60" },
{ label: "strengthStrong", bar: "bg-primary" },
] as const satisfies readonly { label: keyof Auth01Labels; bar: string }[]
/** 按长度与字符种类打分:0 表示尚未输入,1–4 对应 弱 / 一般 / 较强 / 强。 */
function getPasswordStrength(password: string) {
if (!password) return 0
const score =
Number(password.length >= 8) +
Number(password.length >= 12) +
Number(/[a-z]/.test(password) && /[A-Z]/.test(password)) +
Number(/\d/.test(password)) +
Number(/[^A-Za-z0-9]/.test(password))
return Math.min(4, Math.max(1, score <= 1 ? 1 : score >= 4 ? 4 : score))
}
/** 四段强度条 + 文字说明。 */
function PasswordStrength({
password,
labels,
}: {
password: string
labels?: Partial<Auth01Labels>
}) {
const text = resolveLabels(labels)
const strength = getPasswordStrength(password)
const level = strength ? levels[strength - 1] : null
return (
<div
data-slot="auth-01-password-strength"
data-strength={strength}
className="flex items-center gap-3"
>
<div aria-hidden="true" className="grid flex-1 grid-cols-4 gap-1">
{levels.map((item, index) => (
<span
key={item.label}
className={cn(
"h-1 rounded-full bg-muted transition-colors motion-reduce:transition-none",
index < strength && item.bar,
)}
/>
))}
</div>
<span
aria-live="polite"
className="w-16 text-xs whitespace-nowrap text-muted-foreground"
>
{level
? formatLabel(text.passwordStrength, { level: text[level.label] })
: text.passwordStrengthEmpty}
</span>
</div>
)
}
export { PasswordStrength, getPasswordStrength }
import { z } from "zod"
import { formatLabel, type Auth01Labels } from "./config"
/**
* 校验规则常量:改策略只改这里,提示文案里的 {min}、{length} 会自动带入新值。
* 提示文案本身全部来自 labels(config.ts 的 validation* 键),可逐实例覆盖。
*/
/** 密码最少位数 */
const PASSWORD_MIN_LENGTH = 8
/** 姓名最少字符数 */
const NAME_MIN_LENGTH = 2
/** 短信验证码位数 */
const CODE_LENGTH = 6
/** 中国大陆手机号位数,需与 PHONE_PATTERN 的位数一致 */
const PHONE_LENGTH = 11
/** 中国大陆 11 位手机号;面向海外用户时替换这条规则 */
const PHONE_PATTERN = /^1[3-9]\d{9}$/
/** 验证码只接受数字,位数由 CODE_LENGTH 决定 */
const CODE_PATTERN = new RegExp(`^\\d{${CODE_LENGTH}}$`)
function emailField(labels: Auth01Labels) {
return z
.string()
.trim()
.min(1, labels.validationEmailRequired)
.email(labels.validationEmailInvalid)
}
function passwordField(labels: Auth01Labels) {
return z
.string()
.min(
PASSWORD_MIN_LENGTH,
formatLabel(labels.validationPasswordMin, { min: PASSWORD_MIN_LENGTH }),
)
.regex(/[A-Za-z]/, labels.validationPasswordLetter)
.regex(/\d/, labels.validationPasswordDigit)
}
function phoneField(labels: Auth01Labels) {
return z
.string()
.trim()
.regex(
PHONE_PATTERN,
formatLabel(labels.validationPhoneInvalid, { length: PHONE_LENGTH }),
)
}
/** 密码登录。labels 需是合并过默认值的完整文案。 */
function createSignInSchema(labels: Auth01Labels) {
return z.object({
account: z.string().trim().min(1, labels.validationAccountRequired),
password: z.string().min(1, labels.validationPasswordRequired),
remember: z.boolean(),
})
}
/** 验证码登录。 */
function createCodeSignInSchema(labels: Auth01Labels) {
return z.object({
phone: phoneField(labels),
code: z
.string()
.trim()
.regex(
CODE_PATTERN,
formatLabel(labels.validationCodeInvalid, { length: CODE_LENGTH }),
),
})
}
/** 注册:确认密码与条款勾选也在前端校验。 */
function createSignUpSchema(labels: Auth01Labels) {
return z
.object({
name: z
.string()
.trim()
.min(
NAME_MIN_LENGTH,
formatLabel(labels.validationNameMin, { min: NAME_MIN_LENGTH }),
),
email: emailField(labels),
password: passwordField(labels),
confirm: z.string().min(1, labels.validationConfirmRequired),
terms: z.boolean().refine((value) => value, labels.validationTermsRequired),
})
.refine((values) => values.password === values.confirm, {
message: labels.validationPasswordMismatch,
path: ["confirm"],
})
}
/** 找回密码。 */
function createForgotPasswordSchema(labels: Auth01Labels) {
return z.object({ email: emailField(labels) })
}
export {
CODE_LENGTH,
NAME_MIN_LENGTH,
PASSWORD_MIN_LENGTH,
PHONE_LENGTH,
PHONE_PATTERN,
createCodeSignInSchema,
createForgotPasswordSchema,
createSignInSchema,
createSignUpSchema,
}
import type { SVGProps } from "react"
import { Button } from "@/components/ui/button"
type SocialProvider = "github" | "google"
function GitHubIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-slot="auth-01-github-icon"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.68-.22.68-.48v-1.87c-2.78.6-3.37-1.18-3.37-1.18-.45-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.61.07-.61 1 .07 1.53 1.03 1.53 1.03.89 1.53 2.34 1.09 2.91.83.09-.65.35-1.09.64-1.34-2.22-.25-4.56-1.11-4.56-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02A9.6 9.6 0 0 1 12 6.81c.85 0 1.71.11 2.51.34 1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.76c0 .27.18.58.69.48A10 10 0 0 0 12 2Z" />
</svg>
)
}
// 单色 G 标识:用 currentColor,不引入品牌多色,跟随主题
function GoogleIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
data-slot="auth-01-google-icon"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
{...props}
>
<path d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z" />
</svg>
)
}
// 品牌名是专有名词,不放进 labels
const providers = {
github: { label: "GitHub", Icon: GitHubIcon },
google: { label: "Google", Icon: GoogleIcon },
} as const
/** 第三方登录按钮行:点击后交给 onSelect,pending 时全部禁用。 */
function SocialButtons({
providers: enabled,
pending,
onSelect,
}: {
providers: readonly SocialProvider[]
pending: boolean
onSelect: (provider: SocialProvider) => void
}) {
if (!enabled.length) return null
return (
<div
data-slot="auth-01-social-buttons"
className="grid gap-2"
style={{
gridTemplateColumns: `repeat(${enabled.length}, minmax(0, 1fr))`,
}}
>
{enabled.map((provider) => {
const { label, Icon } = providers[provider]
return (
<Button
key={provider}
type="button"
variant="outline"
size="lg"
disabled={pending}
onClick={() => onSelect(provider)}
>
<Icon data-icon="inline-start" />
{label}
</Button>
)
})}
</div>
)
}
export { SocialButtons }
export type { SocialProvider }
"use client"
import { useCallback, useState, type FormEvent } from "react"
import type { z } from "zod"
import { DEFAULT_LABELS } from "./config"
type FieldErrors = Record<string, string>
// 字面量初始值(false、"")会被推断成字面量类型,这里放宽为 boolean / string
type Widen<T> = {
[K in keyof T]: T[K] extends boolean ? boolean : T[K] extends string ? string : T[K]
}
/** 每个字段只保留第一条错误,避免一次堆出一长串提示 */
function toErrors(error: z.ZodError): FieldErrors {
const errors: FieldErrors = {}
for (const issue of error.issues) {
const key = String(issue.path[0] ?? "")
if (key && !(key in errors)) errors[key] = issue.message
}
return errors
}
/** 把未知错误转成可展示的中文文案;没有 message 时用传入的兜底文案。 */
function errorMessage(
error: unknown,
fallback: string = DEFAULT_LABELS.requestFailed,
) {
return error instanceof Error && error.message ? error.message : fallback
}
/** 包装一个异步动作:管理 loading,并把抛出的错误转成可展示的中文文案。 */
function useAsyncAction(fallbackError: string = DEFAULT_LABELS.requestFailed) {
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const run = useCallback(
async (action: () => Promise<void>) => {
setPending(true)
setError(null)
try {
await action()
return true
} catch (caught) {
setError(errorMessage(caught, fallbackError))
return false
} finally {
setPending(false)
}
},
[fallbackError],
)
return { pending, error, run, clearError: () => setError(null) }
}
/**
* 极简受控表单:zod 校验 + 字段错误 + 提交状态。
* 首次提交前,字段失焦时校验该字段;提交失败后,输入时实时重新校验。
*/
function useAuthForm<I extends Record<string, string | boolean>>(
schema: z.ZodType,
initial: I,
options: { fallbackError?: string } = {},
) {
type V = Widen<I>
const [values, setValues] = useState<V>(initial as V)
const [errors, setErrors] = useState<FieldErrors>({})
const [submitted, setSubmitted] = useState(false)
const action = useAsyncAction(options.fallbackError)
const check = useCallback(
(next: V) => {
const result = schema.safeParse(next)
return result.success ? {} : toErrors(result.error)
},
[schema],
)
const setValue = <K extends keyof V & string>(key: K, value: V[K]) => {
const next = { ...values, [key]: value }
setValues(next)
if (submitted) setErrors(check(next))
else if (errors[key]) setErrors((prev) => ({ ...prev, [key]: "" }))
}
const blur = (key: keyof V & string) => {
if (values[key] === "" && !submitted) return
const found = check(values)[key] ?? ""
setErrors((prev) => ({ ...prev, [key]: found }))
}
const handleSubmit =
(onValid: (values: V) => Promise<void>) =>
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
setSubmitted(true)
const found = check(values)
setErrors(found)
if (Object.keys(found).length) return
await action.run(() => onValid(values))
}
return {
values,
errors,
setValue,
blur,
handleSubmit,
pending: action.pending,
formError: action.error,
run: action.run,
/** 校验单个字段并返回是否通过(用于「发送验证码」前校验手机号) */
validateField: (key: keyof V & string) => {
const found = check(values)[key] ?? ""
setSubmitted(true)
setErrors((prev) => ({ ...prev, [key]: found }))
return !found
},
}
}
export { errorMessage, useAsyncAction, useAuthForm }
export type { FieldErrors }
Split Sign-in分屏登录页
左侧品牌面板 + 右侧表单的分屏登录页:登录(密码 / 验证码)、注册(带强度条)、找回密码,zod 校验。
正在加载…
"use client"
import { Settings01 } from "@/components/settings-01/settings-01"
/**
* 设置页入口:用 onSave 把设置存到你自己的后端。
* 回调返回 Promise;失败时 throw new Error("中文原因"),原因会显示在底部保存栏上。
* 想把改动实时应用到页面(例如切换主题),在 onValueChange 里处理。
*/
export default function SettingsPage() {
return (
<Settings01
onSave={async (values) => {
// TODO: 调用你的接口,例如 await fetch("/api/settings", { method: "PUT", body: JSON.stringify(values) })
console.info("save settings", values)
await new Promise((resolve) => setTimeout(resolve, 600))
}}
/>
)
}
"use client"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Check, LoaderCircle, Settings } from "lucide-react"
import { useMemo, useState, type ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { useControllableState } from "@/hooks/use-controllable-state"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import { AppearanceSection } from "./appearance-section"
import {
DEFAULT_VALUES,
SECTION_IDS,
SECTION_KEYS,
formatLabel,
resolveLabels,
type Settings01Labels,
} from "./config"
import { EditorSection } from "./editor-section"
import { NotificationsSection } from "./notifications-section"
import { PrivacySection } from "./privacy-section"
import type { SectionProps, SetValue } from "./section-layout"
import { SectionNav, SectionTabs } from "./section-nav"
import { SoundSection } from "./sound-section"
import type { Settings01SectionId, Settings01Values } from "./types"
import { useSaveAction } from "./use-save-action"
import { isSameValue } from "./utils"
const SECTIONS: Record<
Settings01SectionId,
(props: SectionProps) => ReactNode
> = {
appearance: AppearanceSection,
notifications: NotificationsSection,
sound: SoundSection,
editor: EditorSection,
privacy: PrivacySection,
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
// 未传回调时的示例实现:只在控制台打印,并模拟 0.8 秒的网络请求
async function demoSave(values: Settings01Values) {
console.info("[settings-01] onSave", values)
await wait(800)
}
function pickKeys(
values: Settings01Values,
keys: readonly (keyof Settings01Values)[],
) {
return Object.fromEntries(keys.map((key) => [key, values[key]]))
}
/**
* 设置页:左侧分区导航(窄屏变成顶部分段选择)+ 右侧当前分区的控制面板 + 底部保存栏。
* 值同时支持受控与非受控;保存回调返回 Promise,组件管理 loading,回调 throw 的 Error.message 会显示在保存栏上。
*/
function Settings01({
value: valueProp,
defaultValue,
defaults,
onValueChange,
section: sectionProp,
defaultSection = "appearance",
onSectionChange,
onSave = demoSave,
labels,
className,
}: {
/** 受控的全部设置值 */
value?: Settings01Values
/** 非受控时的初始值,没写的项取 `defaults` */
defaultValue?: Partial<Settings01Values>
/** 出厂默认值,逐项覆盖 config.ts 的 DEFAULT_VALUES;「已修改」「恢复默认」都以它为准 */
defaults?: Partial<Settings01Values>
/** 任何一项变化时回调,参数是变化后的全部设置 */
onValueChange?: (value: Settings01Values) => void
/** 受控的当前分区 */
section?: Settings01SectionId
defaultSection?: Settings01SectionId
onSectionChange?: (section: Settings01SectionId) => void
/** 点击「保存」时调用;失败时 throw new Error("原因"),原因会显示在保存栏 */
onSave?: (values: Settings01Values) => Promise<void>
/** 覆盖默认文案,见 config.ts 的 DEFAULT_LABELS */
labels?: Partial<Settings01Labels>
className?: string
}) {
const text = resolveLabels(labels)
const reducedMotion = useReducedMotion()
const resolvedDefaults = useMemo<Settings01Values>(
() => ({ ...DEFAULT_VALUES, ...defaults }),
[defaults],
)
const [values, setValues] = useControllableState<Settings01Values>({
prop: valueProp,
defaultProp: { ...resolvedDefaults, ...defaultValue },
onChange: onValueChange,
})
const [section, setSection] = useControllableState<Settings01SectionId>({
prop: sectionProp,
defaultProp: defaultSection,
onChange: onSectionChange,
})
// 上一次保存(或首次载入)时的值:和默认值比出「已修改」,和它比出「有未保存的更改」
const [saved, setSaved] = useState(values)
const action = useSaveAction(text.saveFailed)
const set: SetValue = (key, next) => {
setValues((previous) => ({ ...previous, [key]: next }))
action.reset()
}
const modified = (key: keyof Settings01Values) =>
!isSameValue(values[key], resolvedDefaults[key])
const counts = Object.fromEntries(
SECTION_IDS.map((id) => [id, SECTION_KEYS[id].filter(modified).length]),
) as Record<Settings01SectionId, number>
const total = SECTION_IDS.reduce((sum, id) => sum + counts[id], 0)
const dirty = (Object.keys(values) as (keyof Settings01Values)[]).some(
(key) => !isSameValue(values[key], saved[key]),
)
const pending = action.status === "pending"
const resetSection = (id: Settings01SectionId) => {
setValues((previous) => ({
...previous,
...pickKeys(resolvedDefaults, SECTION_KEYS[id]),
}))
action.reset()
}
const resetAll = () => {
setValues({ ...resolvedDefaults })
action.reset()
}
const save = async () => {
const snapshot = values
if (await action.run(() => onSave(snapshot))) setSaved(snapshot)
}
const Section = SECTIONS[section]
const sectionProps: SectionProps = {
values,
labels: text,
set,
modified,
count: counts[section],
onReset: () => resetSection(section),
getCode: () =>
JSON.stringify(pickKeys(values, SECTION_KEYS[section]), null, 2),
}
return (
<div
data-slot="settings-01"
className={cn(
"relative flex min-h-svh flex-col bg-background text-foreground",
className,
)}
// 强调色同时作为 --flow-tone,导航的选中标记等跟着它走
style={flowToneStyle(values.accent)}
>
<header className="border-b border-edge">
<div className="mx-auto flex w-full max-w-6xl items-center gap-3 px-4 py-4 sm:px-6 lg:border-x lg:border-edge">
<span
aria-hidden="true"
className="flex size-8 shrink-0 items-center justify-center rounded-md border border-edge bg-muted/50"
>
<Settings className="size-4" />
</span>
<div className="min-w-0">
<h1 className="text-base leading-tight font-semibold tracking-tight">
{text.title}
</h1>
<p className="truncate text-xs text-muted-foreground">
{text.description}
</p>
</div>
</div>
</header>
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col md:flex-row lg:border-x lg:border-edge">
<SectionNav
section={section}
onSectionChange={setSection}
counts={counts}
labels={text}
/>
<SectionTabs
section={section}
onSectionChange={setSection}
labels={text}
tone={values.accent}
/>
<main className="min-w-0 flex-1 p-4 sm:p-6">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={section}
initial={{ opacity: 0, y: reducedMotion ? 0 : 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reducedMotion ? 0 : -8 }}
transition={{ duration: 0.18 }}
>
<Section {...sectionProps} />
</motion.div>
</AnimatePresence>
</main>
</div>
<footer
aria-label={text.footerLabel}
data-slot="settings-01-footer"
className="sticky bottom-0 z-20 border-t border-edge bg-background/90 backdrop-blur"
>
<div className="mx-auto flex w-full max-w-6xl items-center justify-between gap-3 px-4 py-3 sm:px-6 lg:border-x lg:border-edge">
<div className="min-w-0 text-sm">
<p className="truncate font-medium tabular-nums">
{total > 0
? formatLabel(text.modifiedCount, { count: total })
: text.unmodified}
</p>
{/* 状态行固定占一行高度,避免保存时底栏跳动;出错时同一位置换成红色的错误原因 */}
{action.status === "error" ? (
<p
role="alert"
data-slot="settings-01-error"
className="min-h-4 truncate text-xs text-destructive"
>
{action.error}
</p>
) : (
<p
role="status"
data-slot="settings-01-status"
className="flex min-h-4 items-center gap-1 truncate text-xs text-muted-foreground"
>
{pending ? (
text.saving
) : action.status === "saved" ? (
<>
<Check className="size-3.5 shrink-0" />
{text.saved}
</>
) : dirty ? (
text.unsaved
) : (
text.upToDate
)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
variant="outline"
disabled={total === 0 || pending}
onClick={resetAll}
>
{text.resetAll}
</Button>
<Button disabled={!dirty || pending} onClick={save}>
{pending ? (
<LoaderCircle className="animate-spin" data-icon="inline-start" />
) : null}
{pending ? text.saving : text.save}
</Button>
</div>
</div>
</footer>
</div>
)
}
export { Settings01 }
export type { Settings01Labels, Settings01SectionId, Settings01Values }
import type { Settings01SectionId, Settings01Values } from "./types"
/**
* 设置页所有用户可见的文案。
* 含 {count}、{value}、{section} 的文案在渲染时由 formatLabel 填入实际值。
*/
const DEFAULT_LABELS = {
// 页面
title: "设置",
description: "调整外观、通知、声音与编辑器的偏好",
navLabel: "设置分区",
navSectionModified: "已修改 {count} 项",
// 分区名(导航与面板)
sectionAppearance: "外观",
sectionNotifications: "通知",
sectionSound: "声音与触感",
sectionEditor: "编辑器",
sectionPrivacy: "隐私",
// 窄屏顶部分段选择里的短名,宽度有限所以单独一份
navAppearance: "外观",
navNotifications: "通知",
navSound: "声音",
navEditor: "编辑器",
navPrivacy: "隐私",
// 分区说明
descriptionAppearance: "主题、强调色与界面尺寸,右侧预览会实时变化",
descriptionNotifications: "决定哪些事件会打扰你,以及什么时候不打扰",
descriptionSound: "音量、按键音效与触感反馈",
descriptionEditor: "缩进、行高与空白字符的显示方式",
descriptionPrivacy: "谁能看到你,以及我们收集哪些数据",
// 面板头部
panelModified: "已修改 {count} 项",
panelDefault: "全部为默认值",
copySection: "复制本分区设置",
copiedSection: "已复制",
resetSection: "恢复本分区默认值",
// 外观
groupTheme: "主题",
groupShape: "尺寸与形状",
groupMotion: "动态",
theme: "主题模式",
themeLight: "浅色",
themeDark: "深色",
themeSystem: "跟随系统",
accent: "强调色",
fontSize: "界面字号",
radius: "圆角",
density: "信息密度",
densityCompact: "紧凑",
densityComfortable: "舒适",
densitySpacious: "宽松",
reduceMotion: "减少动态效果",
reduceMotionHint: "关闭动画",
readoutPx: "{value}px",
// 预览卡片
previewTitle: "实时预览",
previewHeading: "本周回顾",
previewBody: "已完成 12 项任务,比上周多 3 项。",
previewAction: "查看详情",
previewProgress: "进度",
previewLive: "实时",
previewModeLight: "浅色",
previewModeDark: "深色",
previewModeSystem: "跟随系统",
// 通知
groupChannels: "通知渠道",
groupQuiet: "免打扰",
notifications: "允许通知",
notificationsHint: "总开关",
notifyMentions: "提及我",
notifyMentionsHint: "有人 @ 我时",
notifyReplies: "评论回复",
notifyRepliesHint: "有人回复时",
notifyEmail: "邮件摘要",
notifyEmailHint: "每天一封",
notifyUpdates: "产品动态",
notifyUpdatesHint: "新功能上线",
quietHours: "免打扰时段",
// 声音与触感
groupVolume: "音量",
groupFeedback: "反馈",
volume: "主音量",
keySound: "按键音效",
keySoundHint: "输入时发声",
soundTheme: "音色",
soundSoft: "柔和",
soundCrisp: "清脆",
soundMechanical: "机械",
haptics: "触感反馈",
hapticsHint: "设备震动",
// 编辑器
groupIndent: "缩进与行距",
groupWhitespace: "空白字符",
indent: "缩进宽度",
indentTwo: "2 空格",
indentFour: "4 空格",
indentTab: "制表符",
lineHeight: "行高",
showTabs: "显示制表符",
showTabsHint: "标出缩进",
editorPreviewTitle: "代码预览",
// 隐私
groupProfile: "个人资料",
groupData: "数据",
visibility: "资料可见范围",
visibilityPublic: "公开",
visibilityTeam: "团队",
visibilityPrivate: "仅自己",
analytics: "使用统计",
analyticsHint: "匿名数据",
crashReports: "崩溃报告",
crashReportsHint: "自动上报",
retention: "历史保留",
readoutDays: "{value} 天",
// 底部操作栏
footerLabel: "保存设置",
modifiedCount: "已修改 {count} 项",
unmodified: "全部为默认值",
unsaved: "有未保存的更改",
upToDate: "没有未保存的更改",
saving: "保存中…",
saved: "已保存",
save: "保存",
resetAll: "恢复默认",
saveFailed: "保存失败,请稍后重试",
} as const
type Settings01Labels = { [Key in keyof typeof DEFAULT_LABELS]: string }
/** 出厂默认值。组件的「已修改」判断、恢复默认和未受控时的初始值都以它为准。 */
const DEFAULT_VALUES: Settings01Values = {
theme: "system",
accent: "primary",
fontSize: 14,
radius: 10,
density: "comfortable",
reduceMotion: false,
notifications: true,
notifyMentions: true,
notifyReplies: true,
notifyEmail: false,
notifyUpdates: false,
quietHours: [22, 31],
volume: 60,
keySound: false,
soundTheme: "soft",
haptics: true,
indent: "2",
lineHeight: 1.6,
showTabs: false,
visibility: "team",
analytics: true,
crashReports: true,
retention: 90,
}
/** 分区的显示顺序 */
const SECTION_IDS: readonly Settings01SectionId[] = [
"appearance",
"notifications",
"sound",
"editor",
"privacy",
]
/** 每个分区管哪些设置项:分区计数、分区复制与分区恢复默认都按它来。 */
const SECTION_KEYS: Record<Settings01SectionId, readonly (keyof Settings01Values)[]> =
{
appearance: [
"theme",
"accent",
"fontSize",
"radius",
"density",
"reduceMotion",
],
notifications: [
"notifications",
"notifyMentions",
"notifyReplies",
"notifyEmail",
"notifyUpdates",
"quietHours",
],
sound: ["volume", "keySound", "soundTheme", "haptics"],
editor: ["indent", "lineHeight", "showTabs"],
privacy: ["visibility", "analytics", "crashReports", "retention"],
}
/** 每个分区用到的文案键:完整名称、说明与窄屏短名。 */
const SECTION_LABEL_KEYS: Record<
Settings01SectionId,
{
title: keyof Settings01Labels
description: keyof Settings01Labels
nav: keyof Settings01Labels
}
> = {
appearance: {
title: "sectionAppearance",
description: "descriptionAppearance",
nav: "navAppearance",
},
notifications: {
title: "sectionNotifications",
description: "descriptionNotifications",
nav: "navNotifications",
},
sound: {
title: "sectionSound",
description: "descriptionSound",
nav: "navSound",
},
editor: {
title: "sectionEditor",
description: "descriptionEditor",
nav: "navEditor",
},
privacy: {
title: "sectionPrivacy",
description: "descriptionPrivacy",
nav: "navPrivacy",
},
}
/** 合并用户传入的文案与默认文案。 */
function resolveLabels(labels?: Partial<Settings01Labels>): Settings01Labels {
return { ...DEFAULT_LABELS, ...labels }
}
/** 把文案里的 {key} 占位符替换成实际值,例如「已修改 {count} 项」。 */
function formatLabel(
template: string,
values: Record<string, string | number>,
): string {
return Object.entries(values).reduce(
(text, [key, value]) => text.replaceAll(`{${key}}`, String(value)),
template,
)
}
export {
DEFAULT_LABELS,
DEFAULT_VALUES,
SECTION_IDS,
SECTION_KEYS,
SECTION_LABEL_KEYS,
formatLabel,
resolveLabels,
}
export type { Settings01Labels }
type ThemeMode = "light" | "dark" | "system"
type Density = "compact" | "comfortable" | "spacious"
type SoundTheme = "soft" | "crisp" | "mechanical"
type IndentWidth = "2" | "4" | "tab"
type ProfileVisibility = "public" | "team" | "private"
/** 设置页的全部取值。键按分区归类,见 config.ts 的 SECTION_KEYS。 */
type Settings01Values = {
// 外观
theme: ThemeMode
/** 强调色,取值是 tone-picker 的色调名(primary、blue……)或任意 CSS 颜色 */
accent: string
/** 界面字号(px) */
fontSize: number
/** 圆角(px) */
radius: number
density: Density
reduceMotion: boolean
// 通知
notifications: boolean
notifyMentions: boolean
notifyReplies: boolean
notifyEmail: boolean
notifyUpdates: boolean
/** 免打扰时段 [起点, 终点],单位小时,范围 12–36(36 即次日 12:00),读数按 24 小时取模 */
quietHours: [number, number]
// 声音与触感
volume: number
keySound: boolean
soundTheme: SoundTheme
haptics: boolean
// 编辑器
indent: IndentWidth
lineHeight: number
showTabs: boolean
// 隐私
visibility: ProfileVisibility
analytics: boolean
crashReports: boolean
/** 历史记录保留天数 */
retention: number
}
type Settings01SectionId =
| "appearance"
| "notifications"
| "sound"
| "editor"
| "privacy"
export type {
Density,
IndentWidth,
ProfileVisibility,
Settings01SectionId,
Settings01Values,
SoundTheme,
ThemeMode,
}
import { DEFAULT_LABELS } from "./config"
/** 两个设置值是否相同;区间(数组)逐项比较。 */
function isSameValue(a: unknown, b: unknown): boolean {
if (Array.isArray(a) && Array.isArray(b))
return a.length === b.length && a.every((item, index) => item === b[index])
return Object.is(a, b)
}
/**
* 把小时数格式化成「22:00」。
* 免打扰滑块的范围是 12–36(跨午夜),所以先对 24 取模;用 Intl 并固定 UTC,保证服务端与客户端一致。
*/
function formatHour(hour: number, locale: string = "zh-CN"): string {
const whole = Math.floor(hour)
const minutes = Math.round((hour - whole) * 60)
return new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
timeZone: "UTC",
}).format(new Date(Date.UTC(1970, 0, 1, whole % 24, minutes)))
}
/** 把未知错误转成可展示的文案;没有 message 时用兜底文案。 */
function errorMessage(
error: unknown,
fallback: string = DEFAULT_LABELS.saveFailed,
) {
return error instanceof Error && error.message ? error.message : fallback
}
export { errorMessage, formatHour, isSameValue }
import type * as React from "react"
import { useId } from "react"
import { cn } from "@/lib/utils"
import {
ControlPanel,
ControlPanelActions,
ControlPanelCopy,
ControlPanelHeader,
ControlPanelReset,
ControlPanelTitle,
} from "@/components/ui/control-panel/control-panel"
import {
SECTION_LABEL_KEYS,
formatLabel,
type Settings01Labels,
} from "./config"
import type { Settings01SectionId, Settings01Values } from "./types"
type SetValue = <Key extends keyof Settings01Values>(
key: Key,
value: Settings01Values[Key],
) => void
/** 每个分区组件收到的同一组属性 */
type SectionProps = {
values: Settings01Values
labels: Settings01Labels
set: SetValue
/** 某一项是否与默认值不同,传给控件的 `modified` */
modified: (key: keyof Settings01Values) => boolean
/** 本分区有几项不是默认值 */
count: number
/** 把本分区恢复为默认值 */
onReset: () => void
/** 本分区当前取值的 JSON,复制按钮用 */
getCode: () => string
}
/**
* 分区外壳:标题与说明 + 控制面板(头部显示本分区已修改项数,带复制与恢复)。
* 有 `aside` 时在宽屏放到右侧,窄屏排在面板上方。
*/
function SectionLayout({
id,
labels,
count,
onReset,
getCode,
aside,
children,
}: Pick<SectionProps, "labels" | "count" | "onReset" | "getCode"> & {
id: Settings01SectionId
aside?: React.ReactNode
children: React.ReactNode
}) {
const headingId = useId()
const keys = SECTION_LABEL_KEYS[id]
return (
<section
id={`settings-01-${id}`}
aria-labelledby={headingId}
data-slot="settings-01-section"
data-section={id}
className="flex flex-col gap-4"
>
<header>
<h2
id={headingId}
className="text-base font-semibold tracking-tight"
>
{labels[keys.title]}
</h2>
<p className="mt-1 text-sm leading-6 text-muted-foreground">
{labels[keys.description]}
</p>
</header>
<div
className={cn(
"grid items-start gap-6",
aside && "lg:grid-cols-[minmax(0,1fr)_16rem]",
)}
>
<ControlPanel className={cn("max-w-2xl", aside && "lg:max-w-none")}>
<ControlPanelHeader>
<ControlPanelTitle>
{count > 0
? formatLabel(labels.panelModified, { count })
: labels.panelDefault}
</ControlPanelTitle>
<ControlPanelActions>
<ControlPanelCopy
code={getCode}
label={labels.copySection}
copiedLabel={labels.copiedSection}
/>
<ControlPanelReset
label={labels.resetSection}
disabled={count === 0}
onClick={onReset}
/>
</ControlPanelActions>
</ControlPanelHeader>
{children}
</ControlPanel>
{aside ? (
<div
data-slot="settings-01-aside"
className="order-first lg:sticky lg:top-6 lg:order-none"
>
{aside}
</div>
) : null}
</div>
</section>
)
}
export { SectionLayout }
export type { SectionProps, SetValue }
import type * as React from "react"
import { Bell, Code, Palette, ShieldCheck, Volume2 } from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import {
SECTION_IDS,
SECTION_LABEL_KEYS,
formatLabel,
type Settings01Labels,
} from "./config"
import type { Settings01SectionId } from "./types"
const SECTION_ICONS: Record<
Settings01SectionId,
React.ComponentType<{ className?: string }>
> = {
appearance: Palette,
notifications: Bell,
sound: Volume2,
editor: Code,
privacy: ShieldCheck,
}
/** 宽屏左侧的分区导航:一行一个分区,右侧小圆标显示该分区已修改的项数。 */
function SectionNav({
section,
onSectionChange,
counts,
labels,
}: {
section: Settings01SectionId
onSectionChange: (section: Settings01SectionId) => void
counts: Record<Settings01SectionId, number>
labels: Settings01Labels
}) {
return (
<nav
aria-label={labels.navLabel}
data-slot="settings-01-nav"
className="hidden w-52 shrink-0 flex-col border-r border-edge md:flex"
>
{SECTION_IDS.map((id) => {
const Icon = SECTION_ICONS[id]
const active = id === section
const count = counts[id]
return (
<button
key={id}
type="button"
aria-current={active ? "page" : undefined}
data-slot="settings-01-nav-item"
onClick={() => onSectionChange(id)}
className="relative flex h-10 w-full shrink-0 cursor-pointer items-center gap-2.5 border-b border-edge px-4 text-start text-sm text-muted-foreground outline-none transition-colors before:absolute before:inset-y-0 before:left-0 before:w-0.5 hover:bg-muted/50 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:ring-inset aria-[current=page]:bg-muted/60 aria-[current=page]:font-medium aria-[current=page]:text-foreground aria-[current=page]:before:bg-(--flow-tone)"
>
<Icon className="size-4 shrink-0" />
<span className="truncate">{labels[SECTION_LABEL_KEYS[id].title]}</span>
{count > 0 ? (
<span
data-slot="settings-01-nav-count"
title={formatLabel(labels.navSectionModified, { count })}
className="ms-auto min-w-5 rounded-full bg-foreground/10 px-1.5 text-center text-[11px] leading-5 text-foreground tabular-nums"
>
{count}
<span className="sr-only">
{formatLabel(labels.navSectionModified, { count })}
</span>
</span>
) : null}
</button>
)
})}
</nav>
)
}
/** 窄屏顶部的分段选择:用短名,五项刚好排进 375px。 */
function SectionTabs({
section,
onSectionChange,
labels,
tone,
}: {
section: Settings01SectionId
onSectionChange: (section: Settings01SectionId) => void
labels: Settings01Labels
tone: string
}) {
return (
<div
data-slot="settings-01-tabs"
className="border-b border-edge p-4 sm:px-6 md:hidden"
>
<SegmentedControl
aria-label={labels.navLabel}
tone={tone}
value={section}
onValueChange={(next) => onSectionChange(next as Settings01SectionId)}
options={SECTION_IDS.map((id) => ({
value: id,
label: labels[SECTION_LABEL_KEYS[id].nav],
}))}
/>
</div>
)
}
export { SectionNav, SectionTabs }
import type * as React from "react"
import { Monitor, Moon, Sparkles, Sun } from "lucide-react"
import { cn } from "@/lib/utils"
import { flowToneStyle } from "@/components/ui/flow-diagram/tone"
import type { Settings01Labels } from "./config"
import type { Density, Settings01Values, ThemeMode } from "./types"
const DENSITY_CLASSES: Record<Density, string> = {
compact: "gap-2 p-3",
comfortable: "gap-3 p-4",
spacious: "gap-4 p-5",
}
const MODE_ICONS: Record<ThemeMode, React.ComponentType<{ className?: string }>> =
{ light: Sun, dark: Moon, system: Monitor }
/** 预览卡片的外框:标题栏 + 内容区。纯展示,对读屏隐藏。 */
function PreviewFrame({
title,
badge,
dark = false,
className,
style,
children,
}: {
title: string
badge?: React.ReactNode
/** 在卡片内套用深色主题变量 */
dark?: boolean
className?: string
style?: React.CSSProperties
children: React.ReactNode
}) {
return (
<div
aria-hidden="true"
data-slot="settings-01-preview"
className={cn(
"overflow-hidden rounded-lg border border-edge bg-muted/40 text-foreground",
dark && "dark bg-background",
className,
)}
style={style}
>
<div className="flex h-10 items-center justify-between gap-2 border-b border-edge px-3 text-xs text-muted-foreground">
<span className="truncate">{title}</span>
{badge ? (
<span className="flex shrink-0 items-center gap-1 [&_svg]:size-3.5">
{badge}
</span>
) : null}
</div>
{children}
</div>
)
}
/**
* 外观预览:一张小卡片,随主题模式、强调色、字号、圆角、信息密度和动态效果实时变化。
* 深色用 `.dark` 变量在卡片内套用;站点本身已是深色时,浅色无法在卡片里反向还原,所以标题栏另外写出当前模式。
*/
function AppearancePreview({
values,
labels,
}: {
values: Settings01Values
labels: Settings01Labels
}) {
const ModeIcon = MODE_ICONS[values.theme]
const modeLabel = {
light: labels.previewModeLight,
dark: labels.previewModeDark,
system: labels.previewModeSystem,
}[values.theme]
return (
<PreviewFrame
title={labels.previewTitle}
dark={values.theme === "dark"}
badge={
<>
<ModeIcon />
{modeLabel}
</>
}
style={
{
...flowToneStyle(values.accent),
"--preview-radius": `${values.radius}px`,
} as React.CSSProperties
}
>
<div className="p-3">
<div
className={cn(
"flex flex-col rounded-(--preview-radius) border border-edge bg-background shadow-xs transition-[gap,padding] duration-200 motion-reduce:transition-none",
DENSITY_CLASSES[values.density],
)}
style={{ fontSize: values.fontSize }}
>
<div className="flex items-center gap-2.5">
<span className="flex size-8 shrink-0 items-center justify-center rounded-(--preview-radius) bg-(--flow-tone)/15 text-(--flow-tone)">
<Sparkles className="size-4" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[1.1em] leading-tight font-medium">
{labels.previewHeading}
</p>
<p className="flex items-center gap-1.5 text-[0.8em] text-muted-foreground">
<span
className={cn(
"size-1.5 rounded-full bg-(--flow-tone)",
!values.reduceMotion && "animate-pulse motion-reduce:animate-none",
)}
/>
{labels.previewLive}
</p>
</div>
</div>
<p className="text-[1em] leading-snug text-muted-foreground">
{labels.previewBody}
</p>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between text-[0.8em] text-muted-foreground">
<span>{labels.previewProgress}</span>
<span className="tabular-nums">72%</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full w-[72%] rounded-full bg-(--flow-tone)" />
</div>
</div>
{/* 模拟按钮:高度取默认档 32px,圆角跟随预览圆角 */}
<span className="inline-flex h-8 items-center justify-center self-start rounded-(--preview-radius) border border-(--flow-tone) bg-(--flow-tone)/10 px-3 text-[0.9em] font-medium">
{labels.previewAction}
</span>
</div>
</div>
</PreviewFrame>
)
}
/** 示例代码:level 是嵌套层级 */
const SAMPLE_CODE = [
{ level: 0, text: "function greet(name) {" },
{ level: 1, text: "if (name) {" },
{ level: 2, text: "return `Hi, ${name}`" },
{ level: 1, text: "}" },
{ level: 1, text: 'return "Hi"' },
{ level: 0, text: "}" },
] as const
const INDENT_WIDTH = { "2": 2, "4": 4, tab: 4 } as const
/** 编辑器预览:缩进宽度、行高与空白字符的显示会实时变化。 */
function EditorPreview({
values,
labels,
}: {
values: Settings01Values
labels: Settings01Labels
}) {
const width = INDENT_WIDTH[values.indent]
const marker = values.indent === "tab" ? "→" : "·".repeat(width)
return (
<PreviewFrame title={labels.editorPreviewTitle}>
<pre
className="overflow-hidden bg-background p-3 font-mono text-xs"
style={{ lineHeight: values.lineHeight }}
>
{SAMPLE_CODE.map((line, index) => (
<div key={index} className="flex">
<span className="w-5 shrink-0 pe-2 text-end text-muted-foreground/60 tabular-nums select-none">
{index + 1}
</span>
{Array.from({ length: line.level }, (_, unit) => (
<span
key={unit}
className="inline-block shrink-0 overflow-hidden text-muted-foreground/50"
style={{ width: `${width}ch` }}
>
{values.showTabs ? marker : ""}
</span>
))}
<span className="whitespace-pre">{line.text}</span>
</div>
))}
</pre>
</PreviewFrame>
)
}
export { AppearancePreview, EditorPreview }
import { Monitor, Moon, Sun } from "lucide-react"
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { TonePicker } from "@/components/ui/tone-picker/tone-picker"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { formatLabel } from "./config"
import { AppearancePreview } from "./previews"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { Density, ThemeMode } from "./types"
/** 外观:主题、强调色、字号、圆角、信息密度、动态效果,右侧是实时预览。 */
function AppearanceSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout
id="appearance"
{...props}
aside={<AppearancePreview values={values} labels={labels} />}
>
<ControlPanelGroup title={labels.groupTheme}>
<SegmentedControl
label={labels.theme}
tone={tone}
value={values.theme}
onValueChange={(next) => set("theme", next as ThemeMode)}
modified={modified("theme")}
options={[
{ value: "light", label: labels.themeLight, icon: <Sun /> },
{ value: "dark", label: labels.themeDark, icon: <Moon /> },
{ value: "system", label: labels.themeSystem, icon: <Monitor /> },
]}
/>
<TonePicker
label={labels.accent}
value={values.accent}
onValueChange={(next) => set("accent", next)}
modified={modified("accent")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupShape} columns={2}>
<ValueSlider
label={labels.fontSize}
tone={tone}
min={12}
max={20}
step={1}
editable
formatValue={(value) =>
formatLabel(labels.readoutPx, { value })
}
value={values.fontSize}
onValueChange={(next) => set("fontSize", next)}
modified={modified("fontSize")}
/>
<ValueSlider
label={labels.radius}
tone={tone}
min={0}
max={20}
step={1}
editable
formatValue={(value) =>
formatLabel(labels.readoutPx, { value })
}
value={values.radius}
onValueChange={(next) => set("radius", next)}
modified={modified("radius")}
/>
<SegmentedControl
className="sm:col-span-2"
label={labels.density}
tone={tone}
value={values.density}
onValueChange={(next) => set("density", next as Density)}
modified={modified("density")}
options={[
{ value: "compact", label: labels.densityCompact },
{ value: "comfortable", label: labels.densityComfortable },
{ value: "spacious", label: labels.densitySpacious },
]}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupMotion}>
<ToggleSwitch
label={labels.reduceMotion}
description={labels.reduceMotionHint}
tone={tone}
checked={values.reduceMotion}
onCheckedChange={(next) => set("reduceMotion", next)}
modified={modified("reduceMotion")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { AppearanceSection }
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { SectionLayout, type SectionProps } from "./section-layout"
import { formatHour } from "./utils"
/** 一天里从中午到次日中午,免打扰通常跨午夜,所以滑块范围取 12–36 */
const QUIET_MIN = 12
const QUIET_MAX = 36
/** 通知:总开关控制其余开关的禁用态,免打扰时段是区间滑块。 */
function NotificationsSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
const off = !values.notifications
return (
<SectionLayout id="notifications" {...props}>
<ControlPanelGroup title={labels.groupChannels} columns={2}>
<ToggleSwitch
className="sm:col-span-2"
label={labels.notifications}
description={labels.notificationsHint}
tone={tone}
checked={values.notifications}
onCheckedChange={(next) => set("notifications", next)}
modified={modified("notifications")}
/>
<ToggleSwitch
label={labels.notifyMentions}
description={labels.notifyMentionsHint}
tone={tone}
disabled={off}
checked={values.notifyMentions}
onCheckedChange={(next) => set("notifyMentions", next)}
modified={modified("notifyMentions")}
/>
<ToggleSwitch
label={labels.notifyReplies}
description={labels.notifyRepliesHint}
tone={tone}
disabled={off}
checked={values.notifyReplies}
onCheckedChange={(next) => set("notifyReplies", next)}
modified={modified("notifyReplies")}
/>
<ToggleSwitch
label={labels.notifyEmail}
description={labels.notifyEmailHint}
tone={tone}
disabled={off}
checked={values.notifyEmail}
onCheckedChange={(next) => set("notifyEmail", next)}
modified={modified("notifyEmail")}
/>
<ToggleSwitch
label={labels.notifyUpdates}
description={labels.notifyUpdatesHint}
tone={tone}
disabled={off}
checked={values.notifyUpdates}
onCheckedChange={(next) => set("notifyUpdates", next)}
modified={modified("notifyUpdates")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupQuiet}>
{/* 区间读数较长,用矮轨让标签与读数排在轨道上方,窄屏也不会被手柄压住 */}
<ValueSlider
label={labels.quietHours}
variant="slim"
tone={tone}
min={QUIET_MIN}
max={QUIET_MAX}
step={0.5}
largeStep={3}
minStepsBetweenValues={1}
ticks={[18, 24, 30]}
disabled={off}
formatValue={formatHour}
value={values.quietHours}
onValueChange={(next) => set("quietHours", [next[0], next[1]])}
modified={modified("quietHours")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { NotificationsSection }
import { Volume2, VolumeX } from "lucide-react"
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { SoundTheme } from "./types"
/** 声音与触感:主音量带拖动音效,音色在按键音效关闭时禁用。 */
function SoundSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout id="sound" {...props}>
<ControlPanelGroup title={labels.groupVolume}>
<ValueSlider
label={labels.volume}
tone={tone}
sound
editable
min={0}
max={100}
step={1}
startIcon={<VolumeX />}
endIcon={<Volume2 />}
value={values.volume}
onValueChange={(next) => set("volume", next)}
modified={modified("volume")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupFeedback} columns={2}>
<ToggleSwitch
label={labels.keySound}
description={labels.keySoundHint}
tone={tone}
checked={values.keySound}
onCheckedChange={(next) => set("keySound", next)}
modified={modified("keySound")}
/>
<ToggleSwitch
label={labels.haptics}
description={labels.hapticsHint}
tone={tone}
checked={values.haptics}
onCheckedChange={(next) => set("haptics", next)}
modified={modified("haptics")}
/>
<SegmentedControl
className="sm:col-span-2"
label={labels.soundTheme}
tone={tone}
disabled={!values.keySound}
value={values.soundTheme}
onValueChange={(next) => set("soundTheme", next as SoundTheme)}
modified={modified("soundTheme")}
options={[
{ value: "soft", label: labels.soundSoft },
{ value: "crisp", label: labels.soundCrisp },
{ value: "mechanical", label: labels.soundMechanical },
]}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { SoundSection }
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { EditorPreview } from "./previews"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { IndentWidth } from "./types"
/** 编辑器:缩进、行高与制表符显示,右侧是代码预览。 */
function EditorSection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout
id="editor"
{...props}
aside={<EditorPreview values={values} labels={labels} />}
>
<ControlPanelGroup title={labels.groupIndent}>
<SegmentedControl
label={labels.indent}
tone={tone}
value={values.indent}
onValueChange={(next) => set("indent", next as IndentWidth)}
modified={modified("indent")}
options={[
{ value: "2", label: labels.indentTwo },
{ value: "4", label: labels.indentFour },
{ value: "tab", label: labels.indentTab },
]}
/>
<ValueSlider
label={labels.lineHeight}
tone={tone}
editable
min={1.2}
max={2}
step={0.1}
value={values.lineHeight}
onValueChange={(next) => set("lineHeight", next)}
modified={modified("lineHeight")}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupWhitespace}>
<ToggleSwitch
label={labels.showTabs}
description={labels.showTabsHint}
tone={tone}
checked={values.showTabs}
onCheckedChange={(next) => set("showTabs", next)}
modified={modified("showTabs")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { EditorSection }
import { ControlPanelGroup } from "@/components/ui/control-panel/control-panel"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import { ToggleSwitch } from "@/components/ui/toggle-switch/toggle-switch"
import { ValueSlider } from "@/components/ui/value-slider/value-slider"
import { formatLabel } from "./config"
import { SectionLayout, type SectionProps } from "./section-layout"
import type { ProfileVisibility } from "./types"
/** 隐私:资料可见范围、数据收集开关与历史保留天数。 */
function PrivacySection(props: SectionProps) {
const { values, labels, set, modified } = props
const tone = values.accent
return (
<SectionLayout id="privacy" {...props}>
<ControlPanelGroup title={labels.groupProfile}>
<SegmentedControl
label={labels.visibility}
tone={tone}
value={values.visibility}
onValueChange={(next) => set("visibility", next as ProfileVisibility)}
modified={modified("visibility")}
options={[
{ value: "public", label: labels.visibilityPublic },
{ value: "team", label: labels.visibilityTeam },
{ value: "private", label: labels.visibilityPrivate },
]}
/>
</ControlPanelGroup>
<ControlPanelGroup title={labels.groupData} columns={2}>
<ToggleSwitch
label={labels.analytics}
description={labels.analyticsHint}
tone={tone}
checked={values.analytics}
onCheckedChange={(next) => set("analytics", next)}
modified={modified("analytics")}
/>
<ToggleSwitch
label={labels.crashReports}
description={labels.crashReportsHint}
tone={tone}
checked={values.crashReports}
onCheckedChange={(next) => set("crashReports", next)}
modified={modified("crashReports")}
/>
<ValueSlider
className="sm:col-span-2"
label={labels.retention}
tone={tone}
editable
min={7}
max={365}
step={1}
largeStep={30}
ticks={[30, 90, 180]}
snapToTicks
formatValue={(value) => formatLabel(labels.readoutDays, { value })}
value={values.retention}
onValueChange={(next) => set("retention", next)}
modified={modified("retention")}
/>
</ControlPanelGroup>
</SectionLayout>
)
}
export { PrivacySection }
"use client"
import { useCallback, useState } from "react"
import { DEFAULT_LABELS } from "./config"
import { errorMessage } from "./utils"
type SaveStatus = "idle" | "pending" | "saved" | "error"
/** 包装保存动作:管理 loading,成功后记为 saved,抛出的错误转成可展示的文案。 */
function useSaveAction(fallbackError: string = DEFAULT_LABELS.saveFailed) {
const [status, setStatus] = useState<SaveStatus>("idle")
const [error, setError] = useState<string | null>(null)
const run = useCallback(
async (action: () => Promise<void>) => {
setStatus("pending")
setError(null)
try {
await action()
setStatus("saved")
return true
} catch (caught) {
setError(errorMessage(caught, fallbackError))
setStatus("error")
return false
}
},
[fallbackError],
)
/** 回到空闲:设置被再次修改后,「已保存」「出错」的提示就该消失 */
const reset = useCallback(() => {
setStatus("idle")
setError(null)
}, [])
return { status, error, run, reset }
}
export { useSaveAction }
export type { SaveStatus }
Settings Page设置页
用方条控件拼成的完整设置页:分区导航、实时预览、已修改计数、恢复默认与异步保存。
正在加载…
"use client"
import {
useEffect,
useState,
type CSSProperties,
type ComponentProps,
type ReactNode,
} from "react"
import { motion, useReducedMotion } from "motion/react"
import { cva } from "class-variance-authority"
import { MessageSquare } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import {
AgentChatProvider,
useAgentChat,
useAgentContext,
} from "./agent-chat-context"
import { AgentChatBody } from "./agent-chat-body"
import { useAgentChatPosition } from "./use-agent-chat-position"
import type { AgentChatMode } from "./types"
import type { AgentChatLabels } from "./config"
const agentChatTriggerVariants = cva("", {
variants: {
variant: {
fab: "fixed right-4 bottom-4 z-40 size-12 rounded-full shadow-lg",
button: "",
},
},
})
function AgentChatTrigger({
className,
variant = "fab",
...props
}: Omit<ComponentProps<typeof Button>, "variant"> & {
variant?: "fab" | "button"
}) {
const { open, setOpen, mode, labels } = useAgentContext()
if (variant === "fab" && (mode !== "floating" || open)) return null
return (
<Button
data-slot="agent-chat-trigger"
data-state={open ? "open" : "closed"}
data-mode={mode}
className={cn(agentChatTriggerVariants({ variant }), className)}
variant={variant === "fab" ? "default" : "outline"}
size={variant === "fab" ? "icon" : "sm"}
aria-label={open ? labels.closeTrigger : labels.openTrigger}
aria-expanded={open}
{...props}
onClick={(event) => {
props.onClick?.(event)
if (!event.defaultPrevented) setOpen(!open)
}}
>
<MessageSquare />
{variant === "button" ? labels.title : null}
</Button>
)
}
/** 从工作区发起一个上下文问题,仍使用 Provider 中的同一段会话。 */
function AgentChatSuggestion({
question,
children,
className,
...props
}: ComponentProps<typeof Button> & { question: string; children?: ReactNode }) {
const { chat, setOpen, updateScroll } = useAgentContext()
const busy = chat.status === "streaming" || chat.status === "submitted"
return (
<Button
data-slot="agent-chat-suggestion"
variant="link"
className={className}
{...props}
disabled={busy || props.disabled}
onClick={(event) => {
props.onClick?.(event)
if (event.defaultPrevented) return
setOpen(true)
updateScroll({ follow: true })
void chat.sendMessage({ text: question })
}}
>
{children ?? question}
</Button>
)
}
function AgentChatPanel({
className,
id,
draggable = true,
}: Pick<ComponentProps<"aside">, "className" | "id"> & {
/**
* 桌面浮动模式下是否允许拖动面板;小于 768px 的 Sheet 始终不可拖动
* @default true
*/
draggable?: boolean
}) {
const { mode, open, setOpen, title, labels } = useAgentContext()
const [mobile, setMobile] = useState(false)
const [width, setWidth] = useState(400)
const reduced = useReducedMotion()
const movable = mode === "floating" && !mobile && draggable
const { panelRef, position, headerProps, onMoveKeyDown, reset } =
useAgentChatPosition({
enabled: movable && open,
active: mode === "floating" && !mobile && open,
})
useEffect(() => {
const query = window.matchMedia("(max-width: 767px)")
const update = () => setMobile(query.matches)
update()
query.addEventListener("change", update)
return () => query.removeEventListener("change", update)
}, [])
if (!open) return null
if (mobile)
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetContent
id={id}
data-slot="agent-chat-panel"
data-mode={mode}
data-state="open"
side="bottom"
showCloseButton={false}
className={cn(
"h-[92dvh]! max-h-[92dvh] gap-0 rounded-t-2xl motion-reduce:transition-none",
className,
)}
>
<SheetTitle className="sr-only">{title}</SheetTitle>
<AgentChatBody />
</SheetContent>
</Sheet>
)
return (
<motion.aside
ref={panelRef}
data-draggable={movable || undefined}
id={id}
data-slot="agent-chat-panel"
data-state="open"
aria-label={title}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
setOpen(false)
}
}}
data-mode={mode}
initial={reduced ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: reduced ? 0 : 0.2 }}
style={
{
"--agent-width": `${width}px`,
...(mode === "floating" && position
? {
left: position.x,
top: position.y,
right: "auto",
bottom: "auto",
}
: {}),
} as CSSProperties
}
className={cn(
"flex min-h-0 flex-col bg-background text-foreground data-[dragging]:cursor-grabbing data-[dragging]:select-none",
mode === "embedded"
? "relative h-full w-(--agent-width) shrink-0 border-l border-edge"
: "fixed right-4 bottom-4 z-40 h-[min(600px,calc(100dvh-2rem))] w-[400px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-edge shadow-xl",
className,
)}
>
{mode === "embedded" ? (
<div
data-slot="agent-chat-resize-handle"
role="separator"
aria-label={labels.resize}
aria-orientation="vertical"
aria-valuemin={320}
aria-valuemax={640}
aria-valuenow={width}
tabIndex={0}
className="absolute inset-y-0 -left-1 w-2 cursor-col-resize touch-none rounded select-none focus-visible:bg-primary/20 focus-visible:outline-none"
onKeyDown={(event) => {
if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
event.preventDefault()
setWidth((value) =>
Math.max(
320,
Math.min(640, value + (event.key === "ArrowLeft" ? 16 : -16)),
),
)
}
}}
onPointerDown={(event) => {
event.currentTarget.setPointerCapture(event.pointerId)
event.preventDefault()
}}
onPointerMove={(event) => {
if (event.currentTarget.hasPointerCapture(event.pointerId))
setWidth(
Math.max(
320,
Math.min(
640,
event.currentTarget.parentElement!.getBoundingClientRect()
.right - event.clientX,
),
),
)
}}
onPointerUp={(event) => {
event.currentTarget.releasePointerCapture(event.pointerId)
}}
/>
) : null}
<AgentChatBody
draggable={movable}
headerProps={headerProps}
onMoveKeyDown={onMoveKeyDown}
onResetPosition={reset}
/>
</motion.aside>
)
}
export {
AgentChatProvider,
AgentChatPanel,
AgentChatTrigger,
AgentChatSuggestion,
useAgentChat,
agentChatTriggerVariants,
}
export type { AgentChatMode, AgentChatLabels }
"use client"
import {
ArrowUpRight,
Command,
PanelRight,
SquareMousePointer,
} from "lucide-react"
import { SegmentedControl } from "@/components/ui/segmented-control/segmented-control"
import {
AgentChatPanel,
AgentChatProvider,
AgentChatTrigger,
AgentChatSuggestion,
useAgentChat,
type AgentChatMode,
} from "@/components/agent-chat/agent-chat"
import {
initialMessages,
scenarios,
} from "@/components/agent-chat/demo-data"
import { DEFAULT_LABELS as labels } from "@/components/agent-chat/config"
function AgentChatWorkspace() {
const { mode, setMode, setOpen } = useAgentChat()
return (
<div
data-slot="agent-chat-workspace"
className="flex h-dvh min-h-0 flex-col overflow-hidden bg-background text-foreground"
>
<a href="#agent-workspace" className="sr-only focus:not-sr-only">
{labels.skip}
</a>
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-edge px-5">
<span className="flex items-center gap-2 text-sm font-medium">
<Command className="size-5" />
{labels.workspace}
<span className="hidden text-muted-foreground sm:inline">
{labels.workspacePath}
</span>
</span>
<AgentChatTrigger variant="button" />
</header>
<div className="flex min-h-0 flex-1">
<main
id="agent-workspace"
className="min-w-0 flex-1 overflow-y-auto p-5 md:p-10"
>
<div className="mx-auto flex max-w-2xl flex-col gap-8 py-4 md:py-10">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="size-1.5 rounded-full bg-primary" />
{labels.demoWorkspace}
</div>
<div>
<p className="mb-3 text-xs tracking-widest text-muted-foreground">
{labels.eyebrow}
</p>
<h1 className="text-3xl font-medium tracking-tight md:text-4xl">
{labels.workspaceTitle}
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-muted-foreground">
{labels.workspaceDescription}
</p>
</div>
<div className="flex flex-col gap-3">
{scenarios.map(({ icon: Icon, label, question, description }) => (
<article
key={label}
className="flex items-start gap-3 rounded-xl border border-edge p-4"
>
<Icon className="mt-1 size-5 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-medium">{label}</h2>
<p className="mt-1 text-xs leading-6 text-muted-foreground">
{description}
</p>
<AgentChatSuggestion
question={question}
className="mt-1 h-auto max-w-full justify-start p-0 text-left whitespace-normal"
>
{question}
<ArrowUpRight
data-icon="inline-end"
className="shrink-0"
/>
</AgentChatSuggestion>
</div>
</article>
))}
</div>
{/* 布局切换用方条:选中哪一项就是当前布局,不用再单独写一行「当前:…」 */}
<SegmentedControl
label={labels.switchLayout}
size="sm"
className="max-w-sm"
options={[
{
value: "embedded",
label: labels.embeddedLayout,
icon: <PanelRight />,
},
{
value: "floating",
label: labels.floatingLayout,
icon: <SquareMousePointer />,
},
]}
value={mode}
onValueChange={(value) => {
setMode(value as AgentChatMode)
setOpen(true)
}}
/>
<p className="text-xs leading-6 text-muted-foreground">
{labels.demoNote}
</p>
</div>
</main>
<AgentChatPanel />
</div>
<AgentChatTrigger />
</div>
)
}
export default function AgentPage() {
return (
<AgentChatProvider
defaultMode="embedded"
defaultOpen
initialMessages={initialMessages}
>
<AgentChatWorkspace />
</AgentChatProvider>
)
}
"use client"
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
import {
ArrowDown,
ArrowUp,
Bot,
GripHorizontal,
PanelRight,
Plus,
Square,
X,
} from "lucide-react"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
import { GlyphField } from "@/components/ui/glyph-field/glyph-field"
import { TextReveal } from "@/components/ui/text-animate/text-animate"
import { AgentChatMessage } from "./chat-message"
import { useAgentContext } from "./agent-chat-context"
function AgentChatBody({
draggable = false,
headerProps,
onMoveKeyDown,
onResetPosition,
}: {
draggable?: boolean
headerProps?: ComponentProps<"header">
onMoveKeyDown?: ComponentProps<"button">["onKeyDown"]
onResetPosition?: () => void
}) {
const {
labels,
chat,
title,
mode,
toggleMode,
setOpen,
draft,
setDraft,
suggestions,
scroll,
updateScroll,
} = useAgentContext()
const viewport = useRef<HTMLDivElement>(null)
const input = useRef<HTMLTextAreaElement>(null)
const [following, setFollowing] = useState(scroll.current.follow)
const busy = chat.status === "streaming" || chat.status === "submitted"
// 切换外壳或关闭再打开时恢复滚动;流式更新仅在用户位于底部时跟随。
useLayoutEffect(() => {
const node = viewport.current
if (node)
node.scrollTop = scroll.current.follow
? node.scrollHeight
: scroll.current.top
}, [mode, scroll])
useLayoutEffect(() => {
const node = viewport.current
if (node && scroll.current.follow) node.scrollTop = node.scrollHeight
}, [chat.messages, scroll])
useLayoutEffect(() => {
if (!input.current) return
input.current.style.height = "auto"
input.current.style.height = `${Math.min(input.current.scrollHeight, 160)}px`
}, [draft])
const send = (text: string) => {
if (!text.trim() || busy) return
updateScroll({ follow: true })
setFollowing(true)
setDraft("")
void chat.sendMessage({ text: text.trim() })
}
return (
<div
data-slot="agent-chat-body"
data-state={busy ? "streaming" : "idle"}
className="contents"
>
<header
data-slot="agent-chat-header"
{...headerProps}
className={cn(
"flex shrink-0 items-center gap-2 border-b border-edge px-3 py-3",
draggable &&
"cursor-grab touch-none select-none data-[dragging]:cursor-grabbing",
)}
>
{draggable ? (
<Button
data-slot="agent-chat-move-handle"
variant="ghost"
size="icon-sm"
aria-label={labels.moveWindow}
className="cursor-grab in-data-[dragging]:cursor-grabbing"
onKeyDown={onMoveKeyDown}
onClick={(event) => {
if (event.detail === 0) onResetPosition?.()
}}
>
<GripHorizontal aria-hidden="true" />
</Button>
) : null}
<Avatar className="size-8">
<AvatarFallback>
<Bot className="size-4" />
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-medium">{title}</h2>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
className={cn(
"size-1.5 rounded-full",
busy
? "bg-primary motion-safe:animate-pulse"
: "bg-muted-foreground",
)}
/>
{busy ? labels.thinking : labels.idle}
</p>
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.newChat}
disabled={busy}
onClick={() => {
chat.setMessages([])
chat.clearError()
setDraft("")
updateScroll({ top: 0, follow: true })
}}
>
<Plus />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={mode === "embedded" ? labels.floatMode : labels.embedMode}
aria-pressed={mode === "embedded"}
onClick={toggleMode}
>
<PanelRight />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.closePanel}
onClick={() => setOpen(false)}
>
<X />
</Button>
</header>
<div
ref={viewport}
role="log"
aria-label={labels.history}
aria-live="polite"
aria-relevant="additions"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5"
onScroll={(event) => {
const node = event.currentTarget
const follow =
node.scrollHeight - node.scrollTop - node.clientHeight < 48
updateScroll({ top: node.scrollTop, follow })
setFollowing(follow)
}}
>
{chat.messages.length ? (
<div className="flex flex-col gap-7">
{chat.messages.map((message, index) => (
<AgentChatMessage
key={message.id}
message={message}
live={busy && index === chat.messages.length - 1}
busy={busy}
regenerate={() => {
updateScroll({ follow: true })
void chat.regenerate({ messageId: message.id })
}}
/>
))}
</div>
) : (
<div className="relative isolate flex h-full flex-col justify-center gap-5 py-6">
{/* 空状态的背景:只铺在四周的字符矩阵,中间留白给文字 */}
<GlyphField
fade="center"
tone="primary"
density={0.45}
fontSize={12}
speed={0.02}
className="absolute -inset-4 -z-10 min-h-0 bg-transparent opacity-20"
/>
<Bot className="size-8 text-primary" />
<div>
<p className="text-lg font-medium tracking-tight">
<TextReveal by="word" trigger="mount" stagger={60}>
{labels.welcome}
</TextReveal>
</p>
<p className="mt-2 text-sm leading-7 text-muted-foreground">
{labels.welcomeDescription}
</p>
</div>
<div className="flex flex-col gap-2">
{suggestions.map((question) => (
<Button
key={question}
variant="outline"
className="h-auto justify-start bg-card py-3 text-left whitespace-normal hover:bg-muted dark:bg-card dark:hover:bg-muted"
onClick={() => send(question)}
>
{question}
<ArrowUp
data-icon="inline-end"
className="ml-auto shrink-0"
/>
</Button>
))}
</div>
</div>
)}
</div>
{!following && chat.messages.length > 0 ? (
<Button
variant="secondary"
size="sm"
className="mx-auto mb-2"
onClick={() => {
updateScroll({ follow: true })
setFollowing(true)
if (viewport.current)
viewport.current.scrollTop = viewport.current.scrollHeight
}}
>
<ArrowDown />
{labels.latest}
</Button>
) : null}
{chat.error ? (
<div role="alert" className="px-4 pb-2 text-sm text-destructive">
{labels.error}
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
void chat.regenerate()
}}
>
{labels.retry}
</Button>
</div>
) : null}
<p className="sr-only" role="status" aria-live="polite">
{busy
? labels.generating
: chat.messages.length
? labels.updated
: labels.ready}
</p>
<form
className="flex shrink-0 flex-col gap-2 border-t border-edge p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]"
onSubmit={(event) => {
event.preventDefault()
send(draft)
}}
>
<Textarea
name="message"
autoComplete="off"
ref={input}
aria-label={labels.input}
placeholder={labels.placeholder}
value={draft}
maxLength={4000}
className="max-h-40 min-h-16 resize-none"
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault()
send(draft)
}
}}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{labels.newline}</p>
{busy ? (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
void chat.stop()
}}
>
<Square />
{labels.stop}
</Button>
) : (
<Button
type="submit"
size="icon-sm"
aria-label={labels.send}
disabled={!draft.trim()}
>
<ArrowUp />
</Button>
)}
</div>
</form>
</div>
)
}
export { AgentChatBody }
"use client"
import { useLayoutEffect, useRef, useState, type ComponentProps } from "react"
type Position = { x: number; y: number }
const STORAGE_KEY = "travinsy:agent-chat:position"
const INTERACTIVE =
"button:not([data-slot='agent-chat-move-handle']), a, input, textarea, select, [role='button']"
function constrain(position: Position, panel: HTMLElement): Position {
return {
x: Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, position.x)),
// 高度不足时仍保留整个标题栏,桌面常规尺寸下将整个窗口收回视口。
y: Math.max(
0,
Math.min(
window.innerHeight - Math.min(panel.offsetHeight, window.innerHeight),
position.y,
),
),
}
}
function defaultPosition(panel: HTMLElement): Position {
return constrain(
{
x: window.innerWidth - panel.offsetWidth - 16,
y: window.innerHeight - panel.offsetHeight - 16,
},
panel,
)
}
function writePosition(panel: HTMLElement, position: Position) {
panel.style.left = `${position.x}px`
panel.style.top = `${position.y}px`
panel.style.right = "auto"
panel.style.bottom = "auto"
}
function storePosition(position: Position | null) {
try {
if (position) localStorage.setItem(STORAGE_KEY, JSON.stringify(position))
else localStorage.removeItem(STORAGE_KEY)
} catch {
// 浏览器禁止存储时,窗口在当前会话中仍可正常移动。
}
}
function useAgentChatPosition({
enabled,
active,
}: {
enabled: boolean
active: boolean
}) {
const panelRef = useRef<HTMLElement>(null)
const current = useRef<Position | null>(null)
const restored = useRef(false)
const [position, setPosition] = useState<Position | null>(null)
const drag = useRef<{
pointerId: number
handle: HTMLElement
start: Position
origin: Position
} | null>(null)
function commit(next: Position | null) {
current.current = next
setPosition(next)
storePosition(next)
}
function finish() {
const session = drag.current
if (!session) return
drag.current = null
panelRef.current?.removeAttribute("data-dragging")
session.handle.removeAttribute("data-dragging")
if (session.handle.hasPointerCapture(session.pointerId))
session.handle.releasePointerCapture(session.pointerId)
commit(current.current)
}
useLayoutEffect(() => {
if (!active) return
const panel = panelRef.current
if (!panel) return
if (!restored.current) {
restored.current = true
try {
const saved: unknown = JSON.parse(
localStorage.getItem(STORAGE_KEY) ?? "null",
)
if (
saved &&
typeof saved === "object" &&
"x" in saved &&
"y" in saved &&
typeof saved.x === "number" &&
Number.isFinite(saved.x) &&
typeof saved.y === "number" &&
Number.isFinite(saved.y)
)
current.current = { x: saved.x, y: saved.y }
} catch {
// 损坏或不可读的缓存回退到默认右下角。
}
}
const update = () => {
const next = current.current ? constrain(current.current, panel) : null
writePosition(panel, next ?? defaultPosition(panel))
if (next) {
current.current = next
setPosition(next)
storePosition(next)
}
}
update()
window.addEventListener("resize", update)
const observer = new ResizeObserver(update)
observer.observe(panel)
return () => {
window.removeEventListener("resize", update)
observer.disconnect()
for (const property of ["left", "top", "right", "bottom"])
panel.style.removeProperty(property)
}
}, [active])
useLayoutEffect(() => {
if (!enabled) finish()
return () => finish()
// finish 只读取 ref;流式消息更新不能解除正在进行的指针捕获。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled])
function reset() {
const panel = panelRef.current
if (!enabled || !panel) return
finish()
// 复位直接定位,无动画,同样适用于 prefers-reduced-motion。
writePosition(panel, defaultPosition(panel))
commit(null)
}
const headerProps: ComponentProps<"header"> = enabled
? {
onPointerDown(event) {
if (
!event.isPrimary ||
event.button !== 0 ||
drag.current ||
(event.target as Element).closest(INTERACTIVE)
)
return
const panel = panelRef.current
if (!panel) return
event.preventDefault()
if (
(event.target as Element).closest(
"[data-slot='agent-chat-move-handle']",
)
)
(event.target as Element).closest<HTMLElement>("button")?.focus()
const origin = current.current ?? defaultPosition(panel)
drag.current = {
pointerId: event.pointerId,
handle: event.currentTarget,
start: { x: event.clientX, y: event.clientY },
origin,
}
current.current = origin
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.setAttribute("data-dragging", "true")
panel.setAttribute("data-dragging", "true")
},
onPointerMove(event) {
const session = drag.current
const panel = panelRef.current
if (!session || !panel || session.pointerId !== event.pointerId)
return
const next = constrain(
{
x: session.origin.x + event.clientX - session.start.x,
y: session.origin.y + event.clientY - session.start.y,
},
panel,
)
current.current = next
// 不提交 React state,避免每帧重渲染消息和输入框。
writePosition(panel, next)
},
onPointerUp(event) {
if (drag.current?.pointerId === event.pointerId) finish()
},
onPointerCancel(event) {
if (drag.current?.pointerId === event.pointerId) finish()
},
onLostPointerCapture(event) {
if (drag.current?.pointerId === event.pointerId) finish()
},
onDoubleClick(event) {
if (!(event.target as Element).closest(INTERACTIVE)) reset()
},
}
: {}
function onMoveKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (!enabled || event.altKey || event.ctrlKey || event.metaKey) return
if (event.key === "Home") {
event.preventDefault()
reset()
return
}
const direction = {
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1],
}[event.key]
const panel = panelRef.current
if (!direction || !panel) return
event.preventDefault()
finish()
const start = current.current ?? defaultPosition(panel)
const step = event.shiftKey ? 64 : 16
const next = constrain(
{ x: start.x + direction[0] * step, y: start.y + direction[1] * step },
panel,
)
writePosition(panel, next)
commit(next)
}
return { panelRef, position, headerProps, onMoveKeyDown, reset }
}
export { useAgentChatPosition }
"use client"
import {
createContext,
useContext,
useRef,
useState,
type ReactNode,
} from "react"
import { useChat } from "@ai-sdk/react"
import type { ChatTransport, UIMessage } from "ai"
import { useControllableState } from "@/hooks/use-controllable-state"
import {
DEFAULT_LABELS,
agentSuggestions,
type AgentChatLabels,
} from "./config"
import type { AgentChatMode } from "./types"
import { createMockTransport } from "./mock-transport"
type ChatState = ReturnType<typeof useChat<UIMessage>>
type AgentContextValue = {
mode: AgentChatMode
setMode: (mode: AgentChatMode) => void
toggleMode: () => void
open: boolean
setOpen: (open: boolean) => void
chat: ChatState
title: string
labels: AgentChatLabels
suggestions: string[]
draft: string
setDraft: (draft: string) => void
scroll: { current: { top: number; follow: boolean } }
updateScroll: (value: Partial<{ top: number; follow: boolean }>) => void
}
const AgentContext = createContext<AgentContextValue | null>(null)
function useAgentContext() {
const context = useContext(AgentContext)
if (!context) throw new Error("请在 AgentChatProvider 内使用助手组件")
return context
}
function useAgentChat() {
const { mode, setMode, toggleMode, open, setOpen } = useAgentContext()
return { mode, setMode, toggleMode, open, setOpen }
}
function AgentChatProvider({
children,
mode: controlledMode,
onModeChange,
defaultMode = "floating",
open: controlledOpen,
onOpenChange,
defaultOpen = false,
transport,
suggestions = agentSuggestions,
title,
labels: labelOverrides,
initialMessages,
}: {
children: ReactNode
mode?: AgentChatMode
onModeChange?: (mode: AgentChatMode) => void
defaultMode?: AgentChatMode
open?: boolean
onOpenChange?: (open: boolean) => void
labels?: Partial<AgentChatLabels>
defaultOpen?: boolean
transport?: ChatTransport<UIMessage>
suggestions?: string[]
title?: string
initialMessages?: UIMessage[]
}) {
const [mode, setMode] = useControllableState({
prop: controlledMode,
defaultProp: defaultMode,
onChange: onModeChange,
})
const [open, setOpen] = useControllableState({
prop: controlledOpen,
defaultProp: defaultOpen,
onChange: onOpenChange,
})
const labels = { ...DEFAULT_LABELS, ...labelOverrides }
const [draft, setDraft] = useState("")
const [mock] = useState(createMockTransport)
const scroll = useRef({ top: 0, follow: true })
const updateScroll = (value: Partial<{ top: number; follow: boolean }>) => {
scroll.current = { ...scroll.current, ...value }
}
const chat = useChat<UIMessage>({
transport: transport ?? mock,
messages: initialMessages,
})
return (
<AgentContext.Provider
value={{
mode,
setMode,
toggleMode: () =>
setMode((value) => (value === "embedded" ? "floating" : "embedded")),
open,
setOpen,
chat,
title: title ?? labels.title,
labels,
suggestions,
draft,
setDraft,
scroll,
updateScroll,
}}
>
{children}
</AgentContext.Provider>
)
}
export { AgentChatProvider, useAgentChat, useAgentContext }
"use client"
import { AgentChatChart } from "./chat-charts"
import { AgentChatMarkdown } from "./chat-markdown"
import { useAgentContext } from "./agent-chat-context"
import type { ChatChart } from "./types"
import { useEffect, useState } from "react"
import { isToolUIPart, getToolName, type UIMessage } from "ai"
import {
Brain,
Check,
Copy,
ExternalLink,
LoaderCircle,
RotateCcw,
ThumbsDown,
ThumbsUp,
X,
} from "lucide-react"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { CodeBlock, CodeBlockContent } from "@/components/ui/code-block"
import { Message, MessageContent, MessageFooter } from "@/components/ui/message"
import {
Trace,
TraceCollapsible,
TraceCollapsibleContent,
TraceCollapsibleTrigger,
TraceIcon,
TraceLine,
TraceTitle,
} from "@/components/ui/trace"
type MessagePart = UIMessage["parts"][number]
/** 是否属于分析过程里的一行:分析摘要、工具调用、可打开的来源(图表块不算)。 */
function isTracePart(part: MessagePart) {
if (part.type === "reasoning" || isToolUIPart(part)) return true
return part.type === "source-url" && safeSource(part.url) !== undefined
}
function messageText(message: UIMessage) {
return message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("")
}
function AgentChatReasoning({
text,
running,
interrupted,
}: {
text: string
running: boolean
interrupted: boolean
}) {
const { labels } = useAgentContext()
const [seconds, setSeconds] = useState(0)
useEffect(() => {
if (!running) return
const started = Date.now()
const timer = setInterval(
() => setSeconds((Date.now() - started) / 1000),
100,
)
return () => clearInterval(timer)
}, [running])
return (
<TraceCollapsible data-slot="agent-chat-reasoning">
<TraceCollapsibleTrigger>
<TraceLine status={running ? "running" : undefined}>
<TraceIcon>
{running ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : (
<Brain />
)}
</TraceIcon>
<TraceTitle>
{running
? labels.thinking
: interrupted
? labels.thinkingStopped
: seconds === 0
? labels.reasoningSummary
: labels.thought.replace(
"{seconds}",
new Intl.NumberFormat(undefined, {
maximumFractionDigits: 1,
}).format(seconds),
)}
</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<p className="text-sm leading-7">{text || labels.preparing}</p>
</TraceCollapsibleContent>
</TraceCollapsible>
)
}
function safeSource(url: string) {
try {
const parsed = new URL(url)
return ["https:", "http:"].includes(parsed.protocol)
? parsed.href
: undefined
} catch {
return undefined
}
}
/** 回答里的图表块:流式接口以 `data-chart` 数据块下发。 */
function messageCharts(message: UIMessage) {
return message.parts.flatMap((part, index) =>
part.type === "data-chart"
? [{ id: part.id ?? String(index), chart: part.data as ChatChart }]
: [],
)
}
function AgentChatMessage({
message,
live,
busy,
regenerate,
}: {
message: UIMessage
live: boolean
busy: boolean
regenerate: () => void
}) {
const { labels } = useAgentContext()
const [feedback, setFeedback] = useState<"up" | "down" | null>(null)
const [notice, setNotice] = useState("")
const text = messageText(message)
const charts = messageCharts(message)
if (message.role === "user")
return (
<Message data-slot="agent-chat-message" data-role="user" align="end">
<MessageContent>
<Bubble align="end" variant="muted">
<BubbleContent>{text}</BubbleContent>
</Bubble>
</MessageContent>
</Message>
)
return (
<Message
data-slot="agent-chat-message"
data-role="assistant"
data-state={live ? "streaming" : "idle"}
>
<MessageContent className="gap-4">
{message.parts.some(isTracePart) ? (
<Trace>
{message.parts.map((part, index) => {
if (part.type === "reasoning")
return (
<AgentChatReasoning
key={index}
text={part.text}
running={live && part.state === "streaming"}
interrupted={!live && part.state === "streaming"}
/>
)
if (isToolUIPart(part)) {
const done = part.state === "output-available"
const failed =
part.state === "output-error" ||
part.state === "output-denied"
const running = live && !done && !failed
const label = `${running ? labels.running : done ? labels.completed : failed ? labels.failed : labels.stopped} · ${getToolName(part)}`
return (
<TraceCollapsible key={part.toolCallId}>
<TraceCollapsibleTrigger>
<TraceLine
status={
running ? "running" : failed ? "error" : undefined
}
>
<TraceIcon>
{running ? (
<LoaderCircle className="animate-spin motion-reduce:animate-none" />
) : done ? (
<Check />
) : (
<X />
)}
</TraceIcon>
<TraceTitle>{label}</TraceTitle>
</TraceLine>
</TraceCollapsibleTrigger>
<TraceCollapsibleContent>
<div className="flex flex-col gap-2">
<p className="text-xs">{labels.toolInput}</p>
<CodeBlock size="sm">
<CodeBlockContent wrap>
{JSON.stringify(part.input ?? {}, null, 2)}
</CodeBlockContent>
</CodeBlock>
<p className="text-xs">{labels.toolOutput}</p>
<CodeBlock
size="sm"
variant={failed ? "destructive" : "default"}
>
<CodeBlockContent wrap>
{part.state === "output-error"
? part.errorText
: done
? JSON.stringify(part.output, null, 2)
: part.state === "output-denied"
? labels.denied
: running
? labels.waiting
: labels.incomplete}
</CodeBlockContent>
</CodeBlock>
</div>
</TraceCollapsibleContent>
</TraceCollapsible>
)
}
if (part.type === "source-url") {
const href = safeSource(part.url)
return href ? (
<a
key={part.sourceId}
href={href}
target="_blank"
rel="noopener noreferrer"
>
<TraceLine>
<TraceIcon>
<ExternalLink />
</TraceIcon>
<TraceTitle>
{part.title ?? new URL(href).hostname}
<span className="sr-only">{labels.newTab}</span>
</TraceTitle>
</TraceLine>
</a>
) : null
}
return null
})}
</Trace>
) : null}
{charts.map(({ id, chart }) => (
<AgentChatChart key={id} chart={chart} />
))}
{text ? (
<Bubble variant="ghost">
<BubbleContent>
<AgentChatMarkdown text={text} />
</BubbleContent>
</Bubble>
) : null}
{!live ? (
<MessageFooter className="flex-wrap gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.copy}
disabled={!text}
onClick={async () => {
try {
await navigator.clipboard.writeText(text)
setNotice(labels.copied)
} catch {
setNotice(labels.copyFailed)
}
}}
>
{notice === labels.copied ? <Check /> : <Copy />}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.useful}
aria-pressed={feedback === "up"}
onClick={() => {
setFeedback(feedback === "up" ? null : "up")
setNotice(labels.feedback)
}}
>
<ThumbsUp />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.notUseful}
aria-pressed={feedback === "down"}
onClick={() => {
setFeedback(feedback === "down" ? null : "down")
setNotice(labels.feedback)
}}
>
<ThumbsDown />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={labels.regenerate}
disabled={busy}
onClick={regenerate}
>
<RotateCcw />
</Button>
<span role="status" className="text-xs">
{notice}
</span>
</MessageFooter>
) : null}
</MessageContent>
</Message>
)
}
export { AgentChatMessage, messageText }
"use client"
import {
ChartCard,
ChartCardContent,
ChartCardDescription,
ChartCardHeader,
ChartCardTitle,
ChartMetric,
ChartMetrics,
} from "@/components/ui/chart-card/chart-card"
import { MiniChart, MiniRing } from "@/components/ui/mini-chart/mini-chart"
import type { ChatChart } from "./types"
/**
* 回答里的图表卡片。刻意做得紧凑:内边距 12px、图表只有 96px 高,
* 宽度跟随助手面板(嵌入约 400px、浮动窗口更窄),和折叠的分析过程放在一起不会把消息撑得很长。
*/
function AgentChatChart({ chart }: { chart: ChatChart }) {
return (
<ChartCard data-slot="agent-chat-chart" data-kind={chart.kind}>
{/* 头部在面板里始终上下排布,所以覆盖掉卡片默认的 sm 以上左右排布 */}
<ChartCardHeader className="gap-0.5 p-3 sm:flex-col sm:items-stretch">
<ChartCardTitle>{chart.title}</ChartCardTitle>
{chart.description ? (
<ChartCardDescription className="text-xs">
{chart.description}
</ChartCardDescription>
) : null}
</ChartCardHeader>
<ChartCardContent className="flex flex-col gap-3 p-3">
{chart.kind === "series" ? (
<>
{chart.metrics?.length ? (
<ChartMetrics>
{chart.metrics.map((metric) => (
<ChartMetric
key={metric.label}
label={metric.label}
value={metric.value}
tone={metric.tone}
/>
))}
</ChartMetrics>
) : null}
<MiniChart
type={chart.type ?? "area"}
data={chart.data}
tone={chart.tone}
format={chart.format}
highlight={chart.highlight}
seriesLabel={chart.seriesLabel}
label={chart.label}
showAxis
className="h-24"
/>
</>
) : (
<ul aria-label={chart.label} className="grid grid-cols-3 gap-2">
{chart.items.map((item) => (
<li
key={item.label}
className="flex min-w-0 flex-col items-center gap-1.5"
>
<MiniRing
value={item.value}
max={item.max}
tone={item.tone}
arc={270}
size={64}
thickness={9}
label={`${item.label} ${item.display}`}
>
<span className="text-xs font-semibold">{item.display}</span>
</MiniRing>
<span className="max-w-full truncate text-xs text-muted-foreground">
{item.label}
</span>
</li>
))}
</ul>
)}
</ChartCardContent>
</ChartCard>
)
}
export { AgentChatChart }
import type { ReactNode } from "react"
// 仅解析段落、列表和行内代码;React 自动转义文本,不解释 HTML。
function inline(text: string): ReactNode {
return text.split(/(`[^`]+`)/g).map((part, index) =>
part.startsWith("`") && part.endsWith("`") ? (
<code
key={index}
className="rounded bg-muted px-1 py-0.5 font-mono text-xs"
>
{part.slice(1, -1)}
</code>
) : (
part
),
)
}
function AgentChatMarkdown({ text }: { text: string }) {
return (
<div
data-slot="agent-chat-markdown"
className="flex flex-col gap-3 leading-7"
>
{text.split(/\n\n+/).map((paragraph, index) => {
const lines = paragraph.split("\n")
if (lines.every((line) => /^[-*] /.test(line)))
return (
<ul key={index} className="flex list-disc flex-col gap-1 pl-5">
{lines.map((line, i) => (
<li key={i}>{inline(line.slice(2))}</li>
))}
</ul>
)
if (lines.every((line) => /^\d+\. /.test(line)))
return (
<ol key={index} className="flex list-decimal flex-col gap-1 pl-5">
{lines.map((line, i) => (
<li key={i}>{inline(line.replace(/^\d+\. /, ""))}</li>
))}
</ol>
)
return (
<p key={index} className="whitespace-pre-wrap">
{inline(paragraph)}
</p>
)
})}
</div>
)
}
export { AgentChatMarkdown }
import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"
import { scripts, fallback } from "./scripts"
function createMockTransport(): ChatTransport<UIMessage> {
return {
async sendMessages({ messages, abortSignal }) {
const question =
messages
.findLast((message) => message.role === "user")
?.parts.filter((part) => part.type === "text")
.map((part) => part.text)
.join("") ?? ""
const script =
scripts.find((item) => item.match.test(question)) ?? fallback
const queue: { chunk: UIMessageChunk; delay: number }[] = []
const push = (chunk: UIMessageChunk, delay = 80) =>
queue.push({ chunk, delay })
push({ type: "start", messageId: crypto.randomUUID() }, 0)
push({ type: "start-step" }, 0)
push({ type: "reasoning-start", id: "reasoning" })
for (const delta of script.reasoning.match(/.{1,8}/gu) ?? [])
push({ type: "reasoning-delta", id: "reasoning", delta }, 130)
push({ type: "reasoning-end", id: "reasoning" })
for (const [index, tool] of script.tools.entries()) {
const toolCallId = `tool-${index}`
push({
type: "tool-input-available",
toolCallId,
toolName: tool.name,
input: tool.input,
dynamic: true,
})
push(
tool.error
? {
type: "tool-output-error",
toolCallId,
errorText: tool.error,
dynamic: true,
}
: {
type: "tool-output-available",
toolCallId,
output: tool.output,
dynamic: true,
},
800,
)
}
if (script.chart) push({ type: "data-chart", data: script.chart }, 400)
push({ type: "source-url", sourceId: "reference", ...script.source })
push({ type: "text-start", id: "answer" })
for (const delta of script.answer.match(/.{1,6}|\n/gu) ?? [])
push({ type: "text-delta", id: "answer", delta }, 35)
push({ type: "text-end", id: "answer" })
push({ type: "finish-step" }, 0)
push({ type: "finish", finishReason: "stop" }, 0)
let timer: ReturnType<typeof setTimeout> | undefined
let finish: (() => void) | undefined
let ended = false
const cleanup = () => {
ended = true
clearTimeout(timer)
finish?.()
abortSignal?.removeEventListener("abort", abort)
}
let controller: ReadableStreamDefaultController<UIMessageChunk>
const abort = () => {
if (ended) return
cleanup()
controller.error(new DOMException("生成已停止", "AbortError"))
}
return new ReadableStream<UIMessageChunk>({
start(value) {
controller = value
abortSignal?.addEventListener("abort", abort, { once: true })
if (abortSignal?.aborted) abort()
},
async pull(value) {
if (ended) return
const next = queue.shift()
if (!next) {
cleanup()
value.close()
return
}
await new Promise<void>((resolve) => {
finish = resolve
timer = setTimeout(resolve, next.delay)
})
if (!ended) value.enqueue(next.chunk)
},
cancel() {
cleanup()
},
})
},
async reconnectToStream() {
return null
},
}
}
export { createMockTransport }
import type { ChatChart } from "./types"
type Script = {
match: RegExp
reasoning: string
tools: {
name: string
input: Record<string, string>
output?: unknown
error?: string
}[]
source: { title: string; url: string }
/** 工具返回后出现在分析过程与回答之间的图表,尺寸克制,保持消息紧凑 */
chart?: ChatChart
answer: string
}
// 所有数据均为演示数据,不会查询或操作真实服务。reasoning 是预写的分析摘要。
const scripts: Script[] = [
{
match: /API|可用率|监控/i,
reasoning: "先对比可用率与错误分布,再核对最近一次发布,缩小排查范围。",
tools: [
{
name: "查询可用率",
input: { service: "api-gateway", window: "15m" },
output: {
availability: "97.8%",
baseline: "99.95%",
status: "演示数据",
},
},
{
name: "关联错误与发布",
input: { service: "api-gateway", groupBy: "upstream" },
output: {
upstream: "orders",
errors: "连接池超时",
deployment: "v2.8.1",
correlation: "发布后 3 分钟开始升高",
},
},
],
source: {
title: "Prometheus 查询基础",
url: "https://prometheus.io/docs/prometheus/latest/querying/basics/",
},
chart: {
kind: "series",
title: "api-gateway 可用率",
description: "近 15 分钟 · 演示数据",
label:
"api-gateway 近 15 分钟可用率走势,14:05 发布后从 99.9% 降到 97.8%",
seriesLabel: "可用率",
type: "area",
tone: "trend",
format: { style: "percent", digits: 2 },
metrics: [
{ label: "当前", value: "97.80%", tone: "rose" },
{ label: "基线", value: "99.95%", tone: "green" },
{ label: "发布", value: "14:05 v2.8.1" },
],
data: [
99.96, 99.95, 99.97, 99.95, 99.94, 99.2, 98.4, 97.9, 97.6, 97.7, 97.9,
97.8, 97.7, 97.8, 97.8,
].map((value, index) => ({
label: `14:${String(index).padStart(2, "0")}`,
value,
})),
},
answer:
"可用率下降与 `orders` 上游的连接池超时相关。演示窗口内可用率从 99.95% 降到 97.8%,错误集中在 `v2.8.1` 发布之后。\n\n- 先对照发布前后的连接池配置与慢查询。\n- 检查等待连接数、请求并发和数据库延迟。\n- 若错误持续扩大,按你的发布流程评估回滚。\n\n这些是模拟观测结果,尚未执行任何变更。",
},
{
match: /vllm|显存|推理|GPU/i,
reasoning:
"先读取 GPU 显存快照,再对照 KV cache 与并发配置,区分预分配与实际压力。",
tools: [
{
name: "读取 GPU 快照",
input: {
command:
"nvidia-smi --query-gpu=memory.used,memory.total --format=csv",
},
output: {
gpu: "A100 80GB",
used: "72 GiB",
utilization: "61%",
status: "演示数据",
},
},
{
name: "检查 vLLM 配置",
input: {
service: "inference",
metrics: "kv_cache_usage,requests_waiting",
},
output: {
gpu_memory_utilization: 0.9,
kv_cache_usage: "84%",
requests_waiting: 12,
max_model_len: 32768,
},
},
],
source: {
title: "vLLM 性能与调优",
url: "https://docs.vllm.ai/en/latest/configuration/optimization/",
},
chart: {
kind: "rings",
title: "A100 80GB 资源占用",
description: "显存、KV cache 与算力 · 演示数据",
label: "显存已用 90%,KV cache 已用 84%,GPU 利用率 61%",
items: [
{ label: "显存 72/80G", value: 90, display: "90%", tone: "blue" },
{ label: "KV cache", value: 84, display: "84%", tone: "amber" },
{ label: "GPU 利用率", value: 61, display: "61%", tone: "green" },
],
},
answer:
"当前演示服务使用约 `72 / 80 GiB` 显存。`gpu_memory_utilization=0.9` 会预留大部分显存,因此高占用本身不等于泄漏。\n\n- KV cache 已用 84%,同时有 12 个请求排队,建议观察峰值并发。\n- 若长上下文并非必需,可评估降低 `max_model_len`。\n- 调整前记录吞吐、首 token 延迟与 OOM 次数,并做压测对比。\n\n我只展示了模拟检查结果,没有修改推理服务。",
},
{
match: /pnpm|CI|install|前端/i,
reasoning:
"先查看 CI 安装日志,再检查 lockfile 和包管理器版本是否与本地一致。",
tools: [
{
name: "读取 CI 安装日志",
input: {
job: "frontend-build",
step: "pnpm install --frozen-lockfile",
},
error:
"ERR_PNPM_OUTDATED_LOCKFILE:package.json 与 pnpm-lock.yaml 不一致(模拟失败)",
},
{
name: "核对包管理器",
input: { files: "package.json, pnpm-lock.yaml" },
output: {
local: "pnpm 10",
ci: "pnpm 9",
lockfile: "缺少新增依赖项",
status: "演示数据",
},
},
],
source: { title: "pnpm install 文档", url: "https://pnpm.io/cli/install" },
chart: {
kind: "series",
title: "frontend-build 各步骤耗时",
description: "上一次成功运行 · 单位:秒 · 演示数据",
label: "上一次成功运行的各步骤耗时,安装依赖最长,为 94 秒",
seriesLabel: "耗时(秒)",
type: "bar",
tone: "blue",
highlight: "max",
format: { digits: 0 },
metrics: [
{ label: "本次安装", value: "失败 · 3 秒", tone: "rose" },
{ label: "上次总耗时", value: "238 秒" },
],
data: [
{ label: "安装", value: 94 },
{ label: "构建", value: 71 },
{ label: "类型", value: 38 },
{ label: "测试", value: 26 },
{ label: "检查", value: 9 },
],
},
answer:
"这次模拟失败来自锁文件不同步:`--frozen-lockfile` 检测到依赖声明发生变化,CI 与本地 pnpm 版本也不一致。\n\n- 在 `packageManager` 中固定团队使用的 pnpm 版本。\n- 使用相同版本在本地运行 `pnpm install`,检查并提交锁文件差异。\n- 让 CI 使用固定版本,然后重新运行安装任务。\n\n保留 `--frozen-lockfile`,有助于保证安装可复现。",
},
]
const fallback: Script = {
match: /.*/,
reasoning: "先明确问题范围,检查已有上下文,再整理可验证的下一步。",
tools: [
{
name: "读取工作区摘要",
input: { scope: "demo" },
output: {
services: ["应用监控", "模型部署", "前端工程"],
mode: "离线演示",
},
},
{
name: "检查上下文",
input: { source: "当前问题" },
output: {
missing: ["环境", "错误日志", "发生时间"],
status: "需要更多信息",
},
},
],
source: { title: "AI SDK 文档", url: "https://ai-sdk.dev/docs/introduction" },
answer:
"我可以帮你排查应用监控、模型部署和前端工程问题。当前使用离线演示数据。\n\n- 请补充问题发生的环境与时间。\n- 提供经过脱敏的错误日志或配置片段。\n- 说明预期行为与实际结果,便于逐项验证。",
}
export { scripts, fallback }
const DEFAULT_LABELS = {
title: "AI 助手",
closeTrigger: "关闭 AI 助手",
openTrigger: "打开 AI 助手",
thinking: "思考中",
idle: "空闲 · 随时为你解答",
newChat: "新建对话",
floatMode: "切换为浮动模式",
embedMode: "切换为嵌入模式",
closePanel: "关闭助手面板",
history: "聊天记录",
welcome: "一起找到问题的线索",
welcomeDescription: "从一次异常、一个部署问题,或一段构建日志开始。",
latest: "回到最新消息",
error: "生成失败,请检查连接后重试。",
retry: "重试",
generating: "助手正在生成回答",
updated: "回答已更新,可在聊天记录中阅读",
ready: "助手已就绪",
input: "向 AI 助手提问",
placeholder: "描述你的问题…",
newline: "Shift + Enter 换行",
stop: "停止",
send: "发送问题",
moveWindow: "移动助手窗口",
resize: "调整助手面板宽度",
copy: "复制回答",
copied: "回答已复制",
copyFailed: "复制失败,请手动选择文本复制",
useful: "回答有用",
notUseful: "回答无用",
feedback: "反馈已更新,仅保存在当前页面",
regenerate: "重新生成回答",
reasoningSummary: "分析摘要",
thinkingStopped: "思考已停止",
preparing: "正在准备分析摘要…",
running: "运行中",
completed: "已完成",
failed: "失败",
stopped: "已停止",
toolInput: "输入",
toolOutput: "输出",
denied: "调用被拒绝",
waiting: "等待工具返回…",
incomplete: "调用未完成",
newTab: "(在新标签页打开)",
thought: "已思考 {seconds} 秒",
workspace: "工作台",
workspacePath: "/ 智能协作",
demoWorkspace: "离线演示工作区",
eyebrow: "从问题到线索",
workspaceTitle: "让排查有迹可循。",
workspaceDescription:
"把助手放在工作流旁边。查看分析摘要、展开工具结果,再决定下一步。",
embeddedLayout: "嵌入布局",
floatingLayout: "浮动窗口",
switchLayout: "切换布局",
demoNote: "示例数据不连接真实服务。反馈仅保存在当前页面。",
skip: "跳到工作区",
}
type AgentChatLabels = { [K in keyof typeof DEFAULT_LABELS]: string }
const agentSuggestions = [
"为什么 API 可用率下降了?",
"帮我检查 vLLM 推理服务的显存占用",
"pnpm install 在 CI 里失败",
]
export { DEFAULT_LABELS, agentSuggestions }
export type { AgentChatLabels }
import type { ChartMetricProps } from "@/components/ui/chart-card/chart-card"
import type {
MiniChartProps,
MiniRingProps,
} from "@/components/ui/mini-chart/mini-chart"
type AgentChatMode = "embedded" | "floating"
/** 图表块的公共字段:卡片标题、说明与读屏名称。 */
type ChatChartBase = {
title: string
description?: string
/** 读屏名称,说清楚这张图表示什么 */
label: string
}
/**
* 助手回答里的图表块,以 `data-chart` 数据块的形式出现在消息里。
* 内容必须能被 JSON 序列化(不放函数),这样既能来自流式接口,也能随历史消息保存。
*/
type ChatChart =
| (ChatChartBase & {
/** 走势 / 分布:面积、折线、阶梯线或柱图,上方可带一行指标 */
kind: "series"
type?: MiniChartProps["type"]
data: { label: string; value: number }[]
seriesLabel: string
tone?: MiniChartProps["tone"]
format?: MiniChartProps["format"]
highlight?: MiniChartProps["highlight"]
metrics?: {
label: string
value: string
tone?: ChartMetricProps["tone"]
}[]
})
| (ChatChartBase & {
/** 几项占用率并排:每项一个进度环 */
kind: "rings"
items: {
label: string
value: number
max?: number
/** 环中央的文字,如「90%」 */
display: string
tone?: MiniRingProps["tone"]
}[]
})
export type { AgentChatMode, ChatChart }
import type { UIMessage } from "ai"
import { Activity, Layers, Server } from "lucide-react"
const initialMessages: UIMessage[] = [
{
id: "welcome-question",
role: "user",
parts: [{ type: "text", text: "这个工作区可以帮我做什么?" }],
},
{
id: "welcome-answer",
role: "assistant",
parts: [
{
type: "reasoning",
text: "根据工作区提供的三个演示场景,整理可以验证的问题方向。",
state: "done",
},
{
type: "dynamic-tool",
toolName: "读取工作区摘要",
toolCallId: "welcome-tool",
state: "output-available",
input: { scope: "demo" },
output: {
areas: ["应用监控", "模型部署", "前端工程"],
mode: "离线演示",
},
},
{
type: "text",
text: "我已经准备好了。你可以从以下问题开始:\n\n- 为什么 API 可用率下降了?\n- 帮我检查 vLLM 推理服务的显存占用。\n- pnpm install 在 CI 里失败。\n\n点击左侧的场景,或直接输入问题。我会逐步展示检查过程、工具结果与参考来源。",
},
],
},
]
const scenarios = [
{
icon: Activity,
label: "应用监控",
question: "为什么 API 可用率下降了?",
description: "关联错误、上游与最近一次发布",
},
{
icon: Server,
label: "模型部署",
question: "帮我检查 vLLM 推理服务的显存占用",
description: "查看 GPU、KV cache 与并发配置",
},
{
icon: Layers,
label: "前端工程",
question: "pnpm install 在 CI 里失败",
description: "从构建日志定位依赖安装问题",
},
]
export { initialMessages, scenarios }
"use client"
import * as React from "react"
type UseControllableStateParams<T> = {
/** 受控值;传入后组件不再维护内部状态 */
prop?: T
/** 非受控时的初始值 */
defaultProp: T
/** 值变化时回调(受控与非受控都会触发) */
onChange?: (value: T) => void
}
/**
* 同时支持受控与非受控的状态 hook。
* 约定见 component-conventions 技能第 4 节:open/defaultOpen/onOpenChange 等成对 props 都用它实现。
*/
function useControllableState<T>({
prop,
defaultProp,
onChange,
}: UseControllableStateParams<T>) {
const [uncontrolled, setUncontrolled] = React.useState(defaultProp)
const isControlled = prop !== undefined
const value = isControlled ? prop : uncontrolled
// 用 ref 保存最新的回调和值,保证 setValue 引用稳定
const onChangeRef = React.useRef(onChange)
const valueRef = React.useRef(value)
React.useEffect(() => {
onChangeRef.current = onChange
valueRef.current = value
})
const setValue = React.useCallback(
(next: React.SetStateAction<T>) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(valueRef.current)
: next
if (Object.is(resolved, valueRef.current)) return
valueRef.current = resolved
if (!isControlled) setUncontrolled(resolved)
onChangeRef.current?.(resolved)
},
[isControlled],
)
return [value, setValue] as const
}
export { useControllableState }
AI Assistant PanelAI 助手面板
从企业监控平台提炼的 AI 助手:嵌入与浮动布局切换,Trace 展示分析摘要、工具调用与来源,内置流式演示。