/* 跨页面复用的小组件(独立文件以满足 react-refresh) */ import { useEffect, useState, type ReactNode } from "react" import { RiArrowDownSLine, RiHeartLine, RiPhoneLine } from "@remixicon/react" import { Button } from "@/components/ui/button" import { contentLang } from "@/lib/locale" /* 心理危机熔断面板:温和但醒目,热线可直接点按拨打 (Cast 与 Tab2 共用) */ export function CrisisPanel({ message, helplines, onClose }: { message: string helplines: { name: string; tel: string }[] onClose: () => void }) { return (
先停一停

{message.split("\n\n")[0] || message}

{helplines.map((h) => ( {h.name} | {h.tel} ))}

这不是命理问题,而是此刻最该被认真对待的事。你不必独自面对。

) } /* 次级内容折叠:居中分隔线式开关,默认收起,让主角聚焦 */ export function Foldable({ label, children, defaultOpen = false }: { label: string children: React.ReactNode defaultOpen?: boolean }) { const [open, setOpen] = useState(defaultOpen) return (
{open &&
{children}
}
) } /* 长文按换行分段, 段落间留白, 错落有致 (解读叙事/弹窗长文共用) */ /* 行内 Markdown 渲染: **粗** / *强调* 一律渲染为加黑, 不再原样输出星号 */ function renderInline(text: string): ReactNode[] { const out: ReactNode[] = [] const re = /\*\*([^*]+)\*\*|\*([^*\n]+)\*|__([^_]+)__/g let last = 0, key = 0, m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { if (m.index > last) out.push(text.slice(last, m.index)) out.push( {m[1] ?? m[2] ?? m[3]} , ) last = re.lastIndex } if (last < text.length) out.push(text.slice(last)) return out } export function Prose({ text, className = "" }: { text: string; className?: string }) { let t = text // 英文环境的兜底: 模型偶尔照搬中文「今日可行的一小步:」前缀, 这里统一替换 if (contentLang() === "en") t = t.replace(/今日可行的一小步\s*[::]?\s*/g, "Today's small step: ") const paras = t.split(/\n+/).map((s) => s.trim()).filter(Boolean) return (
{paras.map((p, i) =>

{renderInline(p)}

)}
) } /* 墨迹晕开式逐字显示 */ export function InkText({ text }: { text: string }) { const [n, setN] = useState(0) useEffect(() => { // 动画初始化: text 变化时从头重放,是有意的同步重置 // eslint-disable-next-line react-hooks/set-state-in-effect setN(0) const t = setInterval(() => { setN((x) => { if (x >= text.length) { clearInterval(t) return x } return x + 1 }) }, 18) return () => clearInterval(t) }, [text]) return
{text.slice(0, n)}
} /* 水墨晕开式加载占位(替代 18s 思考段的静态"推演中") */ export function InkLoading({ label = "推演中" }: { label?: string }) { return (
{label}
) } /* 离线徽标:本应用全本地运行,离线仍可推演 —— 提示是安心而非警告 */ export function OfflineBadge() { const [offline, setOffline] = useState(typeof navigator !== "undefined" && !navigator.onLine) useEffect(() => { const on = () => setOffline(false) const off = () => setOffline(true) window.addEventListener("online", on) window.addEventListener("offline", off) return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off) } }, []) if (!offline) return null return (
离线中 | 本地推演仍可用
) } export function CardLabel({ children }: { children: React.ReactNode }) { return
{children}
} export function ZoneTitle({ children }: { children: React.ReactNode }) { return (

{children}

) }