import { GreetingCard } from "@/components/ui/greeting/greeting"
// 斜纹底纹向四周渐隐,卡片落在中央;图标、光晕与指示条的色调随访客所在时段变化
export default function GreetingCardDemo() {
return (
<div className="relative flex w-full items-center justify-center py-4">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-hatch opacity-70 [mask-image:radial-gradient(ellipse_at_center,black_15%,transparent_72%)]"
/>
<GreetingCard
name="Travins"
className="relative w-full max-w-sm shadow-sm"
/>
</div>
)
}
安装
pnpm dlx shadcn@latest add https://yiui.dev/r/greeting.json用法
组件分三层,按需取用:
| 组件 | 形态 |
|---|---|
Greeting | 行内文字,放进任何标题或段落 |
GreetingPeriods | 早 / 中 / 下午 / 晚四个时段的指示条,当前时段亮起并显示进度 |
GreetingCard | 问候卡片:图标、色调、大字问候、轮换的副标题,加上时段指示 |
Greeting 默认输出 你好,很高兴在这里遇见你。,挂载后换成与访客时段相符的问候语。
import { Greeting } from "@/components/ui/greeting/greeting"
export function Intro() {
return (
<h2 className="text-xl font-medium">
<Greeting />
</h2>
)
}你好,很高兴在这里遇见你。
你好,今天想写点什么?
import { Greeting } from "@/components/ui/greeting/greeting"
export default function GreetingDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<p className="text-2xl font-medium tracking-tight">
<Greeting />
</p>
<p className="text-sm leading-7 text-muted-foreground">
<Greeting fallback="你好" suffix=",今天想写点什么?" />
</p>
</div>
)
}
示例
图标与称呼
showIcon 在问候语前加当前时段的图标(日出 / 太阳 / 日落 / 月亮),颜色随时段变化;name 拼上称呼,nameSeparator 控制两者之间的分隔。四个时段的问候语、fallback 与 suffix 都可以覆盖,boundaries 重新划分时段。
import { Greeting } from "@/components/ui/greeting/greeting"
export default function GreetingIconDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<p className="text-2xl font-medium tracking-tight">
<Greeting showIcon name="Travins" suffix="" />
</p>
<p className="text-sm leading-7 text-muted-foreground">
<Greeting
showIcon
fallback="嗨"
morning="早安"
noon="午安"
afternoon="午后好"
evening="夜深了"
name="Travins"
nameSeparator=" · "
suffix=",今天也辛苦了。"
boundaries={{ morning: 6, noon: 12, afternoon: 15, evening: 20 }}
/>
</p>
</div>
)
}
<Greeting
showIcon
name="Travins"
evening="夜深了"
boundaries={{ morning: 6, noon: 12, afternoon: 15, evening: 20 }}
suffix=",今天也辛苦了。"
/>时段指示
GreetingPeriods 单独使用时就是一条时间轴:每格有图标、名称和区间,当前时段亮起主题之外的专属色调,格子底部的细线是这个时段已经过去的比例(每分钟更新)。区间文字跟着 boundaries 走。
import { GreetingPeriods } from "@/components/ui/greeting/greeting"
export default function GreetingPeriodsDemo() {
return (
<div className="flex w-full max-w-sm flex-col gap-6">
<GreetingPeriods />
<GreetingPeriods
boundaries={{ morning: 6, noon: 12, afternoon: 15, evening: 20 }}
showProgress={false}
labels={{
names: {
morning: "Morning",
noon: "Noon",
afternoon: "Afternoon",
evening: "Night",
},
}}
/>
</div>
)
}
问候卡片
GreetingCard 把前面的东西组合起来(也就是本页顶部的预览):右上角的大图标与光晕随时段换色,副标题每 6 秒轮换一次(淡入淡出),底部是四时段指示。showPeriods={false} 去掉指示,rotate={false} 固定在第一条副标题。
固定在某个时刻
hour 接收 0–24 的小时数(可带小数,如 12.5)。传入后结果是确定的,服务端也会直接渲染,不再等待客户端挂载——设计预览、截图和测试都用得上。下面的按钮在真实时间和四个固定时刻之间切换:
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { GreetingCard } from "@/components/ui/greeting/greeting"
// `null` 表示跟随访客的真实时间
const PREVIEWS = [
{ label: "现在", hour: null },
{ label: "08:00", hour: 8 },
{ label: "12:30", hour: 12.5 },
{ label: "16:00", hour: 16 },
{ label: "22:00", hour: 22 },
] as const
/** 传入 `hour` 即可固定在某个时刻:做设计预览、截图或测试时不必等到那个时段。 */
export default function GreetingPreviewDemo() {
const [hour, setHour] = useState<number | null>(null)
return (
<div className="flex w-full max-w-sm flex-col gap-3">
<div
role="group"
aria-label="预览时刻"
className="flex flex-wrap gap-1.5"
>
{PREVIEWS.map((preview) => (
<Button
key={preview.label}
size="sm"
variant={hour === preview.hour ? "default" : "outline"}
aria-pressed={hour === preview.hour}
onClick={() => setHour(preview.hour)}
>
{preview.label}
</Button>
))}
</div>
<GreetingCard name="Travins" hour={hour ?? undefined} />
</div>
)
}
时段划分
| 默认本地时间 | 问候语 | 对应 prop | 色调 |
|---|---|---|---|
| 05:00–10:59 | 早上好 | morning | 琥珀 |
| 11:00–13:59 | 中午好 | noon | 天蓝 |
| 14:00–17:59 | 下午好 | afternoon | 玫红 |
| 18:00–次日 04:59 | 晚上好 | evening | 紫 |
boundaries 的四个值是各时段的起始小时,必须严格递增且落在 0–23;传错时回落到默认划分。evening 持续到次日 morning 开始。
色调来自 --flow-amber / --flow-blue / --flow-rose / --flow-violet,安装时随条目一起写入全局样式(与流程动画图示共用同一组变量),也可以在自己的样式里改写。没有这些变量时回落到主题强调色。
文案
Greeting 的文案用单独的 props;GreetingPeriods 与 GreetingCard 的文案集中在 labels,按字段整体覆盖:
labels 字段 | 用于 |
|---|---|
eyebrow / pending | 卡片顶部小标题,以及挂载前的占位 |
periods | 时段指示的读屏名称 |
names | 四个时段的短名称({ morning: "早上", … }) |
tags | 小标题后的英文标记(MORNING …) |
messages | 各时段轮换的副标题,每个时段至少一条 |
fallbackMessage | 挂载前的副标题 |
API
Greeting
首屏与挂载前显示的静态文案,需保持中性以避免水合差异
类型string | undefined"你好"05:00–10:59 的问候语
类型string | undefined"早上好"11:00–13:59 的问候语
类型string | undefined"中午好"14:00–17:59 的问候语
类型string | undefined"下午好"18:00–次日 04:59 的问候语
类型string | undefined"晚上好"称呼,拼在问候语之后,例如 Travins
string | undefined问候语与称呼之间的分隔
类型string | undefined","各时段的起始小时,必须严格递增;传错时回落到默认划分
类型Partial<GreetingBoundaries> | undefined{ morning: 5, noon: 11, afternoon: 14, evening: 18 }指定当前小时(可带小数)。传入后结果是确定的,不再等待客户端挂载,适合演示和测试
类型number | undefined问候语之后拼接的文本
类型string | undefined",很高兴在这里遇见你。"在问候语前显示当前时段的图标(日出 / 太阳 / 日落 / 月亮)
类型boolean | undefinedfalse根元素是 <span data-slot="greeting">,data-state 在挂载前是 pending、拿到时段后变成 ready,同时带上 data-period(morning / noon / afternoon / evening)。
GreetingPeriods
各时段的起始小时,必须严格递增;传错时回落到默认划分
类型Partial<GreetingBoundaries> | undefined{ morning: 5, noon: 11, afternoon: 14, evening: 18 }指定当前小时(可带小数)。传入后结果是确定的,不再等待客户端挂载,适合演示和测试
类型number | undefined在当前时段格里显示时段进度条
类型boolean | undefinedtrue覆盖部分可见文案(用到 periods、names)
Partial<GreetingLabels> | undefined根元素是 <ol data-slot="greeting-periods">,每格 greeting-period(data-period、data-active),进度条 greeting-progress。
GreetingCard
首屏与挂载前显示的静态文案,需保持中性以避免水合差异
类型string | undefined"你好"05:00–10:59 的问候语
类型string | undefined"早上好"11:00–13:59 的问候语
类型string | undefined"中午好"14:00–17:59 的问候语
类型string | undefined"下午好"18:00–次日 04:59 的问候语
类型string | undefined"晚上好"称呼,拼在问候语之后,例如 Travins
string | undefined各时段的起始小时,必须严格递增;传错时回落到默认划分
类型Partial<GreetingBoundaries> | undefined{ morning: 5, noon: 11, afternoon: 14, evening: 18 }指定当前小时(可带小数)。传入后结果是确定的,不再等待客户端挂载,适合演示和测试
类型number | undefined显示四时段指示条
类型boolean | undefinedtrue副标题每 6 秒轮换一次;系统开启“减少动态效果”时固定显示第一条
类型boolean | undefinedtrue覆盖部分可见文案
类型Partial<GreetingLabels> | undefined根元素是 <div data-slot="greeting-card">,带 data-state 与 data-period。
useGreetingPeriod
import { useGreetingPeriod } from "@/components/ui/greeting/greeting"
const { period, progress } = useGreetingPeriod() // period 在挂载前为 null返回当前时段和已过去的比例(0–1),第一个参数是 boundaries,第二个是 hour。时间来自随条目安装的 useNow hook,全页面共用一个定时器。
可访问性
- 渲染的是内联
<span>,可以放进<p>、<h2>等任何文本容器,不改变标题层级。 - 挂载后的文案替换没有加
aria-live:它是页面的主文案而不是通知,逐字播报会打断读屏用户。确实需要播报时,在外层容器上自己加aria-live="polite"。 - 时段图标与光晕都是装饰,
aria-hidden;颜色之外,当前时段还有aria-current="true"和文字名称。 GreetingPeriods是带aria-label的有序列表,进度条只是视觉辅助,不进入无障碍树。- 开启“减少动态效果”后副标题不再轮换,固定显示第一条;其余部分没有位移动画。
- 首屏文案与静态 HTML 一致,读屏和搜索引擎看到的是同一段文字,不会出现空内容。
注意事项
为什么必须在客户端渲染
问候语取决于访客设备上的本地时间,服务端渲染时无从得知。静态导出会把 HTML 在构建时生成一次,如果首屏直接用浏览器时间渲染,客户端水合时拿到的文本就会和 HTML 不一致。
组件因此把 fallback 作为初始文案,挂载后再替换,并每分钟校准一次。
把 fallback 写成中性问候
副作用是首屏会短暂显示
fallback:把它设成一句中性、通用的问候(例如默认的“你好”)就不会显得突兀。传入
hour 可以绕开这个问题。
其他
- 问候语和
fallback、suffix都是纯字符串,不接受 JSX。需要强调某一段时,在外层用<span>包裹。 - 时区以访客设备为准,不做服务端时区推断,因此不需要
timeZone参数。 Greeting返回内联<span>,不是块级容器。
源码
"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Moon, Sun, Sunrise, Sunset, type LucideIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { useNow } from "@/hooks/use-now"
import { DEFAULT_LABELS, type GreetingLabels } from "./config"
import {
PERIODS,
normalizeBoundaries,
periodRange,
type GreetingBoundaries,
type GreetingPeriod,
} from "./period"
import { useGreetingPeriod } from "./use-greeting-period"
const PERIOD_ICONS: Record<GreetingPeriod, LucideIcon> = {
morning: Sunrise,
noon: Sun,
afternoon: Sunset,
evening: Moon,
}
// 每个时段一种强调色:日出琥珀、正午天蓝、日落玫红、夜晚紫。
// 取自 flow-diagram 的多彩色变量;没有安装它们时回落到主题强调色。
const PERIOD_TONES: Record<GreetingPeriod, string> = {
morning: "var(--flow-amber, var(--primary))",
noon: "var(--flow-blue, var(--primary))",
afternoon: "var(--flow-rose, var(--primary))",
evening: "var(--flow-violet, var(--primary))",
}
function toneStyle(period: GreetingPeriod | null) {
return {
"--greeting-tone": period ? PERIOD_TONES[period] : "var(--primary)",
} as React.CSSProperties
}
type GreetingTextProps = {
/**
* 首屏与挂载前显示的静态文案,需保持中性以避免水合差异
* @default "你好"
*/
fallback?: string
/**
* 05:00–10:59 的问候语
* @default "早上好"
*/
morning?: string
/**
* 11:00–13:59 的问候语
* @default "中午好"
*/
noon?: string
/**
* 14:00–17:59 的问候语
* @default "下午好"
*/
afternoon?: string
/**
* 18:00–次日 04:59 的问候语
* @default "晚上好"
*/
evening?: string
/** 称呼,拼在问候语之后,例如 `Travins` */
name?: string
/**
* 问候语与称呼之间的分隔
* @default ","
*/
nameSeparator?: string
/**
* 各时段的起始小时,必须严格递增;传错时回落到默认划分
* @default { morning: 5, noon: 11, afternoon: 14, evening: 18 }
*/
boundaries?: Partial<GreetingBoundaries>
/**
* 指定当前小时(可带小数)。传入后结果是确定的,不再等待客户端挂载,适合演示和测试
*/
hour?: number
}
type GreetingProps = Omit<React.ComponentProps<"span">, "children"> &
GreetingTextProps & {
/**
* 问候语之后拼接的文本
* @default ",很高兴在这里遇见你。"
*/
suffix?: string
/**
* 在问候语前显示当前时段的图标(日出 / 太阳 / 日落 / 月亮)
* @default false
*/
showIcon?: boolean
}
/** 按时段选出问候语;时段未知(挂载前)时返回 `fallback`。 */
function pickGreeting(
period: GreetingPeriod | null,
texts: Pick<
GreetingTextProps,
"fallback" | "morning" | "noon" | "afternoon" | "evening"
>,
) {
const {
fallback = "你好",
morning = "早上好",
noon = "中午好",
afternoon = "下午好",
evening = "晚上好",
} = texts
if (period === null) return fallback
return { morning, noon, afternoon, evening }[period]
}
/** 根据访客当地时间问候,首屏使用一致的静态文案以避免水合差异。 */
function Greeting({
fallback,
suffix = ",很高兴在这里遇见你。",
morning,
noon,
afternoon,
evening,
name,
nameSeparator = ",",
boundaries,
hour,
showIcon = false,
className,
...props
}: GreetingProps) {
const { period } = useGreetingPeriod(boundaries, hour)
const Icon = period ? PERIOD_ICONS[period] : null
return (
<span
data-slot="greeting"
data-state={period === null ? "pending" : "ready"}
data-period={period ?? undefined}
className={className}
{...props}
>
{showIcon ? (
<span
data-slot="greeting-icon"
style={toneStyle(period)}
className="mr-[0.4em] inline-block size-[1em] align-[-0.125em] text-(--greeting-tone)"
>
{Icon ? <Icon aria-hidden="true" className="size-full" /> : null}
</span>
) : null}
{pickGreeting(period, { fallback, morning, noon, afternoon, evening })}
{name ? `${nameSeparator}${name}` : null}
{suffix}
</span>
)
}
type GreetingPeriodsProps = Omit<React.ComponentProps<"ol">, "children"> &
Pick<GreetingTextProps, "boundaries" | "hour"> & {
/**
* 在当前时段格里显示时段进度条
* @default true
*/
showProgress?: boolean
/** 覆盖部分可见文案(用到 `periods`、`names`) */
labels?: Partial<GreetingLabels>
}
/** 四个时段的指示条:当前时段亮起,格内细线表示这个时段已经过去多少。 */
function GreetingPeriods({
boundaries,
hour,
showProgress = true,
labels,
className,
...props
}: GreetingPeriodsProps) {
const text = { ...DEFAULT_LABELS, ...labels }
const normalized = normalizeBoundaries(boundaries)
const { period: active, progress } = useGreetingPeriod(boundaries, hour)
return (
<ol
data-slot="greeting-periods"
aria-label={text.periods}
className={cn("grid grid-cols-4 gap-1.5", className)}
{...props}
>
{PERIODS.map((period) => {
const Icon = PERIOD_ICONS[period]
const current = period === active
return (
<li
key={period}
data-slot="greeting-period"
data-period={period}
data-active={current ? "" : undefined}
aria-current={current ? "true" : undefined}
style={toneStyle(period)}
className={cn(
"relative flex flex-col items-center gap-1 overflow-hidden rounded-md border border-edge bg-background px-1 py-2 text-muted-foreground transition-colors",
"data-[active]:border-(--greeting-tone)/60 data-[active]:bg-(--greeting-tone)/10 data-[active]:text-foreground",
)}
>
<Icon
aria-hidden="true"
strokeWidth={1.5}
className="size-4 in-data-[active]:text-(--greeting-tone)"
/>
<span className="text-xs">{text.names[period]}</span>
<span className="font-mono text-[10px] opacity-70">
{periodRange(period, normalized)}
</span>
{showProgress && current ? (
<span
aria-hidden="true"
data-slot="greeting-progress"
className="absolute inset-x-0 bottom-0 h-0.5 bg-(--greeting-tone)/20"
>
<span
className="block h-full origin-left bg-(--greeting-tone)"
style={{ transform: `scaleX(${progress})` }}
/>
</span>
) : null}
</li>
)
})}
</ol>
)
}
type GreetingCardProps = Omit<React.ComponentProps<"div">, "children"> &
Omit<GreetingTextProps, "nameSeparator"> & {
/**
* 显示四时段指示条
* @default true
*/
showPeriods?: boolean
/**
* 副标题每 6 秒轮换一次;系统开启“减少动态效果”时固定显示第一条
* @default true
*/
rotate?: boolean
/** 覆盖部分可见文案 */
labels?: Partial<GreetingLabels>
}
/** 问候卡片:时段图标与色调、大字问候、轮换的副标题,以及四时段指示。 */
function GreetingCard({
fallback,
morning,
noon,
afternoon,
evening,
name,
boundaries,
hour,
showPeriods = true,
rotate = true,
labels,
className,
...props
}: GreetingCardProps) {
const text = { ...DEFAULT_LABELS, ...labels }
const { period } = useGreetingPeriod(boundaries, hour)
const reduced = useReducedMotion()
const tick = useNow(6000)
const messages = period ? text.messages[period] : []
const index =
rotate && !reduced && tick !== null && messages.length
? Math.floor(tick / 6000) % messages.length
: 0
const message = messages[index] ?? text.fallbackMessage
const Icon = period ? PERIOD_ICONS[period] : null
return (
<div
data-slot="greeting-card"
data-state={period === null ? "pending" : "ready"}
data-period={period ?? undefined}
style={toneStyle(period)}
className={cn(
"relative flex flex-col gap-5 overflow-hidden rounded-lg border border-edge bg-background p-5",
className,
)}
{...props}
>
<div
aria-hidden="true"
className="pointer-events-none absolute -top-16 -right-12 size-48 rounded-full bg-(--greeting-tone)/10 blur-3xl"
/>
{Icon ? (
<Icon
aria-hidden="true"
strokeWidth={1}
className="pointer-events-none absolute top-3 right-3 size-16 text-(--greeting-tone)/50"
/>
) : null}
<div className="relative">
<p className="flex items-center gap-2 font-mono text-[10px] tracking-[0.08em] text-muted-foreground">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-(--greeting-tone)"
/>
{text.eyebrow} / {period ? text.tags[period] : text.pending}
</p>
<p className="mt-3 pr-14 text-4xl font-semibold tracking-tight">
<Greeting
fallback={fallback}
morning={morning}
noon={noon}
afternoon={afternoon}
evening={evening}
name={name}
boundaries={boundaries}
hour={hour}
suffix=""
/>
<span className="text-(--greeting-tone)">.</span>
</p>
<div className="mt-2 min-h-5 text-sm text-muted-foreground">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={message}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
>
{message}
</motion.p>
</AnimatePresence>
</div>
</div>
{showPeriods ? (
<GreetingPeriods
boundaries={boundaries}
hour={hour}
labels={labels}
className="relative"
/>
) : null}
</div>
)
}
export { Greeting, GreetingCard, GreetingPeriods, useGreetingPeriod }
export type {
GreetingBoundaries,
GreetingCardProps,
GreetingLabels,
GreetingPeriod,
GreetingPeriodsProps,
GreetingProps,
}
"use client"
import { useNow } from "@/hooks/use-now"
import {
normalizeBoundaries,
resolvePeriod,
type GreetingBoundaries,
type GreetingPeriod,
} from "./period"
type GreetingState = {
/** 当前时段;服务端与水合期间为 `null` */
period: GreetingPeriod | null
/** 当前时段已经过去的比例(0–1) */
progress: number
}
/**
* 访客本地时间所在的时段,每分钟校准一次。
* 传入 `hour` 时直接用它(可带小数,如 `13.5`),此时结果是确定的,服务端也能渲染。
*/
function useGreetingPeriod(
boundaries?: Partial<GreetingBoundaries>,
hour?: number,
): GreetingState {
const now = useNow(60_000)
const normalized = normalizeBoundaries(boundaries)
if (hour !== undefined)
return resolvePeriod(((hour % 24) + 24) % 24, normalized)
if (now === null) return { period: null, progress: 0 }
const date = new Date(now)
return resolvePeriod(date.getHours() + date.getMinutes() / 60, normalized)
}
export { useGreetingPeriod }
export type { GreetingState }
// 时段划分:纯函数,不依赖 React 和当前时间,方便测试与服务端使用。
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening"
/** 每个时段的起始小时(0–23)。`evening` 持续到次日 `morning` 开始。 */
type GreetingBoundaries = Record<GreetingPeriod, number>
const PERIODS: readonly GreetingPeriod[] = [
"morning",
"noon",
"afternoon",
"evening",
]
const DEFAULT_BOUNDARIES: GreetingBoundaries = {
morning: 5,
noon: 11,
afternoon: 14,
evening: 18,
}
/** 起始小时必须严格递增且落在 0–23;传错时回落到默认划分,而不是渲染出错乱的时段。 */
function normalizeBoundaries(
boundaries?: Partial<GreetingBoundaries>,
): GreetingBoundaries {
const merged = { ...DEFAULT_BOUNDARIES, ...boundaries }
const hours = PERIODS.map((period) => merged[period])
const valid = hours.every(
(hour, index) =>
Number.isFinite(hour) &&
hour >= 0 &&
hour < 24 &&
(index === 0 || hour > hours[index - 1]),
)
return valid ? merged : DEFAULT_BOUNDARIES
}
/** 时刻(小时,可带小数)所在的时段,以及已经过去的比例(0–1)。 */
function resolvePeriod(time: number, boundaries: GreetingBoundaries) {
const shifted = time < boundaries.morning ? time + 24 : time
const starts = PERIODS.map((period) => boundaries[period])
let index = PERIODS.length - 1
while (index > 0 && shifted < starts[index]) index--
const start = starts[index]
const end = index === PERIODS.length - 1 ? starts[0] + 24 : starts[index + 1]
return {
period: PERIODS[index],
progress: Math.min(1, Math.max(0, (shifted - start) / (end - start))),
}
}
/** `05–11` 这样的区间文字;`evening` 的终点是次日 `morning` 的起点。 */
function periodRange(period: GreetingPeriod, boundaries: GreetingBoundaries) {
const index = PERIODS.indexOf(period)
const end = boundaries[PERIODS[(index + 1) % PERIODS.length]]
const pad = (hour: number) => String(hour).padStart(2, "0")
return `${pad(boundaries[period])}–${pad(end)}`
}
export {
DEFAULT_BOUNDARIES,
PERIODS,
normalizeBoundaries,
periodRange,
resolvePeriod,
}
export type { GreetingBoundaries, GreetingPeriod }
import type { GreetingPeriod } from "./period"
/** 问候卡片与时段指示的可见文案;`labels` 按字段整体覆盖(`names` 等对象不做深合并)。 */
type GreetingLabels = {
/** 卡片顶部的小标题 */
eyebrow: string
/** 挂载前小标题后的占位 */
pending: string
/** 时段指示的读屏名称 */
periods: string
/** 各时段的短名称 */
names: Record<GreetingPeriod, string>
/** 各时段的英文标记,显示在小标题后 */
tags: Record<GreetingPeriod, string>
/** 各时段轮换的副标题,至少一条 */
messages: Record<GreetingPeriod, readonly string[]>
/** 挂载前的副标题 */
fallbackMessage: string
}
const DEFAULT_LABELS: GreetingLabels = {
eyebrow: "GREETING",
pending: "LOCAL TIME",
periods: "一天中的时段",
names: { morning: "早上", noon: "中午", afternoon: "下午", evening: "晚上" },
tags: {
morning: "MORNING",
noon: "NOON",
afternoon: "AFTERNOON",
evening: "EVENING",
},
messages: {
morning: ["新的一天,先从最重要的一件事开始。", "今天想先做点什么?"],
noon: ["记得吃饭,也给眼睛放个假。", "上午辛苦了,下午继续。"],
afternoon: ["喝杯水,起来走动一下。", "下午的专注力很宝贵。"],
evening: ["今天辛苦了,慢慢来。", "收个尾,早点休息。"],
},
fallbackMessage: "很高兴在这里遇见你。",
}
export { DEFAULT_LABELS }
export type { GreetingLabels }
"use client"
import * as React from "react"
// 全页面共用一个对齐整秒的定时器:多个时钟同时挂载也只有一个 setTimeout 在跑,
// 并且所有实例的秒数在同一帧跳动。
const listeners = new Set<() => void>()
let timer: number | undefined
function schedule() {
timer = window.setTimeout(
() => {
listeners.forEach((listener) => listener())
if (listeners.size) schedule()
},
1005 - (Date.now() % 1000),
) // 多等 5ms,避免定时器提前触发时读到上一秒
}
function subscribe(listener: () => void) {
listeners.add(listener)
if (listeners.size === 1) schedule()
return () => {
listeners.delete(listener)
if (!listeners.size) window.clearTimeout(timer)
}
}
/**
* 当前时间的毫秒时间戳,按 `step` 对齐(默认每秒)。
* 服务端渲染与水合期间返回 `null`,水合完成后才给出真实时间,
* 静态导出的 HTML 因此永远不会带着过期的时间。
*
* @param step 对齐粒度(毫秒);快照不变时组件不会重新渲染,所以只显示分钟的组件传 `60_000` 即可
*/
function useNow(step = 1000): number | null {
return React.useSyncExternalStore(
subscribe,
() => Math.floor(Date.now() / step) * step,
() => null,
)
}
export { useNow }