桌面 GUI 外链修复 + 主题顺序调整(含结构扁平化进行中的工作区基线)

- 集成 tauri-plugin-opener:Cargo/lib.rs/capabilities/npm 四处接线,新增 src/openExternal.ts(桌面走 opener,浏览器回退 window.open,相对路径绝对化)
- 关于页 4 个链接、活动栏官网菜单、代理错误横幅 2 处健康检查链接全部改走 openExternal,桌面端可正常跳转
- 设置→外观 主题顺序调整为 深色/浅色/跟随系统
- release 二进制重编并验证(浏览器实测关于页链接可开文档页)
- 附带工作区中未提交的扁平化重构现状(另一会话进行中)
- homepage 落地页与文档终检通过(8787 / 与 /docs 双 200)
This commit is contained in:
logan committed 2026-10-16 03:03:36 +08:00
1 parent 6452026fd6
commit 2bb4ef7a95
299 files changed
+6059 -3322

No files matched your search

+827
View File
@@ -0,0 +1,827 @@
import { ProxySettingsPanel } from "../../features/settings/ProxySettingsPanel";
import { MockSharingPanel } from "../../features/mock/MockSharingPanel";
import { AiProviderSettings } from "../../features/settings/AiProviderSettings";
/**
* 工作区对话框:新建项 / 重命名 / 环境管理 / 设置。
*/
import type { RequestKind } from "../../core/index";
import { generateMock, KIND_LABELS, MOCK_CHEATSHEET, walk } from "../../core/index";
import { useState } from "react";
import { useWorkspace } from "../../workspaceStore";
import { KVEditor, Modal, cx } from "../common";
import { EnvBadge } from "../EnvBadge";
import { Icon, type IconName } from "../icons";
import { KindIcon } from "../KindIcon";
import { Logo } from "../Logo";
import { WECHAT_GROUP_URL } from "../../links";
import { openExternal } from "../../openExternal";
const KIND_MENU: { kind: RequestKind | "folder"; icon: IconName; desc: string }[] = [
{ kind: "http", icon: "globe", desc: "REST 接口调试" },
{ kind: "graphql", icon: "hexagon", desc: "Query + Variables" },
{ kind: "websocket", icon: "zap", desc: "双向实时通信" },
{ kind: "sse", icon: "radio", desc: "服务端推送事件流" },
{ kind: "socketio", icon: "plug-zap", desc: "Socket.IO 事件收发" },
{ kind: "mqtt", icon: "mailbox", desc: "IoT 消息订阅发布" },
{ kind: "folder", icon: "folder-plus", desc: "接口分组目录" },
];
export function NewItemDialog({
collectionId,
parentId,
}: {
collectionId: string;
parentId: string | null;
}) {
const collections = useWorkspace((s) => s.collections);
const addRequest = useWorkspace((s) => s.addRequest);
const addFolder = useWorkspace((s) => s.addFolder);
const setDialog = useWorkspace((s) => s.setDialog);
const [kind, setKind] = useState<RequestKind | "folder">("http");
const [name, setName] = useState("");
const [targetCollection, setTargetCollection] = useState(collectionId || (collections[0]?.id ?? ""));
const [targetParent, setTargetParent] = useState<string | null>(parentId);
const folderOptions: { id: string | null; label: string }[] = [{ id: null, label: "(集合根目录)" }];
const collection = collections.find((c) => c.id === targetCollection);
if (collection) {
walk(collection.children, (node) => {
if (node.type === "folder") {
folderOptions.push({ id: node.id, label: ` `.repeat(0) + node.name });
}
});
}
const create = () => {
if (kind === "folder") addFolder(targetCollection, targetParent, name || undefined);
else addRequest(targetCollection, targetParent, kind, name || undefined);
setDialog(null);
};
return (
<Modal
title="新建"
onClose={() => setDialog(null)}
footer={
<>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog(null)}>
取消
</button>
<button className="ops-btn ops-btn-primary" onClick={create}>
创建
</button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-4 gap-2">
{KIND_MENU.map((k) => (
<button
key={k.kind}
className={cx(
"rounded-xl border p-3 text-center transition",
kind === k.kind ? "ops-chip-active" : "hover:opacity-80",
)}
style={{ borderColor: kind === k.kind ? "var(--accent)" : "var(--border)" }}
onClick={() => setKind(k.kind)}
>
<div className="flex justify-center"><KindIcon kind={k.kind} size={30} /></div>
<div className="mt-1.5 text-xs font-semibold">
{k.kind === "folder" ? "目录" : KIND_LABELS[k.kind]}
</div>
<div className="ops-faint mt-0.5 text-[11px]">{k.desc}</div>
</button>
))}
</div>
<div>
<label className="ops-label">名称</label>
<input
className="ops-input"
placeholder={kind === "folder" ? "新建目录" : `新建 ${KIND_LABELS[kind as RequestKind]} 接口`}
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && create()}
autoFocus
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="ops-label">目标集合</label>
<select
className="ops-input"
value={targetCollection}
onChange={(e) => {
setTargetCollection(e.target.value);
setTargetParent(null);
}}
>
{collections.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="ops-label">目标目录</label>
<select
className="ops-input"
value={targetParent ?? ""}
onChange={(e) => setTargetParent(e.target.value || null)}
>
{folderOptions.map((f) => (
<option key={f.id ?? "root"} value={f.id ?? ""}>
{f.label}
</option>
))}
</select>
</div>
</div>
</div>
</Modal>
);
}
export function RenameDialog({ id }: { id: string }) {
const collections = useWorkspace((s) => s.collections);
const renameNode = useWorkspace((s) => s.renameNode);
const updateEnvironment = useWorkspace((s) => s.updateEnvironment);
const setDialog = useWorkspace((s) => s.setDialog);
const isCollection = collections.some((c) => c.id === id);
const isEnv = useWorkspace((s) => s.environments.some((e) => e.id === id));
const [name, setName] = useState(() => resolveName(id, isEnv));
const submit = () => {
if (isEnv) {
updateEnvironment(id, { name: name.trim() || "未命名环境" });
} else {
renameNode(id, name.trim() || "未命名");
}
setDialog(null);
};
void isCollection;
return (
<Modal
title="重命名"
onClose={() => setDialog(null)}
footer={
<>
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog(null)}>
取消
</button>
<button className="ops-btn ops-btn-primary" onClick={submit}>
保存
</button>
</>
}
>
<input
className="ops-input"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
autoFocus
/>
</Modal>
);
}
function resolveName(id: string, isEnv: boolean): string {
const state = useWorkspace.getState();
if (isEnv) return state.environments.find((e) => e.id === id)?.name ?? "";
const collection = state.collections.find((c) => c.id === id);
if (collection) return collection.name;
let name = "";
for (const c of state.collections) {
walk(c.children, (n) => {
if (n.id === id) name = n.name;
});
}
return name;
}
export function EnvironmentsDialog() {
const environments = useWorkspace((s) => s.environments);
const activeEnvironmentId = useWorkspace((s) => s.activeEnvironmentId);
const setActiveEnvironment = useWorkspace((s) => s.setActiveEnvironment);
const addEnvironment = useWorkspace((s) => s.addEnvironment);
const duplicateEnvironment = useWorkspace((s) => s.duplicateEnvironment);
const removeEnvironment = useWorkspace((s) => s.removeEnvironment);
const updateEnvironment = useWorkspace((s) => s.updateEnvironment);
const addEnvVariable = useWorkspace((s) => s.addEnvVariable);
const updateEnvVariable = useWorkspace((s) => s.updateEnvVariable);
const removeEnvVariable = useWorkspace((s) => s.removeEnvVariable);
const setDialog = useWorkspace((s) => s.setDialog);
const [selected, setSelected] = useState(activeEnvironmentId ?? environments[0]?.id ?? "");
const [search, setSearch] = useState("");
const env = environments.find((e) => e.id === selected);
const isActive = env?.id === activeEnvironmentId;
const filteredVars = (env?.variables ?? []).filter((v) => {
const q = search.trim().toLowerCase();
if (!q) return true;
return v.key.toLowerCase().includes(q) || (v.description ?? "").toLowerCase().includes(q);
});
return (
<Modal
title="环境管理"
onClose={() => setDialog(null)}
width="750px"
footer={
<div className="flex w-full items-center justify-between gap-3">
<span className="ops-faint text-[11px]">修改自动保存并立即生效</span>
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成
</button>
</div>
}
>
<div className="env-dialog">
{/* 左栏:环境列表 */}
<aside className="env-dialog-side">
<div className="env-dialog-group">环境</div>
{environments.map((e) => (
<button
key={e.id}
className={cx("env-dialog-item", e.id === selected && "active")}
onClick={() => setSelected(e.id)}
>
<EnvBadge envId={e.id} name={e.name} size={22} />
<span className="min-w-0 flex-1 truncate text-left">{e.name}</span>
{e.id === activeEnvironmentId && <span className="env-dialog-current">当前</span>}
</button>
))}
{environments.length === 0 && (
<div className="ops-faint px-2 py-4 text-center text-xs">还没有环境</div>
)}
<button
className="env-dialog-new"
onClick={() => {
const id = addEnvironment();
setSelected(id);
}}
>
<Icon name="plus" size={13} /> 新建环境
</button>
</aside>
{/* 右栏:编辑区 */}
<div className="env-dialog-main">
{env ? (
<>
<div className="env-dialog-heading">环境详情 <span className="ops-faint font-normal">· {isActive ? "当前使用" : "未启用"}</span></div>
<div className="env-dialog-name-row">
<EnvBadge envId={env.id} name={env.name} size={26} />
<input
className="ops-input min-w-0 flex-1"
aria-label="环境名称"
value={env.name}
onChange={(e) => updateEnvironment(env.id, { name: e.target.value })}
placeholder="环境名称"
/>
{isActive ? (
<button className="ops-btn ops-btn-secondary" onClick={() => setActiveEnvironment(null)} title="当前环境已用于请求;点击后不使用环境前置 URL">
取消使用
</button>
) : (
<button className="ops-btn ops-btn-primary" onClick={() => setActiveEnvironment(env.id)}>
<Icon name="play" size={13} /> 设为当前
</button>
)}
<button
className="ops-btn ops-btn-secondary ops-icon-button"
title="复制环境"
aria-label="复制环境"
onClick={() => setSelected(duplicateEnvironment(env.id))}
>
<Icon name="copy" size={14} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger ops-icon-button"
title="删除环境"
aria-label="删除环境"
onClick={() => {
removeEnvironment(env.id);
setSelected(useWorkspace.getState().environments[0]?.id ?? "");
}}
>
<Icon name="trash" size={14} />
</button>
</div>
<div className="mt-4">
<label className="ops-label">前置 URL</label>
<input
className="ops-input font-mono"
aria-label="当前环境的前置 URL"
value={env.baseUrl ?? ""}
onChange={(e) => updateEnvironment(env.id, { baseUrl: e.target.value })}
placeholder="https://api.example.com(请求路径以 / 开头时自动拼接)"
/>
<p className="ops-faint mt-1.5 text-[11px] leading-[1.45]">相对路径(如 /graphql)会随环境切换前缀;完整 URL 不受影响。<a href="/docs/env" target="_blank" rel="noopener noreferrer" className="ops-link">了解环境变量</a></p>
</div>
<div className="env-dialog-vars-head">
<div className="text-[13px] font-semibold">变量</div>
<span className="ops-faint text-[11px]">{env.variables.length} 项</span>
<input
className="ops-input ops-input-sm ml-auto w-36 min-w-0"
aria-label="搜索环境变量"
placeholder="搜索变量 / 描述…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button className="ops-btn ops-btn-secondary" onClick={() => addEnvVariable(env.id)}>
<Icon name="plus" size={13} /> 添加变量
</button>
</div>
<div className="env-dialog-vars">
{env.variables.length === 0 ? (
<div className="ops-faint py-8 text-center text-xs">
暂无变量,可添加 token 等请求参数
</div>
) : (
<KVEditor
rows={filteredVars}
onAdd={() => addEnvVariable(env.id)}
onUpdate={(rowId, patch) => updateEnvVariable(env.id, rowId, patch)}
onRemove={(rowId) => removeEnvVariable(env.id, rowId)}
keyPlaceholder="变量名"
valuePlaceholder="值"
withDescription
compact
showAddButton={false}
/>
)}
{search && filteredVars.length === 0 && env.variables.length > 0 && (
<div className="ops-faint py-6 text-center text-xs">没有匹配的变量</div>
)}
</div>
<p className="ops-faint mt-2 text-[11px]">在请求中使用 {"{{变量名}}"} 引用变量,切换环境时自动替换。</p>
</>
) : (
<div className="ops-faint grid h-full place-items-center text-sm">
{environments.length === 0 ? "左侧点击「新建环境」开始" : "选择左侧环境"}
</div>
)}
</div>
</div>
</Modal>
);
}
/* ---------------- 设置弹窗(左侧导航 + 分区) ---------------- */
type SettingsTab = "appearance" | "network" | "mock" | "mockdata" | "ai" | "data" | "about";
const SETTINGS_TABS: { id: SettingsTab; label: string; desc: string; icon: IconName }[] = [
{ id: "appearance", label: "外观", desc: "主题与界面", icon: "palette" },
{ id: "network", label: "网络", desc: "请求与超时", icon: "network" },
{ id: "mock", label: "Mock 服务", desc: "服务开关与访问地址", icon: "flask-conical" },
{ id: "ai", label: "大模型", desc: "供应商与默认模型", icon: "bot" },
{ id: "data", label: "数据管理", desc: "工作区与备份", icon: "database" },
{ id: "about", label: "关于", desc: "版本与链接", icon: "info" },
];
function SettingRow({
label,
desc,
children,
}: {
label: string;
desc?: React.ReactNode;
children?: React.ReactNode;
}) {
return (
<div className="set-row">
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium">{label}</div>
{desc ? <div className="ops-faint mt-0.5 text-[11px] leading-5">{desc}</div> : null}
</div>
<div className="set-row-control">{children}</div>
</div>
);
}
function SettingGroup({ title, children }: { title?: string; children: React.ReactNode }) {
return (
<section className="set-group">
{title ? <div className="set-group-title">{title}</div> : null}
{children}
</section>
);
}
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<button
className={cx("set-toggle", checked && "on")}
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
>
<span className="set-toggle-dot" />
</button>
);
}
export function SettingsDialog({ tab }: { tab?: SettingsTab }) {
const settings = useWorkspace((s) => s.settings);
const environments = useWorkspace((s) => s.environments);
const collections = useWorkspace((s) => s.collections);
const setTheme = useWorkspace((s) => s.setTheme);
const setTimeoutMs = useWorkspace((s) => s.setTimeoutMs);
const setMockOption = useWorkspace((s) => s.setMockOption);
const updateMockRule = useWorkspace((s) => s.updateMockRule);
const addMockRule = useWorkspace((s) => s.addMockRule);
const removeMockRule = useWorkspace((s) => s.removeMockRule);
const resetMockRules = useWorkspace((s) => s.resetMockRules);
const resetWorkspace = useWorkspace((s) => s.resetWorkspace);
const setDialog = useWorkspace((s) => s.setDialog);
const [active, setActive] = useState<SettingsTab>(tab ?? "appearance");
const [ruleSearch, setRuleSearch] = useState("");
const [previewRule, setPreviewRule] = useState("");
const mock = settings.mock;
const filteredRules = mock.rules.filter((r) => {
const q = ruleSearch.trim().toLowerCase();
if (!q) return true;
return (
r.pattern.toLowerCase().includes(q) ||
r.rule.toLowerCase().includes(q) ||
(r.note ?? "").toLowerCase().includes(q)
);
});
const requestCount = collections.reduce(
(acc, c) => acc + (c.children.filter((n) => n.type === "request").length ? 1 : 0),
0,
);
let totalRequests = 0;
for (const c of collections) {
walk(c.children, (n) => {
if (n.type === "request") totalRequests++;
});
}
void requestCount;
const currentMeta = SETTINGS_TABS.find((t) => t.id === active) ?? { label: "请求数据生成" };
return (
<Modal
title="设置"
onClose={() => setDialog(null)}
width="860px"
footer={<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>完成</button>}
>
<div className="set-dialog">
<aside className="set-side">
<div className="set-side-head">设置</div>
{SETTINGS_TABS.map((t) => (
<button
key={t.id}
className={cx("set-side-item", (active === t.id || (active === "mockdata" && t.id === "network")) && "active")}
onClick={() => setActive(t.id)}
>
<span className={cx("set-side-icon", active === t.id && "active")}>
<Icon name={t.icon} size={15} />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block truncate text-[13px] font-medium leading-5">{t.label}</span>
<span className="ops-faint block truncate text-[11px] leading-4">{t.desc}</span>
</span>
</button>
))}
</aside>
<div className="set-main">
<div className="set-main-head">
<div className="flex items-center gap-2 text-[16px] font-semibold">
{active === "mockdata" && <button type="button" className="ops-btn ops-btn-ghost !px-1" aria-label="返回网络设置" title="返回网络设置" onClick={() => setActive("network")}><Icon name="chevron-right" size={14} className="rotate-180" /></button>}
{currentMeta.label}
</div>
</div>
<div className="set-main-body">
{active === "appearance" && (
<>
<SettingGroup title="主题">
<SettingRow label="界面主题">
<div className="flex gap-1.5">
{(["dark", "light", "system"] as const).map((t) => (
<button
key={t}
className={cx(
"ops-btn ops-btn-secondary",
settings.theme === t && "ops-chip-active",
)}
onClick={() => setTheme(t)}
>
{t !== "system" && <Icon name={t === "dark" ? "moon" : "sun"} size={13} />}
{t === "system" ? "跟随系统" : t === "dark" ? "深色" : "浅色"}
</button>
))}
</div>
</SettingRow>
</SettingGroup>
<SettingGroup title="界面">
<SettingRow
label="环境徽标颜色"
desc="每个环境按名称生成固定色块,便于在顶栏快速识别"
>
<div className="flex items-center gap-1">
{environments.slice(0, 6).map((e) => (
<EnvBadge key={e.id} envId={e.id} name={e.name} size={22} />
))}
</div>
</SettingRow>
</SettingGroup>
</>
)}
{active === "network" && (
<>
<SettingGroup title="请求">
<SettingRow label="请求超时(毫秒)" desc="HTTP / GraphQL 请求的超时上限,超时自动中断">
<input
type="number"
className="ops-input ops-input-sm w-32"
value={settings.timeoutMs}
min={1000}
step={1000}
onChange={(e) => setTimeoutMs(Number(e.target.value) || 30000)}
/>
</SettingRow>
</SettingGroup>
<ProxySettingsPanel />
<SettingGroup title="可选工具">
<SettingRow label="请求数据生成" desc="发送请求时使用 @ 表达式与字段匹配规则;与内网 Mock 服务独立">
<button type="button" className="ops-btn ops-btn-secondary" onClick={() => setActive("mockdata")}>管理规则 <Icon name="chevron-right" size={13} /></button>
</SettingRow>
</SettingGroup>
<SettingGroup title="实时连接">
<SettingRow
label="WebSocket / SSE / Socket.IO / MQTT"
desc="关闭标签页自动断开连接;会话不落盘,刷新后重新连接"
>
<span className="ops-faint text-[11px]">自动管理</span>
</SettingRow>
</SettingGroup>
</>
)}
{active === "mock" && <MockSharingPanel />}
{active === "mockdata" && (
<>
<SettingGroup>
<SettingRow
label="启用请求数据生成"
desc="发送请求时解析参数与 Body 中的 @ 表达式(如 @cname、@integer(1,100));不改变内网 Mock 服务的返回数据"
>
<Toggle checked={mock.enabled} onChange={(v) => setMockOption("enabled", v)} />
</SettingRow>
<SettingRow
label="智能字段匹配"
desc="JSON Body 中字段值未写 Mock 规则时,按字段名自动匹配下方规则生成"
>
<Toggle checked={mock.smart} onChange={(v) => setMockOption("smart", v)} />
</SettingRow>
</SettingGroup>
<SettingGroup title="智能匹配规则">
<div className="ops-hint">
<Icon name="info" size={14} />
<span>
按从上到下的顺序匹配,先命中先用;<b>* 通配任意字符</b>,类型需与 JSON 字段一致。
<a
href="/docs/mock"
target="_blank"
rel="noopener noreferrer"
className="ops-link ml-1"
>
语法速查 →
</a>
</span>
</div>
<div className="mb-2 flex items-center gap-2">
<input
className="ops-input ops-input-sm w-48"
placeholder="搜索规则 / 表达式…"
value={ruleSearch}
onChange={(e) => setRuleSearch(e.target.value)}
/>
<span className="ops-faint text-[11px]">{mock.rules.length} 条</span>
<div className="ml-auto flex gap-1.5">
<button className="ops-btn ops-btn-secondary" onClick={() => addMockRule()}>
<Icon name="plus" size={13} /> 新增规则
</button>
<button
className="ops-btn ops-btn-secondary"
title="恢复内置默认规则(会覆盖自定义规则)"
onClick={() => resetMockRules()}
>
<Icon name="eraser" size={13} /> 恢复默认
</button>
</div>
</div>
<div className="mock-rules">
<div className="mock-rule-head">
<span>数据类型</span>
<span>匹配方式</span>
<span>匹配规则</span>
<span>Mock 表达式</span>
<span>备注</span>
<span>启用</span>
<span />
</div>
{filteredRules.map((r) => (
<div key={r.id} className="mock-rule-row">
<select
className="ops-input ops-input-sm"
value={r.type}
onChange={(e) => updateMockRule(r.id, { type: e.target.value as never })}
>
<option value="string">string</option>
<option value="number">number</option>
<option value="integer">integer</option>
<option value="boolean">boolean</option>
</select>
<select
className="ops-input ops-input-sm"
value={r.match}
onChange={(e) => updateMockRule(r.id, { match: e.target.value as never })}
>
<option value="wildcard">通配符</option>
<option value="exact">完全匹配</option>
<option value="regex">正则</option>
</select>
<input
className="ops-input ops-input-sm mock-rule-pattern"
value={r.pattern}
placeholder="*name*"
onChange={(e) => updateMockRule(r.id, { pattern: e.target.value })}
/>
<input
className="ops-input ops-input-sm mock-rule-expr"
value={r.rule}
placeholder="@cname"
onChange={(e) => updateMockRule(r.id, { rule: e.target.value })}
/>
<input
className="ops-input ops-input-sm mock-rule-note"
value={r.note ?? ""}
placeholder="备注"
onChange={(e) => updateMockRule(r.id, { note: e.target.value })}
/>
<Toggle
checked={r.enabled}
onChange={(v) => updateMockRule(r.id, { enabled: v })}
/>
<button
className="ops-btn ops-btn-ghost ops-danger px-1.5"
title="删除规则"
onClick={() => removeMockRule(r.id)}
>
<Icon name="trash" size={13} />
</button>
</div>
))}
{filteredRules.length === 0 && (
<div className="ops-faint py-6 text-center text-xs">没有匹配的规则</div>
)}
</div>
</SettingGroup>
<SettingGroup title="表达式预览">
<div className="flex items-center gap-2">
<input
className="ops-input ops-input-sm w-56"
placeholder="输入表达式预览,如 @email"
value={previewRule}
onChange={(e) => setPreviewRule(e.target.value)}
/>
{previewRule.trim().startsWith("@") && (
<span className="mock-preview-value">{generateMock(previewRule.trim())}</span>
)}
</div>
<div className="mock-cheatsheet">
{MOCK_CHEATSHEET.map((c) => (
<button
key={c.expr}
className="mock-cheat-item"
title="点击试一试"
onClick={() => setPreviewRule(c.expr)}
>
<code>{c.expr}</code>
<span className="ops-faint">{c.desc}</span>
<span className="mock-cheat-gen">{generateMock(c.expr)}</span>
</button>
))}
</div>
</SettingGroup>
</>
)}
{active === "ai" && <AiProviderSettings />}
{active === "data" && (
<>
<SettingGroup title="工作区数据">
<SettingRow
label="本地存储"
desc="集合、环境与设置保存在本地 localStorage,不经过任何服务器"
>
<span className="ops-faint text-[11px]">浏览器本地</span>
</SettingRow>
<SettingRow label="数据概览">
<span className="ops-faint text-[11px]">
{collections.length} 个集合 · {totalRequests} 个接口 · {environments.length} 个环境
</span>
</SettingRow>
</SettingGroup>
<SettingGroup title="备份与恢复">
<SettingRow
label="导出全量备份"
desc="包含全部集合、环境与设置,可在其他设备导入恢复"
>
<button
className="ops-btn ops-btn-secondary"
onClick={() => setDialog({ type: "export", collectionId: collections[0]?.id ?? "" })}
>
<Icon name="download" size={13} /> 打开导出
</button>
</SettingRow>
<SettingRow
label="重置工作区"
desc="恢复到初始示例数据,当前数据不可恢复,建议先导出备份"
>
<button
className="ops-btn ops-btn-danger"
onClick={() => {
if (confirm("确定重置工作区?将恢复到初始示例数据(建议先导出备份)")) {
resetWorkspace();
setDialog(null);
}
}}
>
<Icon name="trash" size={13} /> 重置
</button>
</SettingRow>
</SettingGroup>
</>
)}
{active === "about" && (
<>
<p className="ops-faint px-1 text-[11px]">
OpsxAPI · 专业接口调试工作台 · HTTP · GraphQL · WebSocket · SSE · Socket.IO · MQTT
</p>
<SettingGroup title="资源链接">
<SettingRow label="微信群" desc="扫码加入交流群">
<button
type="button"
className="ops-btn ops-btn-secondary"
title="打开微信群"
onClick={() => void openExternal(WECHAT_GROUP_URL)}
>
<Icon name="external-link" size={13} />
</button>
</SettingRow>
<SettingRow label="用户文档" desc="功能说明、协议调试与最佳实践">
<button type="button" className="ops-btn ops-btn-secondary" title="打开用户文档" onClick={() => void openExternal("/docs")}>
<Icon name="book-open" size={13} />
</button>
</SettingRow>
<SettingRow label="官网与价格" desc="产品介绍、版本对比与购买方案">
<button type="button" className="ops-btn ops-btn-secondary" title="打开官网" onClick={() => void openExternal("/home")}>
<Icon name="external-link" size={13} />
</button>
</SettingRow>
<SettingRow label="Mock 语法速查" desc="内置 Mock.js 风格表达式与智能匹配说明">
<button type="button" className="ops-btn ops-btn-secondary" title="查看 Mock 速查" onClick={() => void openExternal("/docs/mock")}>
<Icon name="flask-conical" size={13} />
</button>
</SettingRow>
</SettingGroup>
<p className="ops-faint px-1 text-[11px]">
MIT License · Rust
</p>
</>
)}
</div>
</div>
</div>
</Modal>
);
}