feat(platform): migrate bot admins from config.yaml to database

- Add BotAdmin ORM model (bot_admins table) scoped per bot_uuid
- Add Alembic migration 0007 to create table and migrate legacy config admins
- Remove top-level admins key from config.yaml template
- Add GET/POST/DELETE /api/v1/platform/bots/<uuid>/admins endpoints
- Update cmdmgr privilege check to query bot_admins table (bot-scoped)
- Add BotAdminsPanel frontend component in bot detail sessions tab
- Add i18n keys (zh-Hans, en-US)
This commit is contained in:
dadachann
2026-06-26 12:43:30 -04:00
parent 48905ea080
commit 2ef3aebe16
11 changed files with 358 additions and 15 deletions
+13 -1
View File
@@ -22,11 +22,12 @@ import {
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import BotAdminsPanel from '@/app/home/bots/components/bot-admins/BotAdminsPanel';
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
import { Settings, FileText, Users, RefreshCw, Trash2, ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
@@ -229,6 +230,10 @@ export default function BotDetailContent({ id }: { id: string }) {
</button>
)}
</TabsTrigger>
<TabsTrigger value="admins" className="gap-1.5">
<ShieldCheck className="size-3.5" />
{t('bots.admins.title')}
</TabsTrigger>
</TabsList>
{/* Tab: Configuration */}
@@ -291,6 +296,13 @@ export default function BotDetailContent({ id }: { id: string }) {
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
</TabsContent>
{/* Tab: Admins */}
<TabsContent value="admins" className="flex-1 min-h-0 overflow-y-auto mt-4">
<div className="mx-auto max-w-3xl pb-8">
<BotAdminsPanel botId={id} />
</div>
</TabsContent>
</Tabs>
</div>
@@ -0,0 +1,132 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Trash2, Plus } from 'lucide-react';
import { toast } from 'sonner';
interface BotAdmin {
id: number;
launcher_type: string;
launcher_id: string;
}
export default function BotAdminsPanel({ botId }: { botId: string }) {
const { t } = useTranslation();
const [admins, setAdmins] = useState<BotAdmin[]>([]);
const [loading, setLoading] = useState(false);
const [newType, setNewType] = useState('person');
const [newId, setNewId] = useState('');
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await httpClient.getBotAdmins(botId);
setAdmins(res.admins ?? []);
} finally {
setLoading(false);
}
}, [botId]);
useEffect(() => { load(); }, [load]);
async function handleAdd() {
if (!newId.trim()) return;
setAdding(true);
try {
await httpClient.addBotAdmin(botId, newType, newId.trim());
toast.success(t('bots.admins.addSuccess'));
setNewId('');
await load();
} catch (e: any) {
toast.error(t('bots.admins.addError') + (e?.msg ?? e?.message ?? ''));
} finally {
setAdding(false);
}
}
async function handleDelete(id: number) {
try {
await httpClient.deleteBotAdmin(botId, id);
toast.success(t('bots.admins.deleteSuccess'));
setAdmins((prev) => prev.filter((a) => a.id !== id));
} catch (e: any) {
toast.error(t('bots.admins.deleteError') + (e?.msg ?? e?.message ?? ''));
}
}
return (
<div className="space-y-4">
<div className="flex gap-2 items-center">
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">{t('bots.admins.typePerson')}</SelectItem>
<SelectItem value="group">{t('bots.admins.typeGroup')}</SelectItem>
</SelectContent>
</Select>
<Input
className="flex-1"
placeholder={t('bots.admins.placeholderId')}
value={newId}
onChange={(e) => setNewId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
<Button size="sm" onClick={handleAdd} disabled={adding || !newId.trim()}>
<Plus className="size-4 mr-1" />
{t('bots.admins.addAdmin')}
</Button>
</div>
{loading ? (
<div className="text-sm text-muted-foreground py-4 text-center">{t('bots.sessionMonitor.loading')}</div>
) : admins.length === 0 ? (
<div className="text-sm text-muted-foreground py-4 text-center">{t('bots.admins.noAdmins')}</div>
) : (
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">{t('bots.admins.launcherType')}</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">{t('bots.admins.launcherId')}</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{admins.map((admin) => (
<tr key={admin.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
{admin.launcher_type === 'person' ? t('bots.admins.typePerson') : t('bots.admins.typeGroup')}
</span>
</td>
<td className="px-3 py-2 font-mono">{admin.launcher_id}</td>
<td className="px-2 py-2">
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => handleDelete(admin.id)}
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+12
View File
@@ -394,6 +394,18 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/platform/bots/${uuid}`);
}
public getBotAdmins(botId: string): Promise<{ admins: Array<{ id: number; launcher_type: string; launcher_id: string }> }> {
return this.get(`/api/v1/platform/bots/${botId}/admins`);
}
public addBotAdmin(botId: string, launcher_type: string, launcher_id: string): Promise<{ id: number }> {
return this.post(`/api/v1/platform/bots/${botId}/admins`, { launcher_type, launcher_id });
}
public deleteBotAdmin(botId: string, adminId: number): Promise<object> {
return this.delete(`/api/v1/platform/bots/${botId}/admins/${adminId}`);
}
public getBotLogs(
botId: string,
request: GetBotLogsRequest,
+15
View File
@@ -444,6 +444,21 @@ const enUS = {
userMessage: 'User',
botMessage: 'Assistant',
},
admins: {
title: 'Admins',
description: 'Launchers (person/group IDs) that have admin privilege for this bot\'s commands',
addAdmin: 'Add Admin',
launcherType: 'Type',
launcherId: 'ID',
typePerson: 'Person',
typeGroup: 'Group',
placeholderId: 'User or group ID',
addSuccess: 'Admin added',
addError: 'Failed to add admin: ',
deleteSuccess: 'Admin removed',
deleteError: 'Failed to remove admin: ',
noAdmins: 'No admins configured',
},
},
plugins: {
title: 'Extensions',
+15
View File
@@ -426,6 +426,21 @@ const zhHans = {
userMessage: '用户',
botMessage: '助手',
},
admins: {
title: '管理员',
description: '拥有此机器人命令管理员权限的会话(用户/群组 ID)',
addAdmin: '添加管理员',
launcherType: '类型',
launcherId: 'ID',
typePerson: '私聊',
typeGroup: '群聊',
placeholderId: '用户或群组 ID',
addSuccess: '添加成功',
addError: '添加失败:',
deleteSuccess: '已移除',
deleteError: '移除失败:',
noAdmins: '暂无管理员',
},
},
plugins: {
title: '插件扩展',