团队管理:成员擅长标签

- 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
+451 -48

No files matched your search

+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,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member',
specialty TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
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 {
_, _ = 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 = ''`)
if err != nil {
return err
@@ -289,14 +292,68 @@ WHERE m.user_id = ? ORDER BY t.id`, userID)
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)
if err != nil {
return nil, err
}
_, err = s.db.Exec(
`INSERT OR IGNORE INTO team_members(team_id, user_id, role, created_at) VALUES(?,?,'member',?)`,
teamID, u.ID, time.Now().Unix(),
`INSERT OR IGNORE INTO team_members(team_id, user_id, role, specialty, created_at) VALUES(?,?,'member',?,?)`,
teamID, u.ID, specialty, time.Now().Unix(),
)
return u, err
}