团队管理:成员擅长标签

- 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:
logan committed 2026-09-18 13:36:28 +08:00
1 parent 6d72264793
commit c6868b0ddd
9 files changed
+427 -24

No files matched your search

Binary file not shown.
Binary file not shown.
+69 -1
View File
@@ -36,6 +36,8 @@ func (a *API) Register(r *gin.Engine) {
g.GET("/me", a.auth(a.handleMe)) g.GET("/me", a.auth(a.handleMe))
g.POST("/teams", a.auth(a.handleCreateTeam)) g.POST("/teams", a.auth(a.handleCreateTeam))
g.POST("/teams/:id/members", a.auth(a.handleAddMember)) g.POST("/teams/:id/members", a.auth(a.handleAddMember))
g.GET("/teams/:id/members", a.auth(a.handleListMembers))
g.PUT("/teams/:id/members/:uid", a.auth(a.handleUpdateSpecialty))
g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace)) g.GET("/workspaces/:id", a.auth(a.handleGetWorkspace))
g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace)) g.PUT("/workspaces/:id", a.auth(a.handlePutWorkspace))
g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock)) g.PUT("/workspaces/:id/mock", a.auth(a.handlePublishMock))
@@ -176,12 +178,13 @@ func (a *API) handleAddMember(c *gin.Context, user *store.User) {
} }
var req struct { var req struct {
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
Specialty string `json:"specialty"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请填写成员邮箱"}) c.JSON(http.StatusBadRequest, gin.H{"error": "请填写成员邮箱"})
return return
} }
member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email)) member, err := a.store.AddTeamMember(teamID, strings.ToLower(req.Email), trimSpecialty(req.Specialty))
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"}) c.JSON(http.StatusNotFound, gin.H{"error": "该邮箱尚未注册 OpsxAPI 账号"})
return return
@@ -189,6 +192,71 @@ func (a *API) handleAddMember(c *gin.Context, user *store.User) {
c.JSON(http.StatusOK, gin.H{"ok": true, "member": member}) c.JSON(http.StatusOK, gin.H{"ok": true, "member": member})
} }
// handleListMembers 团队成员列表(创建者与成员均可查看)
func (a *API) handleListMembers(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
member, err := a.store.IsTeamMember(teamID, user.ID)
if err != nil || !member {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队成员可查看成员列表"})
return
}
members, err := a.store.TeamMembers(teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"members": members})
}
// handleUpdateSpecialty 更新成员擅长:创建者可改任何人,成员只能改自己
func (a *API) handleUpdateSpecialty(c *gin.Context, user *store.User) {
teamID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的团队"})
return
}
uid, err := strconv.ParseInt(c.Param("uid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的成员"})
return
}
owner, err := a.store.IsTeamOwner(teamID, user.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if !owner && uid != user.ID {
c.JSON(http.StatusForbidden, gin.H{"error": "仅团队创建者可修改他人擅长,成员可修改自己的"})
return
}
var req struct {
Specialty string `json:"specialty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := a.store.UpdateMemberSpecialty(teamID, uid, trimSpecialty(req.Specialty)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "该成员不在团队中"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// trimSpecialty 擅长标签去空白、压长度
func trimSpecialty(s string) string {
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
r := []rune(s)
if len(r) > 60 {
return string(r[:60])
}
return s
}
/* ---------------- Mock 发布 ---------------- */ /* ---------------- Mock 发布 ---------------- */
func (a *API) handlePublishMock(c *gin.Context, user *store.User) { func (a *API) handlePublishMock(c *gin.Context, user *store.User) {
@@ -4,14 +4,14 @@
## 注册 / 登录 ## 注册 / 登录
顶栏右上 **登录** 按钮打开弹窗: 最左**活动栏**底部 **登录** 按钮打开弹窗:
- 注册只需 昵称 + 邮箱 + 密码(≥ 6 位); - 注册只需 昵称 + 邮箱 + 密码(≥ 6 位);
- 登录态保存在本机,令牌失效会自动清除。 - 登录态保存在本机,令牌失效会自动清除。
## 空间切换 ## 空间切换
登录后顶栏出现 **用户胶囊**(头像首字母 + 当前空间名 + 同步状态),点击弹出空间列表: 活动栏**顶部空间徽标**(未登录时为产品 Logo,云端空间显示彩色徽标)点击弹出空间列表:
| 空间 | 说明 | | 空间 | 说明 |
| --- | --- | | --- | --- |
@@ -20,20 +20,21 @@
| 团队 · ×× | 团队共享空间,成员读写同一份数据 | | 团队 · ×× | 团队共享空间,成员读写同一份数据 |
- 首次切入空的云端空间时,会自动把**当前本地数据播种上去**; - 首次切入空的云端空间时,会自动把**当前本地数据播种上去**;
- 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),胶囊显示「已同步 / 同步中 / 同步失败」; - 在云端空间内的所有改动(接口、环境、测试用例、设置)**自动同步**(改动后约 1.5 秒),活动栏底部头像上的状态点显示 已同步 / 同步中(闪烁)/ 同步失败(红);
- 切回本地空间时恢复离开前的本地快照,两端互不覆盖; - 切回本地空间时恢复离开前的本地快照,两端互不覆盖;
- 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。 - 同步策略为**后写覆盖**:多人同时编辑以最后一次保存为准。
## 团队管理 ## 团队管理
用户胶囊旁 **⋯** 菜单 → 团队管理: 活动栏账号菜单或空间菜单 → **团队管理**:
- **新建团队**:创建后自动获得同名共享空间; - **新建团队**:创建后自动获得同名共享空间;
- **添加成员**:输入已注册成员邮箱(创建者可操作),成员登录后即可看到并切换到该团队空间。 - **添加成员**:输入已注册成员邮箱(创建者可操作),邀请时可顺手点选**擅长**预设;成员登录后即可看到并切换到该团队空间;
- **成员与擅长**:每个团队卡片展示成员列表(头像 / 名称 / 角色 / 擅长标签)。擅长支持预设点选(前端、后端、全栈、测试、运维、移动端、数据库、AI、接口设计、安全)与自定义输入,空格或顿号分隔;创建者可修改任何成员的擅长,成员只能修改自己的(行尾「擅长」按钮)。
## 数据与安全 ## 数据与安全
- 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废; - 密码加盐哈希后存储;令牌为随机 Bearer Token,可随时退出登录作废;
- 云端空间单份数据上限 20MB; - 云端空间单份数据上限 20MB;
- 团队空间权限:全体成员可读写,仅创建者可管理成员; - 团队空间权限:全体成员可读写,仅创建者可管理成员与修改他人擅长;
- 不登录不影响任何本地功能。 - 不登录不影响任何本地功能。
+60 -3
View File
@@ -71,6 +71,7 @@ CREATE TABLE IF NOT EXISTS team_members (
team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member', role TEXT NOT NULL DEFAULT 'member',
specialty TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
PRIMARY KEY (team_id, user_id) PRIMARY KEY (team_id, user_id)
); );
@@ -107,6 +108,8 @@ CREATE INDEX IF NOT EXISTS idx_workspaces_owner ON workspaces(owner_id);
if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil { if _, err := s.db.Exec(`ALTER TABLE workspaces ADD COLUMN mock_token TEXT`); err == nil {
_, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`) _, _ = s.db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_mock_token ON workspaces(mock_token)`)
} }
// 老库补擅长列(幂等)
_, _ = s.db.Exec(`ALTER TABLE team_members ADD COLUMN specialty TEXT NOT NULL DEFAULT ''`)
rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`) rows, err := s.db.Query(`SELECT id FROM workspaces WHERE mock_token IS NULL OR mock_token = ''`)
if err != nil { if err != nil {
return err return err
@@ -289,14 +292,68 @@ WHERE m.user_id = ? ORDER BY t.id`, userID)
return out, rows.Err() return out, rows.Err()
} }
func (s *Store) AddTeamMember(teamID int64, email string) (*User, error) { // TeamMember 团队成员(含擅长标签,多个以空白或顿号分隔)
type TeamMember struct {
UserID int64 `json:"userId"`
Name string `json:"name"`
Email string `json:"email"`
Role string `json:"role"`
Specialty string `json:"specialty"`
}
// TeamMembers 团队成员列表:创建者在最前,其余按加入时间
func (s *Store) TeamMembers(teamID int64) ([]TeamMember, error) {
rows, err := s.db.Query(`
SELECT m.user_id, IFNULL(u.name, ''), IFNULL(u.email, ''), m.role, m.specialty
FROM team_members m LEFT JOIN users u ON u.id = m.user_id
WHERE m.team_id = ?
ORDER BY CASE m.role WHEN 'owner' THEN 0 ELSE 1 END, m.created_at`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TeamMember
for rows.Next() {
var m TeamMember
if err := rows.Scan(&m.UserID, &m.Name, &m.Email, &m.Role, &m.Specialty); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// IsTeamMember 是否为团队成员(创建者亦为成员)
func (s *Store) IsTeamMember(teamID, userID int64) (bool, error) {
var one int
err := s.db.QueryRow(`SELECT 1 FROM team_members WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
// UpdateMemberSpecialty 更新成员擅长(须先确认调用者是创建者或本人)
func (s *Store) UpdateMemberSpecialty(teamID, userID int64, specialty string) error {
res, err := s.db.Exec(`UPDATE team_members SET specialty = ? WHERE team_id = ? AND user_id = ?`, specialty, teamID, userID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) AddTeamMember(teamID int64, email string, specialty string) (*User, error) {
u, err := s.UserByEmail(email) u, err := s.UserByEmail(email)
if err != nil { if err != nil {
return nil, err return nil, err
} }
_, err = s.db.Exec( _, err = s.db.Exec(
`INSERT OR IGNORE INTO team_members(team_id, user_id, role, created_at) VALUES(?,?,'member',?)`, `INSERT OR IGNORE INTO team_members(team_id, user_id, role, specialty, created_at) VALUES(?,?,'member',?,?)`,
teamID, u.ID, time.Now().Unix(), teamID, u.ID, specialty, time.Now().Unix(),
) )
return u, err return u, err
} }
+21
View File
@@ -596,6 +596,27 @@
background: var(--border); background: var(--border);
} }
/* ---------- 团队成员行与擅长标签 ---------- */
.member-row {
@apply flex items-center gap-2.5 rounded-lg px-1.5 py-2;
}
.member-row:hover { background: var(--panel-2); }
.spec-chip {
@apply inline-flex shrink-0 items-center rounded-full border px-2 py-px text-[10px] font-medium;
}
.spec-preset {
@apply rounded-full border px-2 py-0.5 text-[11px] transition;
color: var(--text-dim);
border-color: var(--border);
background: transparent;
}
.spec-preset:hover { color: var(--text); border-color: var(--accent); }
.spec-preset.active {
color: var(--accent-strong);
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
/* ---------- 方法 chip 着色 ---------- */ /* ---------- 方法 chip 着色 ---------- */
.ops-chip.ops-method-GET { background: color-mix(in srgb, var(--m-GET) 14%, transparent); color: var(--m-GET); } .ops-chip.ops-method-GET { background: color-mix(in srgb, var(--m-GET) 14%, transparent); color: var(--m-GET); }
.ops-chip.ops-method-POST { background: color-mix(in srgb, var(--m-POST) 16%, transparent); color: var(--m-POST); } .ops-chip.ops-method-POST { background: color-mix(in srgb, var(--m-POST) 16%, transparent); color: var(--m-POST); }
+22 -2
View File
@@ -60,10 +60,30 @@ export function createTeam(token: string, name: string) {
return call<AccountTeam>("/teams", token, { method: "POST", body: JSON.stringify({ name }) }); return call<AccountTeam>("/teams", token, { method: "POST", body: JSON.stringify({ name }) });
} }
export function addTeamMember(token: string, teamId: number, email: string) { export function addTeamMember(token: string, teamId: number, email: string, specialty?: string) {
return call<{ ok: boolean; member: AccountUser }>(`/teams/${teamId}/members`, token, { return call<{ ok: boolean; member: AccountUser }>(`/teams/${teamId}/members`, token, {
method: "POST", method: "POST",
body: JSON.stringify({ email }), body: JSON.stringify({ email, specialty: specialty ?? "" }),
});
}
/** 团队成员(specialty:擅长标签,空格或顿号分隔) */
export type AccountTeamMember = {
userId: number;
name: string;
email: string;
role: string;
specialty: string;
};
export function fetchTeamMembers(token: string, teamId: number) {
return call<{ members: AccountTeamMember[] }>(`/teams/${teamId}/members`, token);
}
export function updateMemberSpecialty(token: string, teamId: number, userId: number, specialty: string) {
return call<{ ok: boolean }>(`/teams/${teamId}/members/${userId}`, token, {
method: "PUT",
body: JSON.stringify({ specialty }),
}); });
} }
@@ -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 { useWorkspace } from "../../workspaceStore";
import { Modal, cx } from "../common"; import { Modal, cx } from "../common";
import { Icon } from "../icons"; 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() { export function TeamDialog() {
const account = useWorkspace((s) => s.account); const account = useWorkspace((s) => s.account);
const createTeam = useWorkspace((s) => s.createTeam); const createTeam = useWorkspace((s) => s.createTeam);
@@ -111,10 +198,42 @@ export function TeamDialog() {
const [teamName, setTeamName] = useState(""); const [teamName, setTeamName] = useState("");
const [inviteEmail, setInviteEmail] = useState<Record<number, string>>({}); 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 [busy, setBusy] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [okMsg, setOkMsg] = 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 () => { const doCreate = async () => {
if (!teamName.trim()) return; if (!teamName.trim()) return;
setBusy(true); setBusy(true);
@@ -136,8 +255,10 @@ export function TeamDialog() {
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
await addTeamMember(teamId, email); await addTeamMember(teamId, email, inviteSpecialty[teamId] ?? "");
setInviteEmail((m) => ({ ...m, [teamId]: "" })); setInviteEmail((m) => ({ ...m, [teamId]: "" }));
setInviteSpecialty((m) => ({ ...m, [teamId]: "" }));
await loadMembers();
setOkMsg(`已添加成员 ${email}`); setOkMsg(`已添加成员 ${email}`);
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : String(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 ( return (
<Modal <Modal
title="团队管理" title="团队管理"
onClose={() => setDialog(null)} onClose={() => setDialog(null)}
width="560px" width="600px"
footer={ footer={
<div className="flex w-full items-center justify-between"> <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 className="ops-btn ops-btn-primary" onClick={() => setDialog(null)}>
完成 完成
</button> </button>
@@ -181,7 +320,9 @@ export function TeamDialog() {
<div className="ops-faint py-4 text-center text-xs">还没有团队,创建一个即可获得团队共享空间</div> <div className="ops-faint py-4 text-center text-xs">还没有团队,创建一个即可获得团队共享空间</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{account.teams.map((t) => ( {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 key={t.id} className="rounded-xl px-3 py-2.5" style={{ border: "1px solid var(--border)" }}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}> <span className="grid h-6 w-6 place-items-center rounded-md" style={{ background: "var(--panel-2)" }}>
@@ -191,10 +332,82 @@ export function TeamDialog() {
<span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}> <span className={cx("ops-chip")} style={{ padding: "1px 8px", fontSize: 10 }}>
{t.role === "owner" ? "创建者" : "成员"} {t.role === "owner" ? "创建者" : "成员"}
</span> </span>
<span className="ops-faint ml-auto text-[11px]">创建者 {t.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" && ( {t.role === "owner" && (
<div className="mt-2 flex gap-2"> <div className="mt-2 space-y-1.5">
<div className="flex gap-2">
<input <input
className="ops-input ops-input-sm flex-1" className="ops-input ops-input-sm flex-1"
placeholder="成员邮箱(需已注册)" placeholder="成员邮箱(需已注册)"
@@ -206,9 +419,32 @@ export function TeamDialog() {
添加成员 添加成员
</button> </button>
</div> </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>
);
})}
</div> </div>
)} )}
+3 -3
View File
@@ -150,7 +150,7 @@ type WorkspaceStore = PersistedShape & {
authLogout(): Promise<void>; authLogout(): Promise<void>;
refreshAccount(): Promise<void>; refreshAccount(): Promise<void>;
createTeam(name: string): Promise<void>; createTeam(name: string): Promise<void>;
addTeamMember(teamId: number, email: string): Promise<void>; addTeamMember(teamId: number, email: string, specialty?: string): Promise<void>;
/** 切换空间:null=本地;服务端空间为空时用当前数据播种 */ /** 切换空间:null=本地;服务端空间为空时用当前数据播种 */
switchSpace(spaceId: number | null): Promise<void>; switchSpace(spaceId: number | null): Promise<void>;
syncNow(): Promise<void>; syncNow(): Promise<void>;
@@ -516,10 +516,10 @@ export const useWorkspace = create<WorkspaceStore>((set, get) => {
await get().refreshAccount(); await get().refreshAccount();
}, },
async addTeamMember(teamId, email) { async addTeamMember(teamId, email, specialty) {
const { token } = get().account; const { token } = get().account;
if (!token) throw new Error("未登录"); if (!token) throw new Error("未登录");
await addTeamMemberApi(token, teamId, email.trim()); await addTeamMemberApi(token, teamId, email.trim(), specialty?.trim() || undefined);
await get().refreshAccount(); await get().refreshAccount();
}, },