/* 跨页面复用的小组件(独立文件以满足 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}
这不是命理问题,而是此刻最该被认真对待的事。你不必独自面对。
)
}
/* 次级内容折叠:居中分隔线式开关,默认收起,让主角聚焦 */
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}
)
}