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:
@@ -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
@@ -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 系统全局认证与准入配置
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -322,6 +322,7 @@ export default function App() {
|
||||
|
||||
useAuthenticatedWorkspaceSync({
|
||||
allUsers,
|
||||
setAllUsers,
|
||||
setCurrentUser,
|
||||
setSession,
|
||||
setSessionValidated,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user