diff --git a/.doc/ops/short-term/2026-09-23-user-profile-display.md b/.doc/ops/short-term/2026-09-23-user-profile-display.md new file mode 100644 index 00000000..bf7ad85f --- /dev/null +++ b/.doc/ops/short-term/2026-09-23-user-profile-display.md @@ -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 再浏览器验收) diff --git a/deploy/postgres/goose/00064_2026-09-23-user-profile-admin-edit.sql b/deploy/postgres/goose/00064_2026-09-23-user-profile-admin-edit.sql new file mode 100644 index 00000000..679f0e15 --- /dev/null +++ b/deploy/postgres/goose/00064_2026-09-23-user-profile-admin-edit.sql @@ -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; diff --git a/internal/auth/models.go b/internal/auth/models.go index 310ea33c..3ab2a59b 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -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 系统全局认证与准入配置 diff --git a/internal/auth/pg_store.go b/internal/auth/pg_store.go index 43363e71..75589f14 100644 --- a/internal/auth/pg_store.go +++ b/internal/auth/pg_store.go @@ -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 { diff --git a/internal/auth/service.go b/internal/auth/service.go index 99aabdf5..fb5d58e2 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -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() diff --git a/internal/auth/store.go b/internal/auth/store.go index ef5371c0..2e3b8ab7 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -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 ©, nil @@ -249,6 +260,34 @@ func (s *MemoryStore) UpdateUserPassword(ctx context.Context, id int64, password return ©, 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() diff --git a/internal/server/me_settings_handlers.go b/internal/server/me_settings_handlers.go index d1cf5fd2..9e9a1e77 100644 --- a/internal/server/me_settings_handlers.go +++ b/internal/server/me_settings_handlers.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index 282fca8c..1ea6a2f8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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) diff --git a/internal/server/user_directory_handlers.go b/internal/server/user_directory_handlers.go new file mode 100644 index 00000000..918ca2f6 --- /dev/null +++ b/internal/server/user_directory_handlers.go @@ -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) +} diff --git a/internal/server/user_profile_directory_test.go b/internal/server/user_profile_directory_test.go new file mode 100644 index 00000000..c13803f7 --- /dev/null +++ b/internal/server/user_profile_directory_test.go @@ -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) + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx index 469836a4..e2776d2c 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -322,6 +322,7 @@ export default function App() { useAuthenticatedWorkspaceSync({ allUsers, + setAllUsers, setCurrentUser, setSession, setSessionValidated, diff --git a/web/src/api/auth.ts b/web/src/api/auth.ts index 3f69cb3a..46672c35 100644 --- a/web/src/api/auth.ts +++ b/web/src/api/auth.ts @@ -49,6 +49,66 @@ export class AuthClient { return resp.json(); } + async updateMyProfile(patch: { + name?: string; + department?: string; + email?: string; + locale?: string; + timezone?: string; + }): Promise { + 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 { + 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 { + const token = this.getSessionToken(); + const body: Record = {}; + 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): Promise> { const token = this.getSessionToken(); const resp = await authRequest('/api/v1/me/preferences', { diff --git a/web/src/api/authUserMap.test.ts b/web/src/api/authUserMap.test.ts index 263ca95f..91f0cf19 100644 --- a/web/src/api/authUserMap.test.ts +++ b/web/src/api/authUserMap.test.ts @@ -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) { diff --git a/web/src/components/admin/console/sections/UsersSection.tsx b/web/src/components/admin/console/sections/UsersSection.tsx index 49b0a2f0..281b83be 100644 --- a/web/src/components/admin/console/sections/UsersSection.tsx +++ b/web/src/components/admin/console/sections/UsersSection.tsx @@ -45,6 +45,14 @@ export const UsersSection: React.FC = ({ currentUser, onRefre const [newPosition, setNewPosition] = useState('方案工程师'); const [newDept, setNewDept] = useState('研发中心'); + // 管理员编辑展示资料 + const [editProfileUser, setEditProfileUser] = useState(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 = ({ 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 = ({ currentUser, onRefre
+
)} + + {editProfileUser && ( +
{ + if (e.target === e.currentTarget && !editProfileSaving) setEditProfileUser(null); + }} + > +
+
+ 编辑展示资料 +
+
+ 账号 {editProfileUser.username}:修改后对方设置页将提示由管理员代改。 +
+
+
+ + 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' }} + /> +
+
+ + 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' }} + /> +
+
+ + 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' }} + /> +
+
+ + 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' }} + /> +
+
+ + +
+
+
+
+ )} ); }; diff --git a/web/src/components/layout/WorkbenchView.tsx b/web/src/components/layout/WorkbenchView.tsx index 6b25c6bf..e2ece52a 100644 --- a/web/src/components/layout/WorkbenchView.tsx +++ b/web/src/components/layout/WorkbenchView.tsx @@ -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) => { diff --git a/web/src/components/user/UserSettingsModal.tsx b/web/src/components/user/UserSettingsModal.tsx index 72556caf..e36da972 100644 --- a/web/src/components/user/UserSettingsModal.tsx +++ b/web/src/components/user/UserSettingsModal.tsx @@ -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) => void; + onUpdateProfile: (updated: Partial & { email?: string }) => void; onLogout: () => void; theme?: 'dark' | 'light'; onToggleTheme?: () => void; @@ -55,7 +55,11 @@ export const UserSettingsModal: React.FC = ({ // 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 = ({ // 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 = ({ 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 = ({ + {adminEditHint && ( +
+ + + 你的展示信息最近由管理员「{adminEditHint.byName}」 + {adminEditHint.at ? `于 ${adminEditHint.at} ` : ''} + 修改。你可在此核对并自行保存覆盖。 + +
+ )} + + {profileError && ( +
+ + {profileError} +
+ )} + {profileSuccessTip && (
= ({
+
+ + 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', + }} + /> +
+