账号与团队: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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user