tianwen / frontend /src /components /Shared.tsx
liuyd-dev's picture
v0: zero is infinite
07c10da verified
Raw
History Blame Contribute Delete
6.33 kB
/* 跨页面复用的小组件(独立文件以满足 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 (
<div className="rounded-2xl border p-8 text-left"
style={{ background: "rgba(163,91,91,0.07)", borderColor: "#A35B5B" }}>
<div className="mb-4 flex items-center gap-2.5" style={{ color: "#A35B5B" }}>
<RiHeartLine className="h-6 w-6" />
<span className="text-base tracking-[0.25em]">先停一停</span>
</div>
<p className="text-sm leading-7 whitespace-pre-wrap">{message.split("\n\n")[0] || message}</p>
<div className="my-5 space-y-1">
{helplines.map((h) => (
<a key={h.tel} href={`tel:${h.tel.replace(/\s/g, "")}`}
className="hover:bg-foreground/5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm tracking-wide transition-colors"
style={{ color: "var(--gold)" }}>
<RiPhoneLine className="h-4 w-4 shrink-0" /> {h.name} | {h.tel}
</a>
))}
</div>
<p className="text-muted-foreground mb-5 text-xs leading-6">
这不是命理问题,而是此刻最该被认真对待的事。你不必独自面对。
</p>
<Button variant="outline" onClick={onClose}>回到安静处</Button>
</div>
)
}
/* 次级内容折叠:居中分隔线式开关,默认收起,让主角聚焦 */
export function Foldable({ label, children, defaultOpen = false }: {
label: string
children: React.ReactNode
defaultOpen?: boolean
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<div className="mx-auto w-full">
<div className="flex justify-center">
<button type="button" aria-expanded={open} onClick={() => setOpen((o) => !o)}
className="text-muted-foreground hover:text-foreground group flex items-center gap-2.5 py-2 transition-colors">
<span className="bg-border h-px w-8 transition-all group-hover:w-10" />
<span className="text-xs tracking-[0.3em]">{open ? label.replace(/^展开/, "收起") : label}</span>
<RiArrowDownSLine className={`h-4 w-4 transition-transform ${open ? "rotate-180" : ""}`} />
<span className="bg-border h-px w-8 transition-all group-hover:w-10" />
</button>
</div>
{open && <div className="fade-in mt-5">{children}</div>}
</div>
)
}
/* 长文按换行分段, 段落间留白, 错落有致 (解读叙事/弹窗长文共用) */
/* 行内 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(
<strong key={key++} className="text-foreground font-semibold">
{m[1] ?? m[2] ?? m[3]}
</strong>,
)
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 (
<div className={`space-y-3 text-sm leading-[1.85] ${className}`}>
{paras.map((p, i) => <p key={i}>{renderInline(p)}</p>)}
</div>
)
}
/* 墨迹晕开式逐字显示 */
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 <div className="text-sm whitespace-pre-wrap">{text.slice(0, n)}</div>
}
/* 水墨晕开式加载占位(替代 18s 思考段的静态"推演中") */
export function InkLoading({ label = "推演中" }: { label?: string }) {
return (
<div className="ink-loading">
<span className="ink-blot" />
<span className="ink-blot" />
<span className="ink-blot" />
<span className="ink-loading-label">{label}</span>
</div>
)
}
/* 离线徽标:本应用全本地运行,离线仍可推演 —— 提示是安心而非警告 */
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 (
<div className="glass-heavy fixed top-3 left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-1.5 text-xs tracking-[0.2em]"
style={{ color: "var(--gold)" }}>
离线中 | 本地推演仍可用
</div>
)
}
export function CardLabel({ children }: { children: React.ReactNode }) {
return <div className="mb-3 text-[0.95rem] font-medium tracking-[0.18em]" style={{ color: "var(--gold)" }}>{children}</div>
}
export function ZoneTitle({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-foreground mb-7 text-lg font-medium tracking-[0.4em]">
{children}
<span className="mt-3 block h-px w-10 opacity-50" style={{ background: "var(--gold)" }} />
</h3>
)
}