Files
opsxapi/src/components/dialogs/WorkspaceDialogs.tsx
T
logan c304b9f251 授权体系落地:30 天试用 + LIC- 在线 / LICOFF- 离线激活 + /pricing 定价页
借鉴 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 天徽章渲染正常;浏览器态授权页正确显示仅桌面端提示
2026-10-16 03:27:45 +08:00

953 lines
39 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}