借鉴 Yaak 授权模型 + 复用自家 OpsXShell 生产级授权协议(opsx-licence 服务端): - Rust services/license:30 天试用自首次启动自动开始;LIC- 在线激活/周检/解绑 (双节点容灾 + 响应 Ed25519 签名校验 + 时钟回拨检测 + 30 天离线容忍); LICOFF- 离线证本地自验(产品码 opsxapi + 设备绑定 + ≤1 年期限) - 前端 src/license.ts + 活动栏授权徽章(试用 N 天/已授权/未授权)+ 设置→授权页(状态横幅/激活表单/解绑/定价链接带 ?intent= 追踪) - /pricing 独立定价页(三站同结构):30 天试用条款、在线/离线激活说明三卡、 年付↔终身切换、授权 FAQ、独立开发者声明;导航全部接入 - opsxDB 个人 ¥69/年·¥199 终身 / 团队 ¥69 / 私有化 ¥6000(与 API 定位错开) - GUI 实测:试用 30 天徽章渲染正常;浏览器态授权页正确显示仅桌面端提示
953 lines
39 KiB
TypeScript
953 lines
39 KiB
TypeScript
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 { useLicense } from "../../license";
|
||
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" | "license" | "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: "license", label: "授权", desc: "试用与许可证", icon: "shield-check" },
|
||
{ 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 === "license" && <LicensePane />}
|
||
{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>
|
||
);
|
||
}
|
||
|
||
/* ---------------- 授权:试用状态 + 在线/离线激活 ---------------- */
|
||
|
||
function LicensePane() {
|
||
const status = useLicense((s) => s.status);
|
||
const busy = useLicense((s) => s.busy);
|
||
const error = useLicense((s) => s.error);
|
||
const activate = useLicense((s) => s.activate);
|
||
const deactivate = useLicense((s) => s.deactivate);
|
||
const load = useLicense((s) => s.load);
|
||
const [key, setKey] = useState("");
|
||
const [email, setEmail] = useState("");
|
||
const [ok, setOk] = useState(false);
|
||
|
||
const doActivate = async () => {
|
||
if (!key.trim()) return;
|
||
setOk(false);
|
||
const done = await activate(key, email);
|
||
if (done) {
|
||
setKey("");
|
||
setOk(true);
|
||
}
|
||
};
|
||
|
||
if (!status) {
|
||
return (
|
||
<div className="ops-hint">
|
||
<Icon name="info" size={14} />
|
||
<span>授权状态仅在桌面端可用;网页预览环境不涉及许可证。</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const until = status.expiresAt ? new Date(status.expiresAt * 1000).toLocaleDateString("zh-CN") : "永久";
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{status.phase === "active" ? (
|
||
<div className="set-banner" style={{ borderColor: "var(--success)" }}>
|
||
<div className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--success)" }}>
|
||
<Icon name="shield-check" size={15} /> 已授权
|
||
</div>
|
||
<div className="ops-faint mt-1 text-[12px] leading-6">
|
||
{status.planType ? `计划:${status.planType} · ` : ""}
|
||
{status.maxDevices ? `设备数:${status.maxDevices} · ` : ""}
|
||
到期:{until}
|
||
{status.email ? ` · ${status.email}` : ""}
|
||
</div>
|
||
<div className="ops-faint mt-1 text-[11px]">设备 ID:{status.deviceId}</div>
|
||
<div className="mt-2 flex gap-2">
|
||
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => void deactivate()}>
|
||
解绑本机
|
||
</button>
|
||
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => void load()}>
|
||
刷新状态
|
||
</button>
|
||
</div>
|
||
</div>
|
||
) : status.phase === "trialing" ? (
|
||
<div className="set-banner" style={{ borderColor: "var(--accent)" }}>
|
||
<div className="flex items-center gap-2 text-[13px] font-semibold" style={{ color: "var(--accent-strong)" }}>
|
||
<Icon name="clock" size={15} /> 试用中 · 剩 {status.daysLeft} 天
|
||
</div>
|
||
<div className="ops-faint mt-1 text-[12px] leading-6">
|
||
30 天全功能试用自首次启动自动开始。试用期结束后个人非商业用途可继续使用;工作中商用需购买授权。
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="set-banner" style={{ borderColor: "var(--danger)" }}>
|
||
<div className="flex items-center gap-2 text-[13px] font-semibold ops-danger">
|
||
<Icon name="shield-check" size={15} /> 试用已结束
|
||
</div>
|
||
<div className="ops-faint mt-1 text-[12px] leading-6">
|
||
个人非商业用途可继续使用;在工作中商用请购买授权。
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{status.phase !== "active" && (
|
||
<div className="set-group">
|
||
<div className="set-group-title">激活许可证</div>
|
||
<div className="space-y-2">
|
||
<input
|
||
className="ops-input font-mono"
|
||
placeholder="LIC- 在线授权码 或 LICOFF- 离线许可证"
|
||
value={key}
|
||
onChange={(e) => { setKey(e.target.value); setOk(false); }}
|
||
onKeyDown={(e) => e.key === "Enter" && void doActivate()}
|
||
/>
|
||
<input
|
||
className="ops-input"
|
||
placeholder="邮箱(可选,用于授权关联)"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
/>
|
||
<div className="flex items-center gap-2">
|
||
<button className="ops-btn ops-btn-primary" disabled={busy || !key.trim()} onClick={() => void doActivate()}>
|
||
{busy ? "激活中…" : "激活"}
|
||
</button>
|
||
<span className="ops-faint text-[11px]">
|
||
LIC- 联网激活、可自助解绑;LICOFF- 完全离线激活(绑定本机)
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{ok && <div className="ops-success text-[12px]">激活成功,已授权 ✓</div>}
|
||
{error && <div className="ops-danger text-[12px]">{error}</div>}
|
||
|
||
<div className="flex items-center justify-between">
|
||
<span className="ops-faint text-[11px]">定价与购买</span>
|
||
<button
|
||
className="ops-btn ops-btn-secondary !py-1 text-[12px]"
|
||
onClick={() => void openExternal(`/pricing?intent=app.license.purchase.${status.phase}`)}
|
||
>
|
||
<Icon name="external-link" size={13} /> 查看定价
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|