- 新接口:PATCH /api/teams/:id 重命名(1-40 字符)、DELETE /api/teams/:id 删除(均仅创建者);删除随外键级联清理成员/共享空间/Mock 接口 - TeamDialog:卡片右上 ✎ 内联重命名、🗑 删除二次确认(红色警示条,明示连带删除范围) - 删除后若当前停留在该团队空间自动切回本地空间 - curl E2E:改名/越权 403/删除/空间级联失效全通过;浏览器验证改名与删除全流程;53 前端测试 + Go 构建通过
565 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|