收敛用户角色并共享项目库
- 后端限制系统只保留默认 admin 管理员,新建用户固定为标注员,并拒绝观察员或额外管理员角色。 - 将项目、帧、媒体解析、AI 标注、任务、Dashboard 和导出接口改为共享项目库访问,标注员具备同等项目管理和标注能力。 - 前端用户管理移除角色选择和观察员入口,只展示唯一管理员与标注员状态。 - 更新后端/前端测试,覆盖唯一 admin、旧 viewer 归一为标注员、用户删除和共享项目库访问。 - 同步更新 AGENTS 与 doc 文档中的角色权限、共享项目库和测试计划说明。
This commit is contained in:
@@ -52,11 +52,11 @@ describe('UserAdmin', () => {
|
||||
expect(screen.getByText('当前管理员:admin')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('creates a user with role and password', async () => {
|
||||
it('creates new users as annotators', async () => {
|
||||
apiMock.createAdminUser.mockResolvedValueOnce({
|
||||
id: 3,
|
||||
username: 'nurse',
|
||||
role: 'viewer',
|
||||
role: 'annotator',
|
||||
is_active: 1,
|
||||
});
|
||||
|
||||
@@ -64,30 +64,26 @@ describe('UserAdmin', () => {
|
||||
await screen.findByText('doctor');
|
||||
fireEvent.change(screen.getByPlaceholderText('用户名'), { target: { value: 'nurse' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('初始密码'), { target: { value: 'secret123' } });
|
||||
fireEvent.change(screen.getAllByDisplayValue('标注员')[0], { target: { value: 'viewer' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增用户/ }));
|
||||
|
||||
await waitFor(() => expect(apiMock.createAdminUser).toHaveBeenCalledWith({
|
||||
username: 'nurse',
|
||||
password: 'secret123',
|
||||
role: 'viewer',
|
||||
role: 'annotator',
|
||||
is_active: true,
|
||||
}));
|
||||
expect(await screen.findByText('用户已创建')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('updates role, status and password, and deletes users', async () => {
|
||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 1 });
|
||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
|
||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'viewer', is_active: 0 });
|
||||
it('updates status and password, and deletes users', async () => {
|
||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
|
||||
apiMock.updateAdminUser.mockResolvedValueOnce({ id: 2, username: 'doctor', role: 'annotator', is_active: 0 });
|
||||
apiMock.deleteAdminUser.mockResolvedValueOnce(undefined);
|
||||
|
||||
render(<UserAdmin />);
|
||||
await screen.findByText('doctor');
|
||||
|
||||
const roleSelects = screen.getAllByDisplayValue('标注员');
|
||||
fireEvent.change(roleSelects[1], { target: { value: 'viewer' } });
|
||||
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { role: 'viewer' }));
|
||||
expect(screen.queryByText('观察员')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '启用' })[1]);
|
||||
await waitFor(() => expect(apiMock.updateAdminUser).toHaveBeenCalledWith(2, { is_active: false }));
|
||||
|
||||
@@ -17,7 +17,6 @@ import { TransientNotice, type NoticeState, type NoticeTone } from './TransientN
|
||||
const roleLabels: Record<string, string> = {
|
||||
admin: '管理员',
|
||||
annotator: '标注员',
|
||||
viewer: '观察员',
|
||||
};
|
||||
|
||||
function formatTime(value: string): string {
|
||||
@@ -47,7 +46,6 @@ export function UserAdmin() {
|
||||
const [notice, setNotice] = useState<NoticeState | null>(null);
|
||||
const [newUsername, setNewUsername] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [newRole, setNewRole] = useState('annotator');
|
||||
const [passwordTarget, setPasswordTarget] = useState<AdminUser | null>(null);
|
||||
const [nextPassword, setNextPassword] = useState('');
|
||||
const [deleteUserTarget, setDeleteUserTarget] = useState<AdminUser | null>(null);
|
||||
@@ -88,13 +86,12 @@ export function UserAdmin() {
|
||||
const created = await createAdminUser({
|
||||
username: newUsername.trim(),
|
||||
password: newPassword,
|
||||
role: newRole,
|
||||
role: 'annotator',
|
||||
is_active: true,
|
||||
});
|
||||
setUsers((prev) => [...prev, created]);
|
||||
setNewUsername('');
|
||||
setNewPassword('');
|
||||
setNewRole('annotator');
|
||||
showNotice('用户已创建', 'success');
|
||||
setAuditLogs(await getAuditLogs(100));
|
||||
} catch (err: any) {
|
||||
@@ -188,7 +185,7 @@ export function UserAdmin() {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">用户管理后台</h1>
|
||||
<p className="mt-1 text-xs text-gray-500">账号、角色、状态和安全审计</p>
|
||||
<p className="mt-1 text-xs text-gray-500">账号、状态和安全审计</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-gray-400">
|
||||
<span className="rounded border border-cyan-400/20 bg-cyan-400/10 px-3 py-1 text-cyan-100">
|
||||
@@ -209,7 +206,7 @@ export function UserAdmin() {
|
||||
{isLoading && <Loader2 size={16} className="animate-spin text-cyan-300" />}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_150px_auto] gap-2 border-b border-white/10 p-4">
|
||||
<form onSubmit={handleCreateUser} className="grid grid-cols-[1fr_1fr_auto] gap-2 border-b border-white/10 p-4">
|
||||
<input
|
||||
value={newUsername}
|
||||
onChange={(event) => setNewUsername(event.target.value)}
|
||||
@@ -225,15 +222,6 @@ export function UserAdmin() {
|
||||
autoComplete="new-password"
|
||||
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
||||
/>
|
||||
<select
|
||||
value={newRole}
|
||||
onChange={(event) => setNewRole(event.target.value)}
|
||||
className="rounded border border-white/10 bg-[#181818] px-3 py-2 text-sm text-white outline-none focus:border-cyan-400/50"
|
||||
>
|
||||
<option value="annotator">标注员</option>
|
||||
<option value="viewer">观察员</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
@@ -262,16 +250,16 @@ export function UserAdmin() {
|
||||
<div className="text-xs text-gray-500">ID {user.id}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(event) => void handlePatchUser(user, { role: event.target.value })}
|
||||
disabled={isSaving}
|
||||
className="rounded border border-white/10 bg-[#181818] px-2 py-1 text-xs text-cyan-100"
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex rounded border px-2 py-1 text-xs',
|
||||
user.role === 'admin'
|
||||
? 'border-cyan-400/30 bg-cyan-400/10 text-cyan-100'
|
||||
: 'border-white/10 bg-white/5 text-gray-200',
|
||||
)}
|
||||
>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="annotator">标注员</option>
|
||||
<option value="viewer">观察员</option>
|
||||
</select>
|
||||
{roleLabels[user.role] || '标注员'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
|
||||
@@ -180,12 +180,12 @@ describe('api client contracts', () => {
|
||||
.mockResolvedValueOnce({ data: [{ id: 1, username: 'admin', role: 'admin', is_active: 1 }] })
|
||||
.mockResolvedValueOnce({ data: [{ id: 9, action: 'admin.user_created', created_at: 'now' }] });
|
||||
axiosMock.client.post.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 1 } });
|
||||
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'viewer', is_active: 1 } });
|
||||
axiosMock.client.patch.mockResolvedValueOnce({ data: { id: 2, username: 'doctor', role: 'annotator', is_active: 0 } });
|
||||
axiosMock.client.delete.mockResolvedValueOnce({ data: null });
|
||||
|
||||
await expect(getAdminUsers()).resolves.toEqual([expect.objectContaining({ username: 'admin' })]);
|
||||
await createAdminUser({ username: 'doctor', password: 'secret123', role: 'annotator', is_active: true });
|
||||
await updateAdminUser(2, { role: 'viewer' });
|
||||
await updateAdminUser(2, { is_active: false });
|
||||
await deleteAdminUser(2);
|
||||
await expect(getAuditLogs(50)).resolves.toEqual([expect.objectContaining({ action: 'admin.user_created' })]);
|
||||
|
||||
@@ -196,7 +196,7 @@ describe('api client contracts', () => {
|
||||
role: 'annotator',
|
||||
is_active: true,
|
||||
});
|
||||
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { role: 'viewer' });
|
||||
expect(axiosMock.client.patch).toHaveBeenCalledWith('/api/admin/users/2', { is_active: false });
|
||||
expect(axiosMock.client.delete).toHaveBeenCalledWith('/api/admin/users/2');
|
||||
expect(axiosMock.client.get).toHaveBeenNthCalledWith(2, '/api/admin/audit-logs', { params: { limit: 50 } });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user