Files
opsxapi/packages/ui/src/components/dialogs/AccountDialogs.tsx
T
logan 36df1cec83 团队管理:重命名与删除团队
- 新接口:PATCH /api/teams/:id 重命名(1-40 字符)、DELETE /api/teams/:id 删除(均仅创建者);删除随外键级联清理成员/共享空间/Mock 接口
- TeamDialog:卡片右上 ✎ 内联重命名、🗑 删除二次确认(红色警示条,明示连带删除范围)
- 删除后若当前停留在该团队空间自动切回本地空间
- curl E2E:改名/越权 403/删除/空间级联失效全通过;浏览器验证改名与删除全流程;53 前端测试 + Go 构建通过
2026-09-18 13:40:32 +08:00

565 lines
22 KiB
TypeScript

/**
* 账号弹窗:登录 / 注册;团队管理:建团队、邀请成员、成员擅长标签。
*/
import { fetchTeamMembers, updateMemberSpecialty, type AccountTeamMember } from "../../account";
import { useEffect, 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>
);
}
/* ---------------- 团队管理 ---------------- */
/** 擅长预设(点击即选/取消) */
const SPECIALTY_PRESETS = ["前端", "后端", "全栈", "测试", "运维", "移动端", "数据库", "AI", "接口设计", "安全"];
const SPEC_PALETTE = ["#3b82f6", "#8b5cf6", "#10b981", "#f59e0b", "#f43f5e", "#06b6d4", "#ec4899", "#84cc16"];
function specColor(label: string): string {
let h = 0;
for (let i = 0; i < label.length; i++) h = (h * 31 + label.charCodeAt(i)) >>> 0;
return SPEC_PALETTE[h % SPEC_PALETTE.length];
}
function splitSpecialty(s: string): string[] {
return s
.split(/[\s,,、/]+/)
.map((x) => x.trim())
.filter(Boolean);
}
function SpecChip({ label }: { label: string }) {
const color = specColor(label);
return (
<span
className="spec-chip"
style={{
background: `color-mix(in srgb, ${color} 14%, transparent)`,
color,
borderColor: `color-mix(in srgb, ${color} 38%, transparent)`,
}}
>
{label}
</span>
);
}
/** 擅长编辑器:预设点选 + 自定义输入 */
function SpecialtyEditor({
value,
onChange,
onSave,
onCancel,
busy,
}: {
value: string;
onChange: (v: string) => void;
onSave: () => void;
onCancel: () => void;
busy: boolean;
}) {
const picked = splitSpecialty(value);
const toggle = (p: string) => {
const next = picked.includes(p) ? picked.filter((x) => x !== p) : [...picked, p];
onChange(next.join(" "));
};
return (
<div className="space-y-1.5">
<div className="flex gap-1.5">
<input
className="ops-input ops-input-sm flex-1"
placeholder="如:前端 测试(空格分隔,可自定义)"
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onSave()}
autoFocus
/>
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={onSave}>
保存
</button>
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={onCancel}>
取消
</button>
</div>
<div className="flex flex-wrap gap-1">
{SPECIALTY_PRESETS.map((p) => (
<button
key={p}
className={cx("spec-preset", picked.includes(p) && "active")}
onClick={() => toggle(p)}
>
{p}
</button>
))}
</div>
</div>
);
}
export function TeamDialog() {
const account = useWorkspace((s) => s.account);
const createTeam = useWorkspace((s) => s.createTeam);
const renameTeam = useWorkspace((s) => s.renameTeam);
const deleteTeam = useWorkspace((s) => s.deleteTeam);
const addTeamMember = useWorkspace((s) => s.addTeamMember);
const setDialog = useWorkspace((s) => s.setDialog);
const [teamName, setTeamName] = useState("");
const [inviteEmail, setInviteEmail] = useState<Record<number, string>>({});
const [inviteSpecialty, setInviteSpecialty] = useState<Record<number, string>>({});
const [members, setMembers] = useState<Record<number, AccountTeamMember[]>>({});
const [membersLoading, setMembersLoading] = useState(false);
const [editing, setEditing] = useState<{ teamId: number; userId: number } | null>(null);
const [editValue, setEditValue] = useState("");
const [renaming, setRenaming] = useState<number | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [okMsg, setOkMsg] = useState("");
const token = account.token;
const teamCount = account.teams.length;
const loadMembers = async () => {
if (!token || useWorkspace.getState().account.teams.length === 0) return;
setMembersLoading(true);
try {
const teams = useWorkspace.getState().account.teams;
const next: Record<number, AccountTeamMember[]> = {};
await Promise.all(
teams.map(async (t) => {
next[t.id] = (await fetchTeamMembers(token, t.id)).members ?? [];
}),
);
setMembers(next);
} catch {
/* 拉取失败时仍展示团队概要 */
} finally {
setMembersLoading(false);
}
};
useEffect(() => {
void loadMembers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, teamCount]);
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 doRename = async (teamId: number) => {
if (!renameValue.trim()) return;
setBusy(true);
setError("");
try {
await renameTeam(teamId, renameValue);
setRenaming(null);
setOkMsg("团队已重命名");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const doDelete = async (teamId: number) => {
setBusy(true);
setError("");
try {
const name = account.teams.find((t) => t.id === teamId)?.name ?? "";
await deleteTeam(teamId);
setConfirmDelete(null);
setMembers((m) => {
const next = { ...m };
delete next[teamId];
return next;
});
setOkMsg(`团队「${name}」已删除(含共享空间与 Mock 数据)`);
} 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, inviteSpecialty[teamId] ?? "");
setInviteEmail((m) => ({ ...m, [teamId]: "" }));
setInviteSpecialty((m) => ({ ...m, [teamId]: "" }));
await loadMembers();
setOkMsg(`已添加成员 ${email}`);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const saveSpecialty = async (teamId: number, userId: number) => {
if (!token) return;
setBusy(true);
setError("");
try {
await updateMemberSpecialty(token, teamId, userId, editValue);
setEditing(null);
await loadMembers();
setOkMsg("擅长已更新");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const me = account.user;
return (
<Modal
title="团队管理"
onClose={() => setDialog(null)}
width="600px"
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) => {
const list = members[t.id];
return (
<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">
{renaming === t.id ? (
<div className="flex flex-1 items-center gap-2">
<input
className="ops-input ops-input-sm flex-1"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doRename(t.id)}
autoFocus
/>
<button className="ops-btn ops-btn-secondary !py-1 text-[12px]" disabled={busy} onClick={() => doRename(t.id)}>
保存
</button>
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => setRenaming(null)}>
取消
</button>
</div>
) : (
<>
<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]">
{list ? `${list.length} 名成员 · 创建者 ${t.owner}` : `创建者 ${t.owner}`}
</span>
{t.role === "owner" && (
<>
<button
className="ops-btn ops-btn-ghost !px-1.5 !py-0.5 text-[11px]"
title="重命名团队"
onClick={() => {
setRenaming(t.id);
setRenameValue(t.name);
setConfirmDelete(null);
}}
>
<Icon name="pencil" size={12} />
</button>
<button
className="ops-btn ops-btn-ghost ops-danger !px-1.5 !py-0.5 text-[11px]"
title="删除团队(含共享空间与 Mock 数据)"
onClick={() => setConfirmDelete(confirmDelete === t.id ? null : t.id)}
>
<Icon name="trash" size={12} />
</button>
</>
)}
</>
)}
</div>
{confirmDelete === t.id && (
<div
className="mt-2 rounded-lg px-2.5 py-2"
style={{
background: "color-mix(in srgb, var(--danger) 8%, transparent)",
border: "1px solid color-mix(in srgb, var(--danger) 32%, transparent)",
}}
>
<div className="ops-danger text-[12px]">
将永久删除团队「{t.name}」及其共享空间、成员关系、已发布 Mock 接口,不可恢复。确定删除?
</div>
<div className="mt-1.5 flex justify-end gap-2">
<button className="ops-btn ops-btn-ghost !py-1 text-[12px]" onClick={() => setConfirmDelete(null)}>
取消
</button>
<button
className="ops-btn ops-btn-danger !py-1 text-[12px]"
disabled={busy}
onClick={() => void doDelete(t.id)}
>
确认删除
</button>
</div>
</div>
)}
{/* 成员与擅长 */}
<div className="mt-1.5 space-y-1">
{list === undefined &&
(membersLoading ? (
<div className="ops-faint px-1 py-1 text-[11px]">成员加载中…</div>
) : null)}
{list?.map((m) => {
const canEdit = t.role === "owner" || m.userId === me?.id;
const isEditing = editing?.teamId === t.id && editing.userId === m.userId;
return (
<div key={m.userId} className="member-row">
{isEditing ? (
<div className="flex-1">
<SpecialtyEditor
value={editValue}
onChange={setEditValue}
onSave={() => void saveSpecialty(t.id, m.userId)}
onCancel={() => setEditing(null)}
busy={busy}
/>
</div>
) : (
<>
<span className="ops-user-avatar" style={{ width: 20, height: 20, fontSize: 10 }}>
{m.name.slice(0, 1).toUpperCase()}
</span>
<span className="min-w-0">
<span className="block truncate text-[12px] font-medium">
{m.name}
{m.userId === me?.id && <span className="ops-faint ml-1 text-[10px]">(我)</span>}
</span>
<span className="ops-faint block truncate text-[10px]">{m.email}</span>
</span>
<span className="ops-chip shrink-0" style={{ padding: "1px 7px", fontSize: 10 }}>
{m.role === "owner" ? "创建者" : "成员"}
</span>
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 pl-1">
{splitSpecialty(m.specialty).slice(0, 3).map((s) => (
<SpecChip key={s} label={s} />
))}
{splitSpecialty(m.specialty).length > 3 && (
<span className="ops-faint text-[10px]" title={m.specialty}>
+{splitSpecialty(m.specialty).length - 3}
</span>
)}
{splitSpecialty(m.specialty).length === 0 && (
<span className="ops-faint text-[10px]">未设置擅长</span>
)}
</span>
{canEdit && (
<button
className="ops-btn ops-btn-ghost shrink-0 !px-1.5 !py-0.5 text-[11px]"
title={t.role === "owner" ? "设置擅长" : "设置我的擅长"}
onClick={() => {
setEditing({ teamId: t.id, userId: m.userId });
setEditValue(m.specialty);
}}
>
<Icon name="pencil" size={12} /> 擅长
</button>
)}
</>
)}
</div>
);
})}
</div>
{t.role === "owner" && (
<div className="mt-2 space-y-1.5">
<div className="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>
{(inviteEmail[t.id] ?? "").trim() !== "" && (
<div className="flex flex-wrap items-center gap-1">
<span className="ops-faint text-[10px]">擅长:</span>
{SPECIALTY_PRESETS.map((p) => {
const picked = splitSpecialty(inviteSpecialty[t.id] ?? "").includes(p);
return (
<button
key={p}
className={cx("spec-preset", picked && "active")}
onClick={() => {
const cur = splitSpecialty(inviteSpecialty[t.id] ?? "");
const next = picked ? cur.filter((x) => x !== p) : [...cur, p];
setInviteSpecialty((m) => ({ ...m, [t.id]: next.join(" ") }));
}}
>
{p}
</button>
);
})}
</div>
)}
</div>
)}
</div>
);
})}
</div>
)}
{error && <div className="ops-danger text-[12px]">{error}</div>}
{okMsg && <div className="ops-success text-[12px]">{okMsg}</div>}
</div>
</Modal>
);
}