账号与团队:SQLite 云端空间(个人 / 团队共享 + 自动同步)
- 服务端(Gin + modernc.org/sqlite,data/opsxapi.db WAL): - users/tokens/teams/team_members/workspaces 表;注册登录(加盐哈希 + Bearer 令牌) - /api:auth register/login/logout、me(用户+团队+空间)、teams CRUD/邀请成员、workspaces 读写(20MB 上限,成员鉴权) - 前端: - account.ts 客户端 + store 账号态:登录/注册/登出/团队/空间切换 - 顶栏账号区:未登录「登录」按钮;登录后用户胶囊(头像+空间名+已同步/同步中/失败)+ 空间切换菜单 + ⋯(立即同步/团队管理/退出) - 空间模型:本地(localStorage)与云端(SQLite)隔离,切云端空空间自动播种本地数据;云端改动 1.5s 去抖自动同步;切回本地恢复快照 - 登录/注册弹窗 + 团队管理弹窗(建团队、邮箱邀请);vite 代理 /api;docs 新增「账号与团队」页 - 验证:curl E2E(注册/冲突 409/错误密码 401/团队共享写/越权 401)+ 浏览器全链路(注册→播种上云 17KB→云端编辑自动同步→本地快照隔离)
This commit is contained in:
1 parent
045e8c65c3
commit
aabd20bd75
18 files changed
+1338
-38
No files matched your search
@@ -20,6 +20,7 @@ import { SseEditor } from "./components/editors/SseEditor";
|
||||
import { WebsocketEditor } from "./components/editors/WebsocketEditor";
|
||||
import { ExportDialog } from "./components/dialogs/ExportDialog";
|
||||
import { ImportDialog } from "./components/dialogs/ImportDialog";
|
||||
import { AuthDialog, TeamDialog } from "./components/dialogs/AccountDialogs";
|
||||
import {
|
||||
EnvironmentsDialog,
|
||||
NewItemDialog,
|
||||
@@ -93,6 +94,8 @@ export function OpsxWorkspace() {
|
||||
{dialog?.type === "environments" && <EnvironmentsDialog />}
|
||||
{dialog?.type === "settings" && <SettingsDialog tab={dialog.tab} />}
|
||||
{dialog?.type === "rename" && <RenameDialog id={dialog.id} />}
|
||||
{dialog?.type === "auth" && <AuthDialog />}
|
||||
{dialog?.type === "team" && <TeamDialog />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 账号 / 团队 / 云端空间客户端(同源 /api,经 Vite 代理或桌面直连本地服务)。
|
||||
*/
|
||||
export type AccountUser = { id: number; name: string; email: string };
|
||||
export type AccountTeam = { id: number; name: string; role: string; owner: string };
|
||||
export type AccountWorkspace = { id: number; name: string; teamId: number | null; ownerId: number; updatedAt: number };
|
||||
|
||||
class ApiError extends Error {
|
||||
constructor(message: string, public status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function call<T>(path: string, token: string | null, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`/api${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
const data = text ? (JSON.parse(text) as T & { error?: string }) : ({} as T & { error?: string });
|
||||
if (!res.ok) throw new ApiError(data.error || `请求失败(${res.status})`, res.status);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export function authRegister(name: string, email: string, password: string) {
|
||||
return call<{ token: string; user: AccountUser }>("/auth/register", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function authLogin(email: string, password: string) {
|
||||
return call<{ token: string; user: AccountUser }>("/auth/login", null, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
}
|
||||
|
||||
export function authLogout(token: string) {
|
||||
return call<{ ok: boolean }>("/auth/logout", token, { method: "POST" });
|
||||
}
|
||||
|
||||
export function fetchMe(token: string) {
|
||||
return call<{ user: AccountUser; teams: AccountTeam[]; workspaces: AccountWorkspace[] }>("/me", token);
|
||||
}
|
||||
|
||||
export function createTeam(token: string, name: string) {
|
||||
return call<AccountTeam>("/teams", token, { method: "POST", body: JSON.stringify({ name }) });
|
||||
}
|
||||
|
||||
export function addTeamMember(token: string, teamId: number, email: string) {
|
||||
return call<{ ok: boolean; member: AccountUser }>(`/teams/${teamId}/members`, token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchWorkspace(token: string, id: number) {
|
||||
return call<{ id: number; name: string; teamId: number | null; data: string; updatedAt: number }>(
|
||||
`/workspaces/${id}`,
|
||||
token,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveWorkspace(token: string, id: number, data: string) {
|
||||
return call<{ updatedAt: number }>(`/workspaces/${id}`, token, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ data }),
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------- 本地令牌存储 ---------------- */
|
||||
|
||||
const AUTH_KEY = "opsxapi.auth.v1";
|
||||
|
||||
export function loadToken(): string | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(AUTH_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as { token?: string };
|
||||
return parsed.token ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveToken(token: string | null): void {
|
||||
try {
|
||||
if (token) localStorage.setItem(AUTH_KEY, JSON.stringify({ token }));
|
||||
else localStorage.removeItem(AUTH_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useWorkspace } from "../workspaceStore";
|
||||
import { ContextMenu, type MenuItem } from "./common";
|
||||
import { ContextMenu, cx, type MenuItem } from "./common";
|
||||
import { EnvBadge } from "./EnvBadge";
|
||||
import { Icon, type IconName } from "./icons";
|
||||
import { Logo } from "./Logo";
|
||||
@@ -31,6 +31,11 @@ export function TopBar() {
|
||||
const [newMenu, setNewMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [exportMenu, setExportMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [moreMenu, setMoreMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [spaceMenu, setSpaceMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [accountMenu, setAccountMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const account = useWorkspace((s) => s.account);
|
||||
const switchSpace = useWorkspace((s) => s.switchSpace);
|
||||
const syncNow = useWorkspace((s) => s.syncNow);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "OpsxAPI · 接口工作台";
|
||||
@@ -89,6 +94,66 @@ export function TopBar() {
|
||||
},
|
||||
];
|
||||
|
||||
const activeSpace =
|
||||
account.activeSpaceId === null ? null : account.spaces.find((sp) => sp.id === account.activeSpaceId) ?? null;
|
||||
const spaceName = activeSpace
|
||||
? `${activeSpace.teamId !== null ? "团队 · " : ""}${activeSpace.name}`
|
||||
: "本地空间";
|
||||
|
||||
const spaceItems: MenuItem[] = [
|
||||
{
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Icon name="shield-check" size={13} className="ops-faint" />
|
||||
<span className="truncate">本地空间</span>
|
||||
{account.activeSpaceId === null && <span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>}
|
||||
</span>
|
||||
),
|
||||
onClick: () => void switchSpace(null),
|
||||
},
|
||||
...account.spaces.map((sp) => ({
|
||||
label: (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<Icon name={sp.teamId !== null ? "library" : "shield-check"} size={13} className="ops-faint" />
|
||||
<span className="truncate">{sp.teamId !== null ? `团队 · ${sp.name}` : sp.name}</span>
|
||||
{sp.id === account.activeSpaceId ? (
|
||||
<span className="ml-auto pl-3 text-[10px] ops-success">● 当前</span>
|
||||
) : (
|
||||
<span className="ops-faint ml-auto pl-3 text-[10px]">
|
||||
{new Date(sp.updatedAt * 1000).toLocaleDateString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: () => void switchSpace(sp.id),
|
||||
})),
|
||||
];
|
||||
|
||||
const accountItems: MenuItem[] = account.user
|
||||
? [
|
||||
...(account.activeSpaceId !== null
|
||||
? [
|
||||
{
|
||||
label: account.syncing ? "同步中…" : account.lastSyncAt ? `已同步 ${new Date(account.lastSyncAt).toLocaleTimeString("zh-CN")}` : "立即同步到云端",
|
||||
icon: <Icon name="arrow-left-right" size={14} />,
|
||||
onClick: () => void syncNow(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{ type: "sep" as const },
|
||||
{
|
||||
label: "团队管理…",
|
||||
icon: <Icon name="library" size={14} />,
|
||||
onClick: () => setDialog({ type: "team" }),
|
||||
},
|
||||
{
|
||||
label: "退出登录",
|
||||
icon: <Icon name="circle-x" size={14} />,
|
||||
onClick: () => void useWorkspace.getState().authLogout(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const moreItems: MenuItem[] = [
|
||||
{
|
||||
label: "设置…",
|
||||
@@ -224,6 +289,46 @@ export function TopBar() {
|
||||
|
||||
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||
|
||||
{account.user ? (
|
||||
<>
|
||||
<button
|
||||
className="ops-envswitch"
|
||||
title={`当前空间:${spaceName}\n点击切换本地 / 个人 / 团队空间`}
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setSpaceMenu((m) => (m ? null : { x: rect.left, y: rect.bottom + 4 }));
|
||||
}}
|
||||
>
|
||||
<span className="ops-user-avatar">{account.user.name.slice(0, 1).toUpperCase()}</span>
|
||||
<span className="max-w-36 truncate">{spaceName}</span>
|
||||
{account.syncing ? (
|
||||
<span className="ops-faint text-[10px]">同步中…</span>
|
||||
) : account.activeSpaceId !== null ? (
|
||||
<span className={cx("text-[10px]", account.syncError ? "ops-danger" : "ops-success")} title={account.syncError ?? undefined}>
|
||||
{account.syncError ? "同步失败" : "已同步"}
|
||||
</span>
|
||||
) : null}
|
||||
<Icon name="chevron-down" size={13} className="ops-faint" />
|
||||
</button>
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost !px-2"
|
||||
title={`${account.user.name}(${account.user.email})`}
|
||||
onClick={(e) => {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
setAccountMenu({ x: rect.right, y: rect.bottom + 4 });
|
||||
}}
|
||||
>
|
||||
<Icon name="ellipsis" size={14} />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button className="ops-btn ops-btn-secondary" onClick={() => setDialog({ type: "auth" })} title="登录 / 注册,开启云端空间">
|
||||
<Icon name="shield-check" size={13} /> 登录
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mx-1 h-5 w-px" style={{ background: "var(--border)" }} />
|
||||
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost"
|
||||
onClick={() => setTheme(settings.theme === "dark" ? "light" : "dark")}
|
||||
@@ -259,6 +364,14 @@ export function TopBar() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
menu={spaceMenu ? { x: spaceMenu.x, y: spaceMenu.y, items: spaceItems } : null}
|
||||
onClose={() => setSpaceMenu(null)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={accountMenu ? { x: accountMenu.x, y: accountMenu.y, items: accountItems } : null}
|
||||
onClose={() => setAccountMenu(null)}
|
||||
/>
|
||||
<ContextMenu
|
||||
menu={moreMenu ? { x: moreMenu.x, y: moreMenu.y, items: moreItems } : null}
|
||||
onClose={() => setMoreMenu(null)}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* 账号弹窗:登录 / 注册;团队管理:建团队、邀请成员。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import { useWorkspace } from "../../workspaceStore";
|
||||
import { Modal, cx } from "../common";
|
||||
import { Icon } from "../icons";
|
||||
|
||||
/* ---------------- 登录 / 注册 ---------------- */
|
||||
|
||||
export function AuthDialog() {
|
||||
const authLogin = useWorkspace((s) => s.authLogin);
|
||||
const authRegister = useWorkspace((s) => s.authRegister);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
const [mode, setMode] = useState<"login" | "register">("login");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "login") await authLogin(email, password);
|
||||
else await authRegister(name, email, password);
|
||||
setDialog(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={mode === "login" ? "登录" : "注册账号"}
|
||||
onClose={() => setDialog(null)}
|
||||
width="400px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
className="ops-btn ops-btn-ghost !px-2 text-[12px]"
|
||||
onClick={() => {
|
||||
setMode(mode === "login" ? "register" : "login");
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
{mode === "login" ? "没有账号?注册" : "已有账号?登录"}
|
||||
</button>
|
||||
<button className="ops-btn ops-btn-primary" disabled={busy} onClick={submit}>
|
||||
{busy ? "请稍候…" : mode === "login" ? "登录" : "注册并登录"}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="ops-hint">
|
||||
<Icon name="info" size={14} />
|
||||
<span>账号数据保存在本机服务(SQLite),用于个人 / 团队云端空间同步</span>
|
||||
</div>
|
||||
{mode === "register" && (
|
||||
<div>
|
||||
<label className="ops-label">昵称</label>
|
||||
<input
|
||||
className="ops-input"
|
||||
placeholder="怎么称呼你"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="ops-label">邮箱</label>
|
||||
<input
|
||||
className="ops-input"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="ops-label">密码</label>
|
||||
<input
|
||||
className="ops-input"
|
||||
type="password"
|
||||
placeholder="至少 6 位"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="ops-danger text-[12px]">{error}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 团队管理 ---------------- */
|
||||
|
||||
export function TeamDialog() {
|
||||
const account = useWorkspace((s) => s.account);
|
||||
const createTeam = useWorkspace((s) => s.createTeam);
|
||||
const addTeamMember = useWorkspace((s) => s.addTeamMember);
|
||||
const setDialog = useWorkspace((s) => s.setDialog);
|
||||
|
||||
const [teamName, setTeamName] = useState("");
|
||||
const [inviteEmail, setInviteEmail] = useState<Record<number, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [okMsg, setOkMsg] = useState("");
|
||||
|
||||
const doCreate = async () => {
|
||||
if (!teamName.trim()) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await createTeam(teamName);
|
||||
setTeamName("");
|
||||
setOkMsg("团队已创建,左侧空间列表可切换到团队空间");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doInvite = async (teamId: number) => {
|
||||
const email = (inviteEmail[teamId] ?? "").trim();
|
||||
if (!email) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await addTeamMember(teamId, email);
|
||||
setInviteEmail((m) => ({ ...m, [teamId]: "" }));
|
||||
setOkMsg(`已添加成员 ${email}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="团队管理"
|
||||
onClose={() => setDialog(null)}
|
||||
width="560px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">团队成员共享同一份云端空间,切换即协同</span>
|
||||
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="ops-label">新建团队</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="ops-input flex-1"
|
||||
placeholder="团队名称,如:支付网关组"
|
||||
value={teamName}
|
||||
onChange={(e) => setTeamName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && doCreate()}
|
||||
/>
|
||||
<button className="ops-btn ops-btn-secondary" disabled={busy} onClick={doCreate}>
|
||||
<Icon name="plus" size={13} /> 创建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{account.teams.length === 0 ? (
|
||||
<div className="ops-faint py-4 text-center text-xs">还没有团队,创建一个即可获得团队共享空间</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{account.teams.map((t) => (
|
||||
<div key={t.id} className="rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}>
|
||||
<Icon name="library" size={13} />
|
||||
</span>
|
||||
<span className="text-[13px] font-medium">{t.name}</span>
|
||||
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}>
|
||||
{t.role === "owner" ? "创建者" : "成员"}
|
||||
</span>
|
||||
<span className="ops-faint ml-auto text-[11px]">创建者 {t.owner}</span>
|
||||
</div>
|
||||
{t.role === "owner" && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
className="ops-input ops-input-sm flex-1"
|
||||
placeholder="成员邮箱(需已注册)"
|
||||
value={inviteEmail[t.id] ?? ""}
|
||||
onChange={(e) => setInviteEmail((m) => ({ ...m, [t.id]: e.target.value }))}
|
||||
onKeyDown={(e) => e.key === "Enter" && doInvite(t.id)}
|
||||
/>
|
||||
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => doInvite(t.id)}>
|
||||
添加成员
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="ops-danger text-[12px]">{error}</div>}
|
||||
{okMsg && <div className="ops-success text-[12px]">{okMsg}</div>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,18 @@ import {
|
||||
normalizeMockSettings,
|
||||
uniqueName,
|
||||
} from "@opsxapi/core";
|
||||
import {
|
||||
addTeamMember as addTeamMemberApi,
|
||||
authLogin as authLoginApi,
|
||||
authLogout as authLogoutApi,
|
||||
authRegister as authRegisterApi,
|
||||
createTeam as createTeamApi,
|
||||
fetchMe,
|
||||
fetchWorkspace,
|
||||
loadToken,
|
||||
saveToken,
|
||||
saveWorkspace,
|
||||
} from "./account";
|
||||
import { create } from "zustand";
|
||||
|
||||
const STORAGE_KEY = "opsxapi.workspace.v2";
|
||||
@@ -75,7 +87,9 @@ export type Dialog =
|
||||
| { type: "environments" }
|
||||
| { type: "settings"; tab?: "appearance" | "network" | "mock" | "ai" | "data" | "about" }
|
||||
| { type: "rename"; id: string }
|
||||
| { type: "confirm"; title: string; message: string; action: () => void };
|
||||
| { type: "confirm"; title: string; message: string; action: () => void }
|
||||
| { type: "auth" }
|
||||
| { type: "team" };
|
||||
|
||||
export function emptyRuntime(): RequestRuntime {
|
||||
return {
|
||||
@@ -113,8 +127,29 @@ type WorkspaceStore = PersistedShape & {
|
||||
/** 主视图:接口工作台 / 自动化测试 */
|
||||
view: "api" | "testing";
|
||||
|
||||
/** 账号与云端空间(null 空间 = 本地) */
|
||||
account: {
|
||||
token: string | null;
|
||||
user: { id: number; name: string; email: string } | null;
|
||||
teams: { id: number; name: string; role: string; owner: string }[];
|
||||
spaces: { id: number; name: string; teamId: number | null; updatedAt: number }[];
|
||||
activeSpaceId: number | null;
|
||||
syncing: boolean;
|
||||
lastSyncAt: number | null;
|
||||
syncError: string | null;
|
||||
};
|
||||
|
||||
init(): void;
|
||||
setView(view: "api" | "testing"): void;
|
||||
authLogin(email: string, password: string): Promise<void>;
|
||||
authRegister(name: string, email: string, password: string): Promise<void>;
|
||||
authLogout(): Promise<void>;
|
||||
refreshAccount(): Promise<void>;
|
||||
createTeam(name: string): Promise<void>;
|
||||
addTeamMember(teamId: number, email: string): Promise<void>;
|
||||
/** 切换空间:null=本地;服务端空间为空时用当前数据播种 */
|
||||
switchSpace(spaceId: number | null): Promise<void>;
|
||||
syncNow(): Promise<void>;
|
||||
|
||||
addCollection(name?: string): string;
|
||||
renameNode(id: string, name: string): void;
|
||||
@@ -261,29 +296,33 @@ function migrateBaseUrl(
|
||||
return { collections: collections.map((c) => ({ ...c, children: fix(c.children) })), environments: envs };
|
||||
}
|
||||
|
||||
/** 任意来源(本地/云端)的工作区数据归一化 */
|
||||
function normalizeWorkspaceData(data: Partial<WorkspaceData>): WorkspaceData | null {
|
||||
if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null;
|
||||
const migrated = migrateBaseUrl(sanitizeCollections(data.collections), data.environments);
|
||||
return {
|
||||
version: 2,
|
||||
collections: migrated.collections,
|
||||
environments: migrated.environments,
|
||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
settings: {
|
||||
theme: data.settings?.theme === "light" ? "light" : "dark",
|
||||
timeoutMs: data.settings?.timeoutMs ?? 30000,
|
||||
mock: normalizeMockSettings(data.settings?.mock),
|
||||
ai: normalizeAiSettings(data.settings?.ai),
|
||||
},
|
||||
testCases: Array.isArray(data.testCases) ? data.testCases : [],
|
||||
testRuns: Array.isArray(data.testRuns) ? data.testRuns.slice(0, 50) : [],
|
||||
schedules: Array.isArray(data.schedules) ? data.schedules : [],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPersisted(): WorkspaceData | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as Partial<WorkspaceData>;
|
||||
if (!Array.isArray(data.collections) || !Array.isArray(data.environments)) return null;
|
||||
const migrated = migrateBaseUrl(sanitizeCollections(data.collections), data.environments);
|
||||
return {
|
||||
version: 2,
|
||||
collections: migrated.collections,
|
||||
environments: migrated.environments,
|
||||
activeEnvironmentId: data.activeEnvironmentId ?? data.environments[0]?.id ?? null,
|
||||
history: Array.isArray(data.history) ? data.history : [],
|
||||
settings: {
|
||||
theme: data.settings?.theme === "light" ? "light" : "dark",
|
||||
timeoutMs: data.settings?.timeoutMs ?? 30000,
|
||||
mock: normalizeMockSettings(data.settings?.mock),
|
||||
ai: normalizeAiSettings(data.settings?.ai),
|
||||
},
|
||||
testCases: Array.isArray(data.testCases) ? data.testCases : [],
|
||||
testRuns: Array.isArray(data.testRuns) ? data.testRuns.slice(0, 50) : [],
|
||||
schedules: Array.isArray(data.schedules) ? data.schedules : [],
|
||||
};
|
||||
return normalizeWorkspaceData(JSON.parse(raw) as Partial<WorkspaceData>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -291,28 +330,35 @@ function loadPersisted(): WorkspaceData | null {
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function snapshotForPersist(state: WorkspaceStore): PersistedShape & { version: number } {
|
||||
return {
|
||||
version: 2,
|
||||
collections: state.collections,
|
||||
environments: state.environments,
|
||||
activeEnvironmentId: state.activeEnvironmentId,
|
||||
history: state.history.slice(0, 100),
|
||||
settings: state.settings,
|
||||
testCases: state.testCases,
|
||||
testRuns: state.testRuns.slice(0, 50),
|
||||
schedules: state.schedules,
|
||||
};
|
||||
}
|
||||
|
||||
function schedulePersist(state: WorkspaceStore): void {
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(() => {
|
||||
try {
|
||||
const data: PersistedShape & { version: number } = {
|
||||
version: 2,
|
||||
collections: state.collections,
|
||||
environments: state.environments,
|
||||
activeEnvironmentId: state.activeEnvironmentId,
|
||||
history: state.history.slice(0, 100),
|
||||
settings: state.settings,
|
||||
testCases: state.testCases,
|
||||
testRuns: state.testRuns.slice(0, 50),
|
||||
schedules: state.schedules,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshotForPersist(state)));
|
||||
} catch (e) {
|
||||
console.warn("[opsxapi] 工作区保存失败", e);
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/** 云端空间同步(去抖 1.5s) */
|
||||
let syncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
|
||||
/** 当前启用环境的前置 URL */
|
||||
export function activeEnvBaseUrl(): string {
|
||||
const s = useWorkspace.getState();
|
||||
@@ -326,7 +372,29 @@ export function applyThemeToDocument(theme: ThemeMode): void {
|
||||
}
|
||||
|
||||
export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
const persist = () => schedulePersist(get());
|
||||
const persist = () => {
|
||||
const { activeSpaceId, token } = get().account;
|
||||
if (activeSpaceId !== null && token) {
|
||||
if (syncTimer) clearTimeout(syncTimer);
|
||||
syncTimer = setTimeout(() => void get().syncNow(), 1500);
|
||||
return;
|
||||
}
|
||||
schedulePersist(get());
|
||||
};
|
||||
|
||||
const applyWorkspaceData = (data: WorkspaceData) => {
|
||||
set({
|
||||
collections: data.collections,
|
||||
environments: data.environments,
|
||||
activeEnvironmentId: data.activeEnvironmentId,
|
||||
history: data.history,
|
||||
settings: data.settings,
|
||||
testCases: data.testCases,
|
||||
testRuns: data.testRuns,
|
||||
schedules: data.schedules,
|
||||
});
|
||||
applyThemeToDocument(data.settings.theme);
|
||||
};
|
||||
|
||||
return {
|
||||
collections: [],
|
||||
@@ -338,6 +406,16 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
testRuns: [],
|
||||
schedules: [],
|
||||
view: "api",
|
||||
account: {
|
||||
token: null,
|
||||
user: null,
|
||||
teams: [],
|
||||
spaces: [],
|
||||
activeSpaceId: null,
|
||||
syncing: false,
|
||||
lastSyncAt: null,
|
||||
syncError: null,
|
||||
},
|
||||
|
||||
openTabIds: [],
|
||||
activeTabId: null,
|
||||
@@ -368,6 +446,116 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
|
||||
set({ expanded });
|
||||
// 落盘:确保迁移(旧演示地址)与自愈(补 data)结果写回 localStorage
|
||||
schedulePersist(get());
|
||||
// 恢复登录态(令牌失效时静默清除)
|
||||
const token = loadToken();
|
||||
if (token) {
|
||||
set({ account: { ...get().account, token } });
|
||||
void get().refreshAccount().catch(() => {
|
||||
saveToken(null);
|
||||
set({ account: { ...get().account, token: null, user: null, teams: [], spaces: [] } });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async authLogin(email, password) {
|
||||
const r = await authLoginApi(email.trim(), password);
|
||||
saveToken(r.token);
|
||||
set({ account: { ...get().account, token: r.token, user: r.user } });
|
||||
await get().refreshAccount();
|
||||
},
|
||||
|
||||
async authRegister(name, email, password) {
|
||||
const r = await authRegisterApi(name.trim(), email.trim(), password);
|
||||
saveToken(r.token);
|
||||
set({ account: { ...get().account, token: r.token, user: r.user } });
|
||||
await get().refreshAccount();
|
||||
},
|
||||
|
||||
async authLogout() {
|
||||
const { token, activeSpaceId } = get().account;
|
||||
if (activeSpaceId !== null) await get().switchSpace(null);
|
||||
if (token) void authLogoutApi(token).catch(() => undefined);
|
||||
saveToken(null);
|
||||
set({
|
||||
account: {
|
||||
token: null,
|
||||
user: null,
|
||||
teams: [],
|
||||
spaces: [],
|
||||
activeSpaceId: null,
|
||||
syncing: false,
|
||||
lastSyncAt: null,
|
||||
syncError: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async refreshAccount() {
|
||||
const { token } = get().account;
|
||||
if (!token) return;
|
||||
const r = await fetchMe(token);
|
||||
set({
|
||||
account: {
|
||||
...get().account,
|
||||
user: r.user,
|
||||
teams: r.teams ?? [],
|
||||
spaces: r.workspaces ?? [],
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async createTeam(name) {
|
||||
const { token } = get().account;
|
||||
if (!token) throw new Error("未登录");
|
||||
await createTeamApi(token, name.trim());
|
||||
await get().refreshAccount();
|
||||
},
|
||||
|
||||
async addTeamMember(teamId, email) {
|
||||
const { token } = get().account;
|
||||
if (!token) throw new Error("未登录");
|
||||
await addTeamMemberApi(token, teamId, email.trim());
|
||||
await get().refreshAccount();
|
||||
},
|
||||
|
||||
async switchSpace(spaceId) {
|
||||
const acc = get().account;
|
||||
if (acc.activeSpaceId === spaceId) return;
|
||||
if (spaceId === null) {
|
||||
set({ account: { ...get().account, activeSpaceId: null, syncError: null } });
|
||||
const local = loadPersisted() ?? createSeedWorkspace();
|
||||
applyWorkspaceData(local);
|
||||
schedulePersist(get());
|
||||
return;
|
||||
}
|
||||
if (!acc.token) throw new Error("未登录");
|
||||
const ws = await fetchWorkspace(acc.token, spaceId);
|
||||
set({ account: { ...get().account, activeSpaceId: spaceId } });
|
||||
if (!ws.data.trim()) {
|
||||
// 云端为空:用当前本地数据播种
|
||||
await get().syncNow();
|
||||
return;
|
||||
}
|
||||
const parsed = normalizeWorkspaceData(JSON.parse(ws.data) as Partial<WorkspaceData>);
|
||||
applyWorkspaceData(parsed ?? createSeedWorkspace());
|
||||
},
|
||||
|
||||
async syncNow() {
|
||||
const { token, activeSpaceId } = get().account;
|
||||
if (!token || activeSpaceId === null) return;
|
||||
set({ account: { ...get().account, syncing: true, syncError: null } });
|
||||
try {
|
||||
const r = await saveWorkspace(token, activeSpaceId, JSON.stringify(snapshotForPersist(get())));
|
||||
set({ account: { ...get().account, syncing: false, lastSyncAt: r.updatedAt * 1000 } });
|
||||
} catch (e) {
|
||||
set({
|
||||
account: {
|
||||
...get().account,
|
||||
syncing: false,
|
||||
syncError: e instanceof Error ? e.message : String(e),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
addCollection(name) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/account.ts","./src/ai.ts","./src/index.ts","./src/scheduler.ts","./src/send.ts","./src/testrunner.ts","./src/workspacestore.ts","./src/components/envbadge.tsx","./src/components/logo.tsx","./src/components/sidebar.tsx","./src/components/tabbar.tsx","./src/components/testingview.tsx","./src/components/topbar.tsx","./src/components/urlpathinput.tsx","./src/components/varhintinput.tsx","./src/components/common.tsx","./src/components/icons.tsx","./src/components/dialogs/aicasedialog.tsx","./src/components/dialogs/exportdialog.tsx","./src/components/dialogs/importdialog.tsx","./src/components/dialogs/scheduledialog.tsx","./src/components/dialogs/workspacedialogs.tsx","./src/components/editors/graphqleditor.tsx","./src/components/editors/httpeditor.tsx","./src/components/editors/mqtteditor.tsx","./src/components/editors/responsepanel.tsx","./src/components/editors/socketioeditor.tsx","./src/components/editors/sseeditor.tsx","./src/components/editors/websocketeditor.tsx","./src/components/editors/livecommon.tsx"],"errors":true,"version":"5.9.3"}
|
||||
Reference in new issue
Block a user