桌面 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:
1 parent
6452026fd6
commit
2bb4ef7a95
299 files changed
+6059
-3322
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user