Files
opsxdb/src/NotifyHost.tsx
T

230 lines
7.2 KiB
TypeScript

import { useEffect } from "react";
import { useWorkspaceStore } from "./store";
import type { ConfirmRequest } from "./store";
import type { NotifyKind, NotifyPayload } from "./notify";
import { OpsModal } from "./OpsModal";
import { settleInputRequest, useInputRequest } from "./dialogs";
// C6:items 字段由 WS-C 在 store.ts 正式补充,此处本地类型扩展先行渲染
type ConfirmRequestWithItems = ConfirmRequest & { items?: string[] };
/**
* 分级语义色:统一走 --ops-* 令牌(修 V2,去除 slate 硬编码),亮暗主题随行
* error→danger / warning→warning / info→accent / success→success
*/
const KIND_META: Record<NotifyKind, { color: string; label: string }> = {
error: { color: "var(--ops-danger)", label: "错误" },
warning: { color: "var(--ops-warning)", label: "提示" },
info: { color: "var(--ops-accent)", label: "信息" },
success: { color: "var(--ops-success)", label: "成功" },
};
function KindIcon({ kind }: { kind: NotifyKind }) {
const meta = KIND_META[kind];
const glyph =
kind === "error" ? "!" : kind === "warning" ? "!" : kind === "success" ? "✓" : "i";
return (
<div
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-sm font-bold"
style={{
background: `color-mix(in srgb, ${meta.color} 15%, transparent)`,
color: meta.color,
}}
>
{glyph}
</div>
);
}
/** 通知模态:走 OpsModal info 变体(Esc/遮罩关闭、焦点管理由 OpsModal 负责) */
function ModalCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
const meta = KIND_META[n.kind];
return (
<OpsModal
variant="info"
title={
<span className="flex items-center gap-2.5">
<KindIcon kind={n.kind} />
<span>
<span
className="block font-semibold uppercase"
style={{ fontSize: "var(--ops-text-xs)", letterSpacing: "0.05em", color: meta.color }}
>
{meta.label}
</span>
<span className="block" style={{ fontSize: "var(--ops-text-lg)" }}>
{n.title}
</span>
</span>
</span>
}
onOk={onClose}
>
<p className="m-0">{n.message}</p>
{n.tips && n.tips.length > 0 && (
<ul
className="m-0 mt-2 space-y-1.5 rounded border px-3 py-2.5"
style={{
fontSize: "var(--ops-text-sm)",
borderColor: "var(--ops-border-soft)",
background: "var(--ops-surface-2)",
color: "var(--ops-muted)",
listStyle: "none",
}}
>
{n.tips.map((t) => (
<li key={t} className="flex gap-2">
<span className="mt-0.5" style={{ color: "var(--ops-accent)" }}>
•
</span>
<span>{t}</span>
</li>
))}
</ul>
)}
{n.detail && (
<details
className="group mt-2 rounded border"
style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-editor-bg)" }}
>
<summary
className="cursor-pointer select-none px-3 py-2 hover:underline"
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
>
技术详情
</summary>
<pre
className="max-h-40 overflow-auto border-t px-3 py-2 font-mono leading-5 whitespace-pre-wrap break-all"
style={{
fontSize: "var(--ops-text-xs)",
borderColor: "var(--ops-border-soft)",
color: "var(--ops-muted)",
}}
>
{n.detail}
</pre>
</details>
)}
</OpsModal>
);
}
/** Toast:保留分级图标 + 自动关闭,样式走令牌 */
function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
const meta = KIND_META[n.kind];
useEffect(() => {
const ms = n.durationMs ?? 3200;
const t = window.setTimeout(onClose, ms);
return () => window.clearTimeout(t);
}, [n.durationMs, onClose]);
return (
<div
className="ops-toast"
style={{ borderColor: `color-mix(in srgb, ${meta.color} 45%, var(--ops-border))` }}
role="status"
>
<KindIcon kind={n.kind} />
<div className="min-w-0 flex-1">
<div className="font-semibold" style={{ fontSize: "var(--ops-text-md)", color: "var(--ops-text)" }}>
{n.title}
</div>
<div className="mt-0.5 leading-relaxed" style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}>
{n.message}
</div>
</div>
<button
type="button"
className="ops-btn-ghost"
style={{ height: "auto", padding: "2px 6px" }}
aria-label="关闭"
onClick={onClose}
>
✕
</button>
</div>
);
}
/** 危险/普通确认框:走 OpsModal confirm 变体;items 语句清单前 3 条 + 折叠其余(修 C2) */
function ConfirmCard() {
const confirm = useWorkspaceStore((s) => s.confirm) as ConfirmRequestWithItems | null;
const resolveConfirm = useWorkspaceStore((s) => s.resolveConfirm);
if (!confirm) return null;
const items = confirm.items;
return (
<OpsModal
variant="confirm"
title={confirm.title}
danger={confirm.danger}
okLabel={confirm.okLabel || "确定"}
zIndex="var(--ops-z-confirm)"
onOk={() => resolveConfirm(true)}
onCancel={() => resolveConfirm(false)}
>
<p className="m-0">{confirm.message}</p>
{items && items.length > 0 && (
<div className="ops-modal-items">
{items.slice(0, 3).map((s, i) => (
<div key={i}>{s}</div>
))}
{items.length > 3 && (
<details className="mt-1">
<summary className="cursor-pointer select-none" style={{ color: "var(--ops-accent)" }}>
还有 {items.length - 3} 条,展开查看
</summary>
{items.slice(3).map((s, i) => (
<div key={i + 3} className="mt-1">
{s}
</div>
))}
</details>
)}
</div>
)}
</OpsModal>
);
}
/** askInput(C6)渲染宿主:队列中的输入请求逐个以 OpsModal input 变体展示 */
function DialogHost() {
const req = useInputRequest();
if (!req) return null;
return (
<OpsModal
key={req.id}
variant="input"
title={req.title}
label={req.label}
defaultValue={req.defaultValue}
placeholder={req.placeholder}
onOk={(v) => settleInputRequest(v ?? null)}
onCancel={() => settleInputRequest(null)}
/>
);
}
export function NotifyHost() {
const modal = useWorkspaceStore((s) => s.notifyModal);
const toasts = useWorkspaceStore((s) => s.notifyToasts);
const dismissNotify = useWorkspaceStore((s) => s.dismissNotify);
return (
<>
{modal && <ModalCard n={modal} onClose={() => dismissNotify(modal.id)} />}
<ConfirmCard />
<DialogHost />
<div
className="pointer-events-none fixed bottom-4 right-4 flex flex-col-reverse gap-2"
style={{ zIndex: "var(--ops-z-toast)" }}
>
{toasts.map((n) => (
<ToastCard key={n.id} n={n} onClose={() => dismissNotify(n.id)} />
))}
</div>
</>
);
}