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:
@@ -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