feat(auth): show real names in duty pickers and enable profile edits

Add a login-visible user directory API and admin/self profile update
paths so project duty selectors stop falling back to "用户 N", and so
users can persist display fields with an admin-edit tip.
This commit is contained in:
WeiCHEN
2026-09-23 09:39:41 +08:00
parent 2ce8688db4
commit 6d3efdf4d7
18 changed files with 1012 additions and 44 deletions
@@ -0,0 +1,61 @@
---
title: "修复负责人选择器显示「用户 N」+ 用户/管理员资料编辑"
task_id: "2026-09-23-user-profile-display"
status: in_progress
created: 2026-09-23
updated: 2026-09-23
owner: Cursor
task_type: implementation
task_tier: small
policy_revision: "agent-governance-2026-09-21-r1 @ 2ce8688db4f49fdd116799b9d36ef7ea802afc33"
branch: feature/2026-09-23-user-profile-display
worktree: .worktrees/2026-09-23-user-profile-display
base_dev_sha: "2ce8688db4f49fdd116799b9d36ef7ea802afc33"
write_scope: ["internal/", "web/src/", "deploy/postgres/goose/", ".doc/"]
allowed_environments: ["dev"]
head_sha: ""
mutex: "auth-profile+web-user-directory"
review_state: not_required
integration_state: pending
deployment: pending
cleanup: pending
cleanup_owner: 独立维护者
cleanup_trigger: tip 为 local_dev 祖先且无进程占用后 remove
remote_sync: not_requested
---
# 修复负责人选择器显示「用户 N」+ 用户/管理员资料编辑
## 用户目标与授权
- 用户目标:test 系统设置编制/审核/批准负责人时显示真实姓名;用户可改自己的展示信息;用户管理员可改所有人展示信息,被改方设置页有提示。
- 授权来源:用户确认计划(彻底修复 + 管理员可编辑姓名/部门/邮箱/工号)。
- 交付终点:实现合入 local `dev`、dev 环境验收;test 晋升需另授。
- 禁止:默认不 push;不操作 test/main 除非另授。
## 范围与非目标
- 写入:`internal/auth`、`internal/server`、`web/src`、`deploy/postgres/goose/00064_*`、本任务文档。
- 非目标:职位/角色改派(已有端点)、头像上传、跨环境晋升。
## 实现摘要
1. 迁移 `00064`:`users.profile_updated_by` / `profile_updated_at`。
2. `GET /api/v1/users/directory`:已登录用户可读 active 轻量目录。
3. `PATCH /api/v1/me/profile`:支持 department;自助保存清零代改标记。
4. `PATCH /api/v1/admin/users/{id}/profile`:管理员改 name/department/email/employee_id。
5. 前端登录后同步目录;负责人选择器用 `allUsers` → `userName` → 兜底;个人设置真实 PATCH;管理台「编辑资料」;设置页管理员代改提示条。
## 验证
- `./scripts/arch-check.sh --mode block` PASS
- `go vet ./internal/auth/ ./internal/server/` PASS
- `go test -short ./internal/auth/ ./internal/server/` PASS(含 directory / me profile / admin profile 用例)
- `web/` `npm test` PASS(490 + vitest 33)
- `npm run build`:既有 `App.tsx` InflightCancelableMessage TS 错误(与本任务无关,HEAD 已存在)
- 全仓 `go test -short ./...`:`internal/kb` 共享库密码环境失败(既有运维债,与本任务无关)
## 状态
- `integration_state`: pending(待候选合入)
- `deployment`: pending(合入后 rebuild dev 再浏览器验收)
@@ -0,0 +1,10 @@
-- +goose Up
-- Track when an admin last edited another user's display profile (name/dept/email/employee_id).
-- Self-edits clear both columns so the user tip disappears after they save their own profile.
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_updated_by BIGINT REFERENCES users(id);
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_updated_at TIMESTAMPTZ;
-- +goose Down
ALTER TABLE users DROP COLUMN IF EXISTS profile_updated_by;
ALTER TABLE users DROP COLUMN IF EXISTS profile_updated_at;
+24 -2
View File
@@ -25,8 +25,30 @@ type UserAccount struct {
Locale string `json:"locale,omitempty"`
Timezone string `json:"timezone,omitempty"`
Avatar string `json:"avatar,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// ProfileUpdatedBy 非零时表示最近一次展示资料由管理员代改(自助保存后清零)。
ProfileUpdatedBy int64 `json:"profile_updated_by,omitempty"`
ProfileUpdatedAt string `json:"profile_updated_at,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// UserDirectoryEntry 已登录用户可见的轻量用户目录项(不含敏感字段)。
type UserDirectoryEntry struct {
ID int64 `json:"id"`
Username string `json:"username"`
Name string `json:"name"`
Department string `json:"department,omitempty"`
Position string `json:"position,omitempty"`
}
// ProfilePatch 自助或管理员更新展示资料的可选字段。
type ProfilePatch struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Department *string `json:"department,omitempty"`
EmployeeID *string `json:"employee_id,omitempty"`
Locale *string `json:"locale,omitempty"`
Timezone *string `json:"timezone,omitempty"`
}
// SystemAuthConfig 系统全局认证与准入配置
+43 -2
View File
@@ -46,6 +46,8 @@ func (s *PgStore) scanUser(row pgx.Row) (*UserAccount, error) {
var u UserAccount
var passwordHash string
var createdAt, updatedAt time.Time
var profileUpdatedBy *int64
var profileUpdatedAt *time.Time
err := row.Scan(
&u.ID,
&u.Username,
@@ -58,6 +60,8 @@ func (s *PgStore) scanUser(row pgx.Row) (*UserAccount, error) {
&u.Department,
&u.EmployeeID,
&u.Email,
&profileUpdatedBy,
&profileUpdatedAt,
&createdAt,
&updatedAt,
)
@@ -65,6 +69,12 @@ func (s *PgStore) scanUser(row pgx.Row) (*UserAccount, error) {
return nil, err
}
u.PasswordHash = passwordHash
if profileUpdatedBy != nil {
u.ProfileUpdatedBy = *profileUpdatedBy
}
if profileUpdatedAt != nil {
u.ProfileUpdatedAt = profileUpdatedAt.Format("2006-01-02 15:04:05")
}
u.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
u.UpdatedAt = updatedAt.Format("2006-01-02 15:04:05")
return &u, nil
@@ -82,6 +92,8 @@ const userSelectSQL = `
u.department,
u.employee_id,
u.email,
u.profile_updated_by,
u.profile_updated_at,
u.created_at,
u.updated_at
FROM users u
@@ -175,22 +187,51 @@ func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccou
return s.GetUserByID(ctx, id)
}
func (s *PgStore) UpdateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) {
func (s *PgStore) UpdateUser(ctx context.Context, user *UserAccount, actorID int64) (*UserAccount, error) {
_, err := s.pool.Exec(ctx, `
UPDATE users SET
name = $2,
department = $3,
employee_id = $4,
email = $5,
profile_updated_by = CASE WHEN $6::bigint = $1 THEN NULL ELSE NULLIF($6, 0) END,
profile_updated_at = CASE WHEN $6::bigint = $1 OR $6 = 0 THEN NULL ELSE now() END,
updated_at = now()
WHERE id = $1
`, user.ID, user.Name, user.Department, user.EmployeeID, user.Email)
`, user.ID, user.Name, user.Department, user.EmployeeID, user.Email, actorID)
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, user.ID)
}
func (s *PgStore) ListActiveUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT u.id,
COALESCE(NULLIF(u.username, ''), u.account_id) AS username,
u.name,
u.department,
u.position
FROM users u
WHERE u.status = 'active'
ORDER BY u.name ASC, u.id ASC
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]UserDirectoryEntry, 0)
for rows.Next() {
var e UserDirectoryEntry
if err := rows.Scan(&e.ID, &e.Username, &e.Name, &e.Department, &e.Position); err != nil {
return nil, err
}
out = append(out, e)
}
return out, rows.Err()
}
func (s *PgStore) UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
+67 -4
View File
@@ -320,12 +320,19 @@ func (s *Service) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error
return s.store.UpdateConfig(ctx, cfg)
}
// UpdateUserProfile 更新当前用户资料字段。
func (s *Service) UpdateUserProfile(ctx context.Context, user *UserAccount) (*UserAccount, error) {
if user == nil || user.ID <= 0 {
// UpdateUserProfile 当前用户自助更新展示资料;清除管理员代改标记。
func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, patch ProfilePatch) (*UserAccount, error) {
if userID <= 0 {
return nil, errors.New("invalid user")
}
updated, err := s.store.UpdateUser(ctx, user)
existing, err := s.store.GetUserByID(ctx, userID)
if err != nil {
return nil, err
}
if err := applyProfilePatch(existing, patch, false); err != nil {
return nil, err
}
updated, err := s.store.UpdateUser(ctx, existing, userID)
if err != nil {
return nil, err
}
@@ -333,6 +340,62 @@ func (s *Service) UpdateUserProfile(ctx context.Context, user *UserAccount) (*Us
return updated, nil
}
// AdminUpdateUserProfile 管理员更新他人展示资料;记录代改人。
func (s *Service) AdminUpdateUserProfile(ctx context.Context, targetID int64, patch ProfilePatch, actorID int64) (*UserAccount, error) {
if targetID <= 0 || actorID <= 0 {
return nil, errors.New("invalid user")
}
if patch.Name == nil && patch.Department == nil && patch.Email == nil && patch.EmployeeID == nil {
return nil, errors.New("至少提供一个可更新字段")
}
existing, err := s.store.GetUserByID(ctx, targetID)
if err != nil {
return nil, err
}
if err := applyProfilePatch(existing, patch, true); err != nil {
return nil, err
}
updated, err := s.store.UpdateUser(ctx, existing, actorID)
if err != nil {
return nil, err
}
updated.PasswordHash = ""
return updated, nil
}
func applyProfilePatch(user *UserAccount, patch ProfilePatch, adminMode bool) error {
if patch.Name != nil {
name := strings.TrimSpace(*patch.Name)
if name == "" {
return errors.New("姓名不能为空")
}
user.Name = name
}
if patch.Email != nil {
user.Email = strings.TrimSpace(*patch.Email)
}
if patch.Department != nil {
user.Department = strings.TrimSpace(*patch.Department)
}
if adminMode && patch.EmployeeID != nil {
user.EmployeeID = strings.TrimSpace(*patch.EmployeeID)
}
if !adminMode {
if patch.Locale != nil {
user.Locale = strings.TrimSpace(*patch.Locale)
}
if patch.Timezone != nil {
user.Timezone = strings.TrimSpace(*patch.Timezone)
}
}
return nil
}
// ListUserDirectory 返回 active 用户轻量目录。
func (s *Service) ListUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error) {
return s.store.ListActiveUserDirectory(ctx)
}
// GetUserPreferences 读取用户偏好(内存/后续 PG user_preferences)。
func (s *Service) GetUserPreferences(ctx context.Context, userID int64) (map[string]any, error) {
s.prefMu.RLock()
+43 -4
View File
@@ -15,11 +15,12 @@ type AuthStore interface {
GetUserByUsername(ctx context.Context, username string) (*UserAccount, error)
GetUserByID(ctx context.Context, id int64) (*UserAccount, error)
CreateUser(ctx context.Context, user *UserAccount) (*UserAccount, error)
UpdateUser(ctx context.Context, user *UserAccount) (*UserAccount, error)
UpdateUser(ctx context.Context, user *UserAccount, actorID int64) (*UserAccount, error)
UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error)
UpdateUserStatus(ctx context.Context, id int64, status AccountStatus) (*UserAccount, error)
UpdateUserPassword(ctx context.Context, id int64, passwordHash string, mustChangePassword bool) (*UserAccount, error)
ListUsers(ctx context.Context) ([]*UserAccount, error)
ListActiveUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error)
GetConfig(ctx context.Context) (SystemAuthConfig, error)
UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error
}
@@ -175,8 +176,8 @@ func (s *MemoryStore) CreateUser(ctx context.Context, user *UserAccount) (*UserA
return &result, nil
}
// UpdateUser 更新用户基础资料
func (s *MemoryStore) UpdateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) {
// UpdateUser 更新用户基础资料;actorID==user.ID 时清除管理员代改标记。
func (s *MemoryStore) UpdateUser(ctx context.Context, user *UserAccount, actorID int64) (*UserAccount, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -190,7 +191,17 @@ func (s *MemoryStore) UpdateUser(ctx context.Context, user *UserAccount) (*UserA
existing.EmployeeID = user.EmployeeID
existing.Email = user.Email
existing.Avatar = user.Avatar
existing.UpdatedAt = time.Now().Format("2006-01-02 15:04:05")
existing.Locale = user.Locale
existing.Timezone = user.Timezone
now := time.Now().Format("2006-01-02 15:04:05")
existing.UpdatedAt = now
if actorID > 0 && actorID != user.ID {
existing.ProfileUpdatedBy = actorID
existing.ProfileUpdatedAt = now
} else {
existing.ProfileUpdatedBy = 0
existing.ProfileUpdatedAt = ""
}
copy := *existing
return &copy, nil
@@ -249,6 +260,34 @@ func (s *MemoryStore) UpdateUserPassword(ctx context.Context, id int64, password
return &copy, nil
}
// ListActiveUserDirectory 返回 active 用户的轻量目录。
func (s *MemoryStore) ListActiveUserDirectory(ctx context.Context) ([]UserDirectoryEntry, error) {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]UserDirectoryEntry, 0, len(s.users))
for _, u := range s.users {
if u.Status != StatusActive {
continue
}
out = append(out, UserDirectoryEntry{
ID: u.ID,
Username: u.Username,
Name: u.Name,
Department: u.Department,
Position: u.Position,
})
}
// 稳定排序:按姓名再按 id
for i := 0; i < len(out); i++ {
for j := i + 1; j < len(out); j++ {
if out[j].Name < out[i].Name || (out[j].Name == out[i].Name && out[j].ID < out[i].ID) {
out[i], out[j] = out[j], out[i]
}
}
}
return out, nil
}
// ListUsers 列出所有用户
func (s *MemoryStore) ListUsers(ctx context.Context) ([]*UserAccount, error) {
s.mu.RLock()
+29 -20
View File
@@ -3,13 +3,17 @@ package server
import (
"encoding/json"
"net/http"
"strings"
"chat-agent-system/internal/auth"
)
type userProfilePatch struct {
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Locale *string `json:"locale,omitempty"`
Timezone *string `json:"timezone,omitempty"`
Name *string `json:"name,omitempty"`
Email *string `json:"email,omitempty"`
Department *string `json:"department,omitempty"`
Locale *string `json:"locale,omitempty"`
Timezone *string `json:"timezone,omitempty"`
}
// handleMeProfile GET/PATCH /api/v1/me/profile
@@ -20,31 +24,36 @@ func (s *Server) handleMeProfile(w http.ResponseWriter, r *http.Request) {
}
switch r.Method {
case http.MethodGet:
writeJSONResponse(w, http.StatusOK, map[string]any{
resp := map[string]any{
"id": user.ID, "username": user.Username, "name": user.Name,
"email": user.Email, "locale": user.Locale, "timezone": user.Timezone,
"department": user.Department, "employee_id": user.EmployeeID, "position": user.Position,
})
}
if user.ProfileUpdatedBy > 0 {
resp["profile_updated_by"] = user.ProfileUpdatedBy
resp["profile_updated_at"] = user.ProfileUpdatedAt
if admin, err := s.authSvc.GetUserByID(r.Context(), user.ProfileUpdatedBy); err == nil && admin != nil {
name := strings.TrimSpace(admin.Name)
if name == "" {
name = admin.Username
}
resp["profile_updated_by_name"] = name
}
}
writeJSONResponse(w, http.StatusOK, resp)
case http.MethodPatch:
var patch userProfilePatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "invalid body")
return
}
updated := *user
if patch.Name != nil {
updated.Name = *patch.Name
}
if patch.Email != nil {
updated.Email = *patch.Email
}
if patch.Locale != nil {
updated.Locale = *patch.Locale
}
if patch.Timezone != nil {
updated.Timezone = *patch.Timezone
}
saved, err := s.authSvc.UpdateUserProfile(r.Context(), &updated)
saved, err := s.authSvc.UpdateUserProfile(r.Context(), user.ID, auth.ProfilePatch{
Name: patch.Name,
Email: patch.Email,
Department: patch.Department,
Locale: patch.Locale,
Timezone: patch.Timezone,
})
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
+2
View File
@@ -315,6 +315,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("PATCH /api/v1/me/profile", s.handleMeProfile)
mux.HandleFunc("GET /api/v1/me/preferences", s.handleMePreferences)
mux.HandleFunc("PUT /api/v1/me/preferences", s.handleMePreferences)
mux.HandleFunc("GET /api/v1/users/directory", s.handleUserDirectory)
mux.HandleFunc("GET /api/v1/auth/sessions", s.handleAuthSessions)
mux.HandleFunc("GET /api/v1/admin/permissions", s.handleAdminPermissions)
mux.HandleFunc("DELETE /api/v1/auth/sessions/{id}", s.handleAuthSessionByID)
@@ -336,6 +337,7 @@ func (s *Server) Routes() http.Handler {
mux.HandleFunc("POST /api/v1/admin/users", s.handleAdminUsers)
mux.HandleFunc("PUT /api/v1/admin/users/{id}/role", s.handleAdminUpdateRole)
mux.HandleFunc("PUT /api/v1/admin/users/{id}/status", s.handleAdminUpdateStatus)
mux.HandleFunc("PATCH /api/v1/admin/users/{id}/profile", s.handleAdminUpdateProfile)
mux.HandleFunc("GET /api/v1/admin/config", s.handleAdminConfig)
mux.HandleFunc("PUT /api/v1/admin/config", s.handleAdminConfig)
@@ -0,0 +1,74 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"chat-agent-system/internal/auth"
)
// handleUserDirectory GET /api/v1/users/directory
// 任何已登录用户可读取 active 用户轻量目录(立项选人 / 负责人选择器)。
func (s *Server) handleUserDirectory(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requirePrincipal(w, r); !ok {
return
}
if r.Method != http.MethodGet {
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
users, err := s.authSvc.ListUserDirectory(r.Context())
if err != nil {
writeSafeError(w, r, http.StatusInternalServerError, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, map[string]any{"users": users})
}
// handleAdminUpdateProfile PATCH /api/v1/admin/users/{id}/profile
func (s *Server) handleAdminUpdateProfile(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireSystemAdmin(w, r)
if !ok {
return
}
if r.Method != http.MethodPatch {
writeSafeError(w, r, http.StatusMethodNotAllowed, "method not allowed")
return
}
targetID, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || targetID <= 0 {
writeSafeError(w, r, http.StatusBadRequest, "无效的用户ID")
return
}
var body struct {
Name *string `json:"name,omitempty"`
Department *string `json:"department,omitempty"`
Email *string `json:"email,omitempty"`
EmployeeID *string `json:"employee_id,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeSafeError(w, r, http.StatusBadRequest, "无效的请求体")
return
}
if body.Name == nil && body.Department == nil && body.Email == nil && body.EmployeeID == nil {
writeSafeError(w, r, http.StatusBadRequest, "至少提供一个可更新字段")
return
}
if body.Name != nil && strings.TrimSpace(*body.Name) == "" {
writeSafeError(w, r, http.StatusBadRequest, "姓名不能为空")
return
}
updated, err := s.authSvc.AdminUpdateUserProfile(r.Context(), targetID, auth.ProfilePatch{
Name: body.Name,
Department: body.Department,
Email: body.Email,
EmployeeID: body.EmployeeID,
}, actor.ID)
if err != nil {
writeSafeError(w, r, http.StatusBadRequest, err.Error())
return
}
writeJSONResponse(w, http.StatusOK, updated)
}
@@ -0,0 +1,194 @@
package server_test
import (
"bytes"
"encoding/json"
"net/http"
"strconv"
"testing"
"chat-agent-system/internal/auth"
)
func loginTS(t *testing.T, baseURL, username, password string) (token string, userID int64) {
t.Helper()
body, _ := json.Marshal(map[string]string{"username": username, "password": password})
resp, err := http.Post(baseURL+"/api/v1/auth/login", "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("login %s: %d", username, resp.StatusCode)
}
var session auth.SessionInfo
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
t.Fatalf("decode login: %v", err)
}
if session.Token == "" || session.User == nil {
t.Fatal("empty session")
}
return session.Token, session.User.ID
}
func TestUserDirectory_RequiresAuthAndReturnsActiveNames(t *testing.T) {
ts, _ := setupTestServer(t)
unauth, err := http.Get(ts.URL + "/api/v1/users/directory")
if err != nil {
t.Fatal(err)
}
defer unauth.Body.Close()
if unauth.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", unauth.StatusCode)
}
token, _ := loginTS(t, ts.URL, "zhanggong", "123456")
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/users/directory", nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var payload struct {
Users []auth.UserDirectoryEntry `json:"users"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if len(payload.Users) == 0 {
t.Fatal("expected non-empty directory")
}
foundZhang := false
for _, u := range payload.Users {
if u.Name == "" {
t.Fatalf("directory entry missing name: %+v", u)
}
if u.Username == "zhanggong" {
foundZhang = true
if u.Name != "张工" {
t.Fatalf("expected 张工, got %q", u.Name)
}
}
}
if !foundZhang {
t.Fatal("expected zhanggong in directory")
}
}
func TestMeProfile_PatchDepartmentAndClearAdminHint(t *testing.T) {
ts, _ := setupTestServer(t)
adminToken, adminID := loginTS(t, ts.URL, "admin", "admin123")
userToken, userID := loginTS(t, ts.URL, "zhanggong", "123456")
// Admin edits target profile → hint set
patchBody, _ := json.Marshal(map[string]string{
"name": "张工-管理员改名",
"department": "骨科耗材研发组",
"email": "zhang@example.com",
"employee_id": "RD-001",
})
adminReq, _ := http.NewRequest(
http.MethodPatch,
ts.URL+"/api/v1/admin/users/"+strconv.FormatInt(userID, 10)+"/profile",
bytes.NewReader(patchBody),
)
adminReq.Header.Set("Authorization", "Bearer "+adminToken)
adminReq.Header.Set("Content-Type", "application/json")
adminResp, err := http.DefaultClient.Do(adminReq)
if err != nil {
t.Fatal(err)
}
defer adminResp.Body.Close()
if adminResp.StatusCode != http.StatusOK {
t.Fatalf("admin profile patch: %d %s", adminResp.StatusCode, adminResp.Body)
}
getReq, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/me/profile", nil)
getReq.Header.Set("Authorization", "Bearer "+userToken)
getResp, err := http.DefaultClient.Do(getReq)
if err != nil {
t.Fatal(err)
}
defer getResp.Body.Close()
if getResp.StatusCode != http.StatusOK {
t.Fatalf("me profile get: %d", getResp.StatusCode)
}
var profile map[string]any
if err := json.NewDecoder(getResp.Body).Decode(&profile); err != nil {
t.Fatal(err)
}
if profile["name"] != "张工-管理员改名" {
t.Fatalf("name after admin edit: %+v", profile["name"])
}
if profile["profile_updated_by_name"] == nil || profile["profile_updated_by_name"] == "" {
t.Fatalf("expected admin hint name, got %+v", profile)
}
if by, ok := profile["profile_updated_by"].(float64); !ok || int64(by) != adminID {
t.Fatalf("expected profile_updated_by=%d, got %+v", adminID, profile["profile_updated_by"])
}
// Self patch clears hint and updates department
selfBody, _ := json.Marshal(map[string]string{
"name": "张工",
"department": "骨科研发一部",
"email": "zhang.self@example.com",
})
selfReq, _ := http.NewRequest(http.MethodPatch, ts.URL+"/api/v1/me/profile", bytes.NewReader(selfBody))
selfReq.Header.Set("Authorization", "Bearer "+userToken)
selfReq.Header.Set("Content-Type", "application/json")
selfResp, err := http.DefaultClient.Do(selfReq)
if err != nil {
t.Fatal(err)
}
defer selfResp.Body.Close()
if selfResp.StatusCode != http.StatusOK {
t.Fatalf("self profile patch: %d", selfResp.StatusCode)
}
getReq2, _ := http.NewRequest(http.MethodGet, ts.URL+"/api/v1/me/profile", nil)
getReq2.Header.Set("Authorization", "Bearer "+userToken)
getResp2, err := http.DefaultClient.Do(getReq2)
if err != nil {
t.Fatal(err)
}
defer getResp2.Body.Close()
var profile2 map[string]any
if err := json.NewDecoder(getResp2.Body).Decode(&profile2); err != nil {
t.Fatal(err)
}
if profile2["name"] != "张工" {
t.Fatalf("name after self save: %+v", profile2["name"])
}
if profile2["department"] != "骨科研发一部" {
t.Fatalf("department after self save: %+v", profile2["department"])
}
if profile2["profile_updated_by_name"] != nil {
t.Fatalf("expected admin hint cleared, got %+v", profile2["profile_updated_by_name"])
}
}
func TestAdminUpdateProfile_ForbiddenForNonAdmin(t *testing.T) {
ts, _ := setupTestServer(t)
userToken, userID := loginTS(t, ts.URL, "zhanggong", "123456")
body, _ := json.Marshal(map[string]string{"name": "黑客改名"})
req, _ := http.NewRequest(
http.MethodPatch,
ts.URL+"/api/v1/admin/users/"+strconv.FormatInt(userID, 10)+"/profile",
bytes.NewReader(body),
)
req.Header.Set("Authorization", "Bearer "+userToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
}
+1
View File
@@ -322,6 +322,7 @@ export default function App() {
useAuthenticatedWorkspaceSync({
allUsers,
setAllUsers,
setCurrentUser,
setSession,
setSessionValidated,
+60
View File
@@ -49,6 +49,66 @@ export class AuthClient {
return resp.json();
}
async updateMyProfile(patch: {
name?: string;
department?: string;
email?: string;
locale?: string;
timezone?: string;
}): Promise<UserAccount> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/me/profile', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(patch),
}, true);
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt.trim() || '保存个人资料失败');
}
return mapUserAccount(await resp.json());
}
async fetchUserDirectory(): Promise<UserAccount[]> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/users/directory', {
headers: token ? { Authorization: `Bearer ${token}` } : {},
}, true);
if (!resp.ok) {
throw new Error('获取用户目录失败');
}
const data = await resp.json();
return mapUserAccountList(data.users);
}
async adminUpdateUserProfile(
userId: number,
patch: { name?: string; department?: string; email?: string; employeeId?: string },
): Promise<UserAccount> {
const token = this.getSessionToken();
const body: Record<string, string> = {};
if (patch.name !== undefined) body.name = patch.name;
if (patch.department !== undefined) body.department = patch.department;
if (patch.email !== undefined) body.email = patch.email;
if (patch.employeeId !== undefined) body.employee_id = patch.employeeId;
const resp = await authRequest(`/api/v1/admin/users/${userId}/profile`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
}, true);
if (!resp.ok) {
const txt = await resp.text();
throw new Error(txt.trim() || '更新用户资料失败');
}
return mapUserAccount(await resp.json());
}
async savePreferences(prefs: Record<string, unknown>): Promise<Record<string, unknown>> {
const token = this.getSessionToken();
const resp = await authRequest('/api/v1/me/preferences', {
+68
View File
@@ -127,6 +127,53 @@ test('AuthClient.login + fetchMe map real snake_case wire payloads', async () =>
headers: { 'Content-Type': 'application/json' },
});
}
if (url.includes('/api/v1/users/directory')) {
return new Response(
JSON.stringify({
users: [
{
id: 2,
username: 'zhanggong',
name: '张工',
department: '骨科耗材研发组',
position: '技术主管',
},
],
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (url.includes('/api/v1/me/profile') && init?.method === 'PATCH') {
return new Response(
JSON.stringify({
id: 1,
username: 'admin',
name: '系统管理员改名',
department: '系统运维部',
email: 'admin@example.com',
role_id: 'role-admin',
employee_id: 'SYS-000',
status: 'active',
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
if (url.includes('/api/v1/admin/users/') && url.includes('/profile') && init?.method === 'PATCH') {
return new Response(
JSON.stringify({
id: 2,
username: 'zhanggong',
name: '张工-管理员改',
department: '骨科',
email: 'z@example.com',
employee_id: 'RD-001',
role_id: 'role-tech-lead',
status: 'active',
profile_updated_by: 1,
}),
{ status: 200, headers: { 'Content-Type': 'application/json' } },
);
}
return new Response('not found', { status: 404 });
}) as typeof fetch;
@@ -149,6 +196,27 @@ test('AuthClient.login + fetchMe map real snake_case wire payloads', async () =>
const users = await client.getAdminUsers();
assert.equal(users[0].roleId, 'role-admin');
assert.equal(users[0].employeeId, 'SYS-000');
const directory = await client.fetchUserDirectory();
assert.equal(directory[0].name, '张工');
assert.equal(directory[0].username, 'zhanggong');
const updated = await client.updateMyProfile({
name: '系统管理员改名',
department: '系统运维部',
email: 'admin@example.com',
});
assert.equal(updated.name, '系统管理员改名');
assert.equal(updated.email, 'admin@example.com');
const adminUpdated = await client.adminUpdateUserProfile(2, {
name: '张工-管理员改',
department: '骨科',
email: 'z@example.com',
employeeId: 'RD-001',
});
assert.equal(adminUpdated.name, '张工-管理员改');
assert.equal(adminUpdated.employeeId, 'RD-001');
} finally {
globalThis.fetch = originalFetch;
if (originalWindow === undefined) {
@@ -45,6 +45,14 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefre
const [newPosition, setNewPosition] = useState('方案工程师');
const [newDept, setNewDept] = useState('研发中心');
// 管理员编辑展示资料
const [editProfileUser, setEditProfileUser] = useState<UserAccount | null>(null);
const [editName, setEditName] = useState('');
const [editDept, setEditDept] = useState('');
const [editEmail, setEditEmail] = useState('');
const [editEmployeeId, setEditEmployeeId] = useState('');
const [editProfileSaving, setEditProfileSaving] = useState(false);
// 准入策略本地状态
const [allowReg, setAllowReg] = useState(true);
const [requireAppr, setRequireAppr] = useState(false);
@@ -190,6 +198,44 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefre
}
};
const openEditProfile = (user: UserAccount) => {
setEditProfileUser(user);
setEditName(user.name || '');
setEditDept(user.department || '');
setEditEmail(user.email || '');
setEditEmployeeId(user.employeeId || '');
};
const handleEditProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!editProfileUser) return;
const trimmedName = editName.trim();
if (!trimmedName) {
showNotification('error', '真实姓名不能为空');
return;
}
setEditProfileSaving(true);
try {
await authClient.adminUpdateUserProfile(editProfileUser.id, {
name: trimmedName,
department: editDept.trim(),
email: editEmail.trim(),
employeeId: editEmployeeId.trim(),
});
setEditProfileUser(null);
try {
await refreshUsers();
showNotification('success', `已更新【${trimmedName}】的展示资料`);
} catch {
showNotification('error', '资料已更新,但全局用户目录同步失败,请刷新后重试');
}
} catch (err: unknown) {
showNotification('error', err instanceof Error ? err.message : '更新展示资料失败');
} finally {
setEditProfileSaving(false);
}
};
// 5. 管理员新建账号
const handleCreateUserSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -490,6 +536,21 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefre
</td>
<td style={{ padding: '10px 14px', textAlign: 'right' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '8px' }}>
<button
type="button"
onClick={() => openEditProfile(u)}
style={{
fontSize: '11px',
color: 'var(--brand-500)',
padding: '3px 6px',
borderRadius: '4px',
border: '1px solid var(--border)',
cursor: 'pointer',
}}
title="编辑姓名、部门、邮箱与工号"
>
编辑资料
</button>
<button
type="button"
onClick={() => handleResetUserPassword(u)}
@@ -893,6 +954,106 @@ export const UsersSection: React.FC<UsersSectionProps> = ({ currentUser, onRefre
</div>
</div>
)}
{editProfileUser && (
<div
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(11, 14, 20, 0.7)',
zIndex: 1100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '20px',
}}
onClick={(e) => {
if (e.target === e.currentTarget && !editProfileSaving) setEditProfileUser(null);
}}
>
<div
style={{
width: '440px',
maxWidth: '95vw',
backgroundColor: 'var(--bg-panel)',
borderRadius: '12px',
border: '1px solid var(--border-strong)',
padding: '20px 22px',
boxShadow: 'var(--shadow-lg)',
}}
>
<div style={{ fontSize: '15px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
编辑展示资料
</div>
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '14px' }}>
账号 {editProfileUser.username}:修改后对方设置页将提示由管理员代改。
</div>
<form onSubmit={handleEditProfileSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
真实姓名 *
</label>
<input
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
所属部门
</label>
<input
type="text"
value={editDept}
onChange={(e) => setEditDept(e.target.value)}
style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
邮箱
</label>
<input
type="email"
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '4px' }}>
工号
</label>
<input
type="text"
value={editEmployeeId}
onChange={(e) => setEditEmployeeId(e.target.value)}
style={{ width: '100%', padding: '8px 10px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-strong)', fontSize: '12px', boxSizing: 'border-box' }}
/>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '8px' }}>
<button
type="button"
disabled={editProfileSaving}
onClick={() => setEditProfileUser(null)}
style={{ padding: '7px 14px', borderRadius: '6px', backgroundColor: 'var(--bg-app)', border: '1px solid var(--border)', color: 'var(--text-muted)', fontSize: '12px', cursor: 'pointer' }}
>
取消
</button>
<button
type="submit"
disabled={editProfileSaving}
style={{ padding: '7px 18px', borderRadius: '6px', background: 'var(--brand-grad)', color: '#FFFFFF', fontSize: '12px', fontWeight: 600, cursor: editProfileSaving ? 'wait' : 'pointer', opacity: editProfileSaving ? 0.7 : 1 }}
>
{editProfileSaving ? '保存中…' : '保存资料'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
+4 -1
View File
@@ -280,7 +280,10 @@ export function WorkbenchView({
.filter((m) => m.status === 'active')
.map((m) => ({
userId: m.userId,
name: allUsers.find((u) => u.id === m.userId)?.name || `用户 ${m.userId}`,
name:
allUsers.find((u) => u.id === m.userId)?.name ||
m.userName ||
`用户 ${m.userId}`,
}))}
onClose={() => setProjectSettingsOpen(false)}
onActivated={(activeKey) => {
+136 -11
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import {
User,
LogOut,
@@ -23,7 +23,7 @@ export interface UserSettingsModalProps {
onClose: () => void;
currentUser: UserProfile;
roles: RoleDefinition[];
onUpdateProfile: (updated: Partial<UserProfile>) => void;
onUpdateProfile: (updated: Partial<UserProfile> & { email?: string }) => void;
onLogout: () => void;
theme?: 'dark' | 'light';
onToggleTheme?: () => void;
@@ -55,7 +55,11 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
// Profile form state
const [name, setName] = useState(currentUser.name);
const [department, setDepartment] = useState(currentUser.department);
const [email, setEmail] = useState('');
const [profileSuccessTip, setProfileSuccessTip] = useState(false);
const [profileError, setProfileError] = useState('');
const [profileLoading, setProfileLoading] = useState(false);
const [adminEditHint, setAdminEditHint] = useState<{ byName: string; at: string } | null>(null);
// Security password state
const [oldPassword, setOldPassword] = useState('');
@@ -68,6 +72,40 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
// Preferences
const [exportFormat, setExportFormat] = useState<'docx' | 'xlsx'>('docx');
useEffect(() => {
if (!isOpen) return;
setName(currentUser.name);
setDepartment(currentUser.department);
setProfileError('');
setProfileSuccessTip(false);
let cancelled = false;
void (async () => {
try {
const profile = await authClient.fetchProfile();
if (cancelled) return;
if (typeof profile.name === 'string') setName(profile.name);
if (typeof profile.department === 'string') setDepartment(profile.department);
if (typeof profile.email === 'string') setEmail(profile.email);
const byName = typeof profile.profile_updated_by_name === 'string'
? profile.profile_updated_by_name.trim()
: '';
const at = typeof profile.profile_updated_at === 'string'
? profile.profile_updated_at.trim()
: '';
if (byName) {
setAdminEditHint({ byName, at });
} else {
setAdminEditHint(null);
}
} catch {
if (!cancelled) setAdminEditHint(null);
}
})();
return () => {
cancelled = true;
};
}, [isOpen, currentUser.id, currentUser.name, currentUser.department]);
if (!isOpen) return null;
// 获取当前用户的角色对象
@@ -77,14 +115,35 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
ALL_PERMISSION_DESCRIPTIONS.forEach((p) => userPermissions.add(p.key));
}
const handleSaveProfile = (e: React.FormEvent) => {
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
onUpdateProfile({
name: name.trim(),
department: department.trim(),
});
setProfileSuccessTip(true);
setTimeout(() => setProfileSuccessTip(false), 2500);
setProfileError('');
setProfileSuccessTip(false);
const trimmedName = name.trim();
if (!trimmedName) {
setProfileError('真实姓名不能为空');
return;
}
setProfileLoading(true);
try {
const saved = await authClient.updateMyProfile({
name: trimmedName,
department: department.trim(),
email: email.trim(),
});
onUpdateProfile({
name: saved.name,
department: saved.department || '',
email: saved.email,
});
setAdminEditHint(null);
setProfileSuccessTip(true);
setTimeout(() => setProfileSuccessTip(false), 2500);
} catch (err: unknown) {
setProfileError(err instanceof Error ? err.message : '保存个人资料失败');
} finally {
setProfileLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
@@ -417,6 +476,48 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
</div>
</div>
{adminEditHint && (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
padding: '10px 14px',
borderRadius: '8px',
backgroundColor: 'rgba(242, 169, 59, 0.12)',
border: '1px solid rgba(242, 169, 59, 0.35)',
color: 'var(--amber-500)',
fontSize: '12px',
}}
>
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: 1 }} />
<span>
你的展示信息最近由管理员「{adminEditHint.byName}」
{adminEditHint.at ? `于 ${adminEditHint.at} ` : ''}
修改。你可在此核对并自行保存覆盖。
</span>
</div>
)}
{profileError && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '10px 14px',
borderRadius: '8px',
backgroundColor: 'var(--danger-bg)',
border: '1px solid rgba(228, 87, 75, 0.3)',
color: 'var(--danger)',
fontSize: '12px',
}}
>
<AlertCircle size={16} />
<span>{profileError}</span>
</div>
)}
{profileSuccessTip && (
<div
style={{
@@ -479,6 +580,28 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-strong)', marginBottom: '6px' }}>
邮箱
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="可选"
style={{
width: '100%',
padding: '9px 12px',
borderRadius: '8px',
backgroundColor: 'var(--bg-app)',
border: '1px solid var(--border)',
color: 'var(--text-strong)',
fontSize: '13px',
boxSizing: 'border-box',
}}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)', marginBottom: '6px' }}>
所属受控职位 / 权限角色(仅管理员可调整)
@@ -507,6 +630,7 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
<div style={{ marginTop: '10px' }}>
<button
type="submit"
disabled={profileLoading}
style={{
padding: '9px 24px',
borderRadius: '8px',
@@ -514,11 +638,12 @@ export const UserSettingsModal: React.FC<UserSettingsModalProps> = ({
color: '#FFFFFF',
fontSize: '13px',
fontWeight: 600,
cursor: 'pointer',
cursor: profileLoading ? 'wait' : 'pointer',
opacity: profileLoading ? 0.7 : 1,
boxShadow: '0 2px 8px rgba(79, 107, 255, 0.3)',
}}
>
保存个人资料
{profileLoading ? '保存中…' : '保存个人资料'}
</button>
</div>
</form>
@@ -12,4 +12,7 @@ test('login workspace sync lives in one hook', () => {
assert.equal(hook.includes('fetchChatSessions'), true);
assert.equal(hook.includes('loginMemberSyncIds'), true);
assert.equal(hook.includes('.fetchMe()'), true);
assert.equal(hook.includes('fetchUserDirectory'), true);
assert.equal(hook.includes('setAllUsers'), true);
assert.equal(app.includes('setAllUsers'), true);
});
@@ -12,6 +12,7 @@ import type { ProjectInfo, ProjectSession } from '../types/workbench';
export interface AuthenticatedWorkspaceSyncInput {
allUsers: UserProfile[];
setAllUsers: Dispatch<SetStateAction<UserProfile[]>>;
setCurrentUser: Dispatch<SetStateAction<UserProfile>>;
setSession: Dispatch<SetStateAction<LoginResult | null>>;
setSessionValidated: Dispatch<SetStateAction<boolean | null>>;
@@ -39,6 +40,7 @@ export interface AuthenticatedWorkspaceSyncInput {
export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncInput): void {
const {
allUsers,
setAllUsers,
setCurrentUser,
setSession,
setSessionValidated,
@@ -99,6 +101,36 @@ export function useAuthenticatedWorkspaceSync(input: AuthenticatedWorkspaceSyncI
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 会话校验通过后拉取全员轻量目录(普通用户可用,修复负责人选择器「用户 N」兜底)。
// 独立 effect + 不依赖 allUsers,避免与项目同步 effect 循环触发。
useEffect(() => {
if (!session || sessionValidated !== true) return;
let cancelled = false;
void (async () => {
try {
const directory = await authClient.fetchUserDirectory();
if (cancelled) return;
setAllUsers(
directory.map((u) => ({
id: u.id,
name: u.name,
employeeId: u.employeeId || '',
department: u.department || '',
position: u.position || '',
roleId: u.roleId,
avatar: u.avatar,
})),
);
} catch (err) {
if (cancelled) return;
console.warn('[users] 用户目录同步失败,继续使用本地缓存:', err);
}
})();
return () => {
cancelled = true;
};
}, [session, sessionValidated, setAllUsers]);
useEffect(() => {
if (!session || sessionValidated !== true) {
setProjectSyncNotice(null);