230 lines
7.2 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|