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
+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);