团队管理:成员擅长标签
- team_members 新增 specialty 列(老库自动 ALTER 迁移,幂等) - 新接口:GET /api/teams/:id/members 成员列表(仅成员可看)、PUT /api/teams/:id/members/:uid 更新擅长(创建者可改他人,成员仅改自己);邀请接口支持带擅长 - TeamDialog 重构:团队卡片内成员列表(头像/名称/邮箱/角色/彩色擅长 chip,超 3 个折叠 +N)、行内擅长编辑器(10 个预设点选 + 自定义输入)、邀请时顺手点选擅长预设 - 文档 20-account 同步更新(含活动栏新导航说明) - curl E2E:邀请带擅长/创建者改他人/自改/越权与非成员 403 全部通过;53 前端测试 + Go 测试全绿
This commit is contained in:
1 parent
6d72264793
commit
c6868b0ddd
9 files changed
+451
-48
No files matched your search
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* 账号弹窗:登录 / 注册;团队管理:建团队、邀请成员。
|
||||
* 账号弹窗:登录 / 注册;团队管理:建团队、邀请成员、成员擅长标签。
|
||||
*/
|
||||
import { useState } from "react";
|
||||
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";
|
||||
@@ -103,6 +104,92 @@ export function AuthDialog() {
|
||||
|
||||
/* ---------------- 团队管理 ---------------- */
|
||||
|
||||
/** 擅长预设(点击即选/取消) */
|
||||
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);
|
||||
@@ -111,10 +198,42 @@ export function TeamDialog() {
|
||||
|
||||
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 [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);
|
||||
@@ -136,8 +255,10 @@ export function TeamDialog() {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await addTeamMember(teamId, email);
|
||||
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));
|
||||
@@ -146,14 +267,32 @@ export function TeamDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
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="560px"
|
||||
width="600px"
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span className="ops-faint text-[11px]">团队成员共享同一份云端空间,切换即协同</span>
|
||||
<span className="ops-faint text-[11px]">团队成员共享同一份云端空间;擅长标签帮助快速找到对口的人</span>
|
||||
<button className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
|
||||
完成
|
||||
</button>
|
||||
@@ -181,34 +320,131 @@ export function TeamDialog() {
|
||||
<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>
|
||||
{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">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
Reference in new issue
Block a user