账号与团队: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:
logan committed 2026-09-18 11:42:34 +08:00
1 parent 045e8c65c3
commit aabd20bd75
18 files changed
+1338 -38

No files matched your search

+114 -1
View File
@@ -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)}