收敛用户角色并共享项目库

- 后端限制系统只保留默认 admin 管理员,新建用户固定为标注员,并拒绝观察员或额外管理员角色。

- 将项目、帧、媒体解析、AI 标注、任务、Dashboard 和导出接口改为共享项目库访问,标注员具备同等项目管理和标注能力。

- 前端用户管理移除角色选择和观察员入口,只展示唯一管理员与标注员状态。

- 更新后端/前端测试,覆盖唯一 admin、旧 viewer 归一为标注员、用户删除和共享项目库访问。

- 同步更新 AGENTS 与 doc 文档中的角色权限、共享项目库和测试计划说明。
This commit is contained in:
2026-05-04 05:20:28 +08:00
parent 02635abab1
commit 523beeb446
21 changed files with 214 additions and 172 deletions

View File

@@ -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 }));

View File

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

View File

@@ -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 } });