refactor(manage): 对齐 GOSpeak 壳层拆分管理页 + 清理重复流程
- 从 919 行单文件拆为 8 模块:ManageShell/ManageOverview/ManageRooms/ManagePermissions/ManageSystem/ManageUsersInline/constants/manageNav - 主入口 web/src/app/routes/manage.tsx 80 行仅作状态与组合,去掉与 /users 重复的用户内嵌 tab(保留权限/房间/概览/系统) - manageNav 引入权限过滤 MANAGE_TABS,对齐 GOSpeak 的 manageNav + ManageShell 壳层思路 - 忽略规则修正:server -> /server 避免误吞 internal/server,新增 .shots/ 与 web/shot*.mjs - 新增 playwright 截图链路(devDep),配套更新 pnpm-lock
This commit is contained in:
parent
46a6bd02d9
commit
077226df4c
|
|
@ -7,5 +7,8 @@ data/*
|
|||
# 前端工程
|
||||
web/node_modules
|
||||
|
||||
server
|
||||
/server
|
||||
/tmp/sync-live
|
||||
.shots/
|
||||
web/shot*.mjs
|
||||
# playwright cache kept local
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -19,9 +19,9 @@
|
|||
}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/static/assets/index-BTRwyDO1.js"></script>
|
||||
<script type="module" crossorigin src="/static/assets/index-D9mHcT7c.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/static/assets/ui-CzB-gjVP.js">
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-OrVzMh6m.css">
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-Cj7RYHpI.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"playwright": "~1.54.2",
|
||||
"tailwindcss": "4.3.3",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.2.1",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ importers:
|
|||
'@tailwindcss/vite':
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3(vite@8.2.1(jiti@2.7.0))
|
||||
playwright:
|
||||
specifier: ~1.54.2
|
||||
version: 1.54.2
|
||||
tailwindcss:
|
||||
specifier: 4.3.3
|
||||
version: 4.3.3
|
||||
|
|
@ -744,6 +747,11 @@ packages:
|
|||
picomatch:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
os: [darwin]
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
|
|
@ -975,6 +983,16 @@ packages:
|
|||
resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
playwright-core@1.54.2:
|
||||
resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
playwright@1.54.2:
|
||||
resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==}
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
postcss@8.5.26:
|
||||
resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
|
@ -2155,6 +2173,9 @@ snapshots:
|
|||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
|
|
@ -2308,6 +2329,14 @@ snapshots:
|
|||
|
||||
picomatch@4.0.5: {}
|
||||
|
||||
playwright-core@1.54.2: {}
|
||||
|
||||
playwright@1.54.2:
|
||||
dependencies:
|
||||
playwright-core: 1.54.2
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.2
|
||||
|
||||
postcss@8.5.26:
|
||||
dependencies:
|
||||
nanoid: 3.3.18
|
||||
|
|
|
|||
|
|
@ -1,110 +1,18 @@
|
|||
import { Component, createEffect, createSignal, For, Show } from 'solid-js'
|
||||
import {
|
||||
Users as UsersIcon,
|
||||
ShieldCheck,
|
||||
Layers,
|
||||
Settings,
|
||||
KeyRound,
|
||||
Plus,
|
||||
Trash2,
|
||||
ShieldAlert,
|
||||
RadioTower,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Crown,
|
||||
UserRound,
|
||||
MessageSquare,
|
||||
Video,
|
||||
Lock,
|
||||
Globe,
|
||||
Hash,
|
||||
Save,
|
||||
RefreshCw,
|
||||
LogOut,
|
||||
Ban,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-solid'
|
||||
import { Component, createEffect, createSignal, Show } from 'solid-js'
|
||||
import { ShieldCheck, RefreshCw, ShieldAlert } from 'lucide-solid'
|
||||
import { api } from '../../lib/api'
|
||||
import { auth } from '../../lib/auth'
|
||||
import { permissions } from '../../lib/permissions'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Corners,
|
||||
LiveDot,
|
||||
Reveal,
|
||||
Select,
|
||||
Segmented,
|
||||
} from '../../components/ui'
|
||||
import { Alert, Badge, Button } from '../../components/ui'
|
||||
import ManageNav from '../../components/manage/manageNav'
|
||||
import { ManagePage, ManageHeader } from '../../components/manage/ManageShell'
|
||||
import { ManageOverview } from '../../components/manage/ManageOverview'
|
||||
import { ManageRooms } from '../../components/manage/ManageRooms'
|
||||
import { ManagePermissions } from '../../components/manage/ManagePermissions'
|
||||
import { ManageSystem } from '../../components/manage/ManageSystem'
|
||||
import { ALL_PERMS } from '../../components/manage/constants'
|
||||
|
||||
type Tab = 'overview' | 'rooms' | 'permissions' | 'users' | 'system'
|
||||
|
||||
const ALL_PERMS = [
|
||||
'send_chat',
|
||||
'view_chat_history',
|
||||
'view_members',
|
||||
'subscribe',
|
||||
'watch_room',
|
||||
'publish',
|
||||
'stop_stream',
|
||||
'broadcast',
|
||||
'manage_members',
|
||||
'add_members',
|
||||
'remove_members',
|
||||
'manage_permissions',
|
||||
'manage_room',
|
||||
'delete_room',
|
||||
'delete_chat_messages',
|
||||
'control_stream',
|
||||
] as const
|
||||
|
||||
const PERM_LABEL: Record<string, string> = {
|
||||
send_chat: '发弹幕',
|
||||
view_chat_history: '看记录',
|
||||
view_members: '看成员',
|
||||
subscribe: '拉流',
|
||||
watch_room: '监听',
|
||||
publish: '推流',
|
||||
stop_stream: '停播',
|
||||
broadcast: '广播',
|
||||
manage_members: '管成员',
|
||||
add_members: '邀成员',
|
||||
remove_members: '踢人',
|
||||
manage_permissions: '管权限',
|
||||
manage_room: '管房间',
|
||||
delete_room: '删房间',
|
||||
delete_chat_messages: '删消息',
|
||||
control_stream: '控流',
|
||||
}
|
||||
|
||||
const ROLE_META: Record<string, { label: string; color: string }> = {
|
||||
creator: { label: '房主', color: 'border-amber-400/40 bg-amber-400/10 text-amber-600' },
|
||||
admin: { label: '管理员', color: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta' },
|
||||
member: { label: '成员', color: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan' },
|
||||
guest: { label: '访客', color: 'border-line bg-muted text-muted-foreground' },
|
||||
}
|
||||
|
||||
function StatCard(props: { label: string; value: string | number; hint?: string; icon: any }) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div class="relative border border-line bg-card/60 p-4">
|
||||
<Corners />
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="micro-label text-muted-foreground">{props.label}</p>
|
||||
<p class="display mt-2 text-3xl">{props.value}</p>
|
||||
<Show when={props.hint}>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{props.hint}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="flex size-8 items-center justify-center border border-line bg-background text-muted-foreground">
|
||||
<Icon class="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
type Tab = 'overview' | 'rooms' | 'permissions' | 'system'
|
||||
|
||||
export const Manage: Component = () => {
|
||||
const [tab, setTab] = createSignal<Tab>('overview')
|
||||
|
|
@ -113,807 +21,60 @@ export const Manage: Component = () => {
|
|||
const [permDefs, setPermDefs] = createSignal<any>(null)
|
||||
const [selectedRoom, setSelectedRoom] = createSignal<string>('')
|
||||
const [members, setMembers] = createSignal<any[]>([])
|
||||
const [users, setUsers] = createSignal<any[]>([])
|
||||
const [err, setErr] = createSignal('')
|
||||
const [loading, setLoading] = createSignal(false)
|
||||
|
||||
// rooms create form
|
||||
const [newRoomName, setNewRoomName] = createSignal('')
|
||||
const [newRoomDesc, setNewRoomDesc] = createSignal('')
|
||||
const [newRoomPublic, setNewRoomPublic] = createSignal(true)
|
||||
const [newRoomMax, setNewRoomMax] = createSignal('100')
|
||||
const [newRoomPwd, setNewRoomPwd] = createSignal('')
|
||||
const [showPwd, setShowPwd] = createSignal(false)
|
||||
|
||||
// room detail edit
|
||||
const [editingRoom, setEditingRoom] = createSignal<any>(null)
|
||||
const [editDesc, setEditDesc] = createSignal('')
|
||||
const [editMax, setEditMax] = createSignal('')
|
||||
|
||||
// settings panel per room
|
||||
const [roomSettings, setRoomSettings] = createSignal<any>(null)
|
||||
|
||||
const refreshOverview = async () => {
|
||||
try {
|
||||
const o = await api.manageOverview()
|
||||
setOverview(o)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
const refreshRooms = async () => {
|
||||
try {
|
||||
const r = await api.manageRooms()
|
||||
setRooms(r.rooms || [])
|
||||
if (!selectedRoom() && r.rooms?.length) setSelectedRoom(r.rooms[0].room.name)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
const refreshPerms = async () => {
|
||||
try {
|
||||
const p = await api.managePermMatrix()
|
||||
setPermDefs(p)
|
||||
} catch {}
|
||||
}
|
||||
const refreshUsers = async () => {
|
||||
try {
|
||||
const u = await api.listUsers()
|
||||
setUsers(u.users || [])
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
const refreshOverview = async () => { try { const o = await api.manageOverview(); setOverview(o) } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
const refreshRooms = async () => { try { const r = await api.manageRooms(); setRooms(r.rooms || []); if (!selectedRoom() && r.rooms?.length) setSelectedRoom(r.rooms[0].room.name) } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
const refreshPerms = async () => { try { const p = await api.managePermMatrix(); setPermDefs(p) } catch {} }
|
||||
const refreshMembers = async (room: string) => {
|
||||
if (!room) return
|
||||
try {
|
||||
const m = await api.manageMembers(room)
|
||||
setMembers(m.members || [])
|
||||
const det = await api.manageRoomDetail(room)
|
||||
setEditingRoom(det.room)
|
||||
setEditDesc(det.room.description || '')
|
||||
setEditMax(String(det.room.max_members || 100))
|
||||
setRoomSettings(det.room.settings || null)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
try { const m = await api.manageMembers(room); setMembers(m.members || []); const det = await api.manageRoomDetail(room); setEditingRoom(det.room); setEditDesc(det.room.description || ''); setEditMax(String(det.room.max_members || 100)); setRoomSettings(det.room.settings || null) } catch (e: any) { setErr(String(e?.message || e)) }
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
refreshOverview()
|
||||
refreshRooms()
|
||||
refreshPerms()
|
||||
refreshUsers()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const r = selectedRoom()
|
||||
if (r && (tab() === 'rooms' || tab() === 'permissions')) refreshMembers(r)
|
||||
})
|
||||
createEffect(() => { refreshOverview(); refreshRooms(); refreshPerms() })
|
||||
createEffect(() => { const r = selectedRoom(); if (r && (tab() === 'rooms' || tab() === 'permissions')) refreshMembers(r) })
|
||||
|
||||
const createRoom = async () => {
|
||||
const name = newRoomName().trim()
|
||||
if (!name) return
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.manageCreateRoom({
|
||||
name,
|
||||
description: newRoomDesc().trim() || undefined,
|
||||
is_public: newRoomPublic(),
|
||||
max_members: parseInt(newRoomMax()) || 100,
|
||||
password: newRoomPwd().trim() || undefined,
|
||||
})
|
||||
setNewRoomName('')
|
||||
setNewRoomDesc('')
|
||||
setNewRoomPwd('')
|
||||
await refreshRooms()
|
||||
await refreshOverview()
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
const name = newRoomName().trim(); if (!name) return; setLoading(true)
|
||||
try { await api.manageCreateRoom({ name, description: newRoomDesc().trim() || undefined, is_public: newRoomPublic(), max_members: parseInt(newRoomMax()) || 100, password: newRoomPwd().trim() || undefined }); setNewRoomName(''); setNewRoomDesc(''); setNewRoomPwd(''); await refreshRooms(); await refreshOverview() } catch (e: any) { setErr(String(e?.message || e)) } finally { setLoading(false) }
|
||||
}
|
||||
|
||||
const saveRoom = async () => {
|
||||
const r = selectedRoom()
|
||||
if (!r || !editingRoom()) return
|
||||
try {
|
||||
await api.manageUpdateRoom(r, {
|
||||
description: editDesc(),
|
||||
max_members: parseInt(editMax()) || undefined,
|
||||
is_public: editingRoom().is_public,
|
||||
})
|
||||
await refreshRooms()
|
||||
await refreshMembers(r)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const delRoom = async (name: string) => {
|
||||
if (!confirm(`确定删除房间 ${name} ?该操作不可恢复。`)) return
|
||||
try {
|
||||
await api.manageDeleteRoom(name)
|
||||
await refreshRooms()
|
||||
await refreshOverview()
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const changeRole = async (room: string, username: string, role: string) => {
|
||||
try {
|
||||
await api.manageUpdateMemberRole(room, username, role)
|
||||
await refreshMembers(room)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
const kick = async (room: string, username: string) => {
|
||||
if (!confirm(`踢出 ${username} ?`)) return
|
||||
try {
|
||||
await api.manageKickMember(room, username)
|
||||
await refreshMembers(room)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const saveRoom = async () => { const r = selectedRoom(); if (!r || !editingRoom()) return; try { await api.manageUpdateRoom(r, { description: editDesc(), max_members: parseInt(editMax()) || undefined, is_public: editingRoom().is_public }); await refreshRooms(); await refreshMembers(r) } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
const delRoom = async (name: string) => { if (!confirm(`删除房间 ${name} ?`)) return; try { await api.manageDeleteRoom(name); await refreshRooms(); await refreshOverview() } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
const changeRole = async (room: string, username: string, role: string) => { try { await api.manageUpdateMemberRole(room, username, role); await refreshMembers(room) } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
const kick = async (room: string, username: string) => { if (!confirm(`踢出 ${username} ?`)) return; try { await api.manageKickMember(room, username); await refreshMembers(room) } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
const togglePerm = async (room: string, user: string, perm: string, had: boolean) => {
|
||||
const bit = 1 << ALL_PERMS.indexOf(perm as any)
|
||||
// simple: if had then move to removed, else to added
|
||||
// For demo we recompute added/removed based on member's current
|
||||
const m = members().find((x) => x.username === user)
|
||||
if (!m) return
|
||||
let added = Number(m.added || 0)
|
||||
let removed = Number(m.removed || 0)
|
||||
if (had) {
|
||||
added &= ~bit
|
||||
removed |= bit
|
||||
} else {
|
||||
added |= bit
|
||||
removed &= ~bit
|
||||
}
|
||||
try {
|
||||
await api.manageUpdateMemberPerms(room, user, added, removed, false)
|
||||
await refreshMembers(room)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
const r = selectedRoom()
|
||||
if (!r || !roomSettings()) return
|
||||
try {
|
||||
await api.manageUpdateRoomSettings(r, roomSettings())
|
||||
await refreshMembers(r)
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
const bit = 1 << ALL_PERMS.indexOf(perm as any); const m = members().find((x) => x.username === user); if (!m) return
|
||||
let added = Number(m.added || 0), removed = Number(m.removed || 0)
|
||||
if (had) { added &= ~bit; removed |= bit } else { added |= bit; removed &= ~bit }
|
||||
try { await api.manageUpdateMemberPerms(room, user, added, removed, false); await refreshMembers(room) } catch (e: any) { setErr(String(e?.message || e)) }
|
||||
}
|
||||
const saveSettings = async () => { const r = selectedRoom(); if (!r || !roomSettings()) return; try { await api.manageUpdateRoomSettings(r, roomSettings()); await refreshMembers(r) } catch (e: any) { setErr(String(e?.message || e)) } }
|
||||
|
||||
return (
|
||||
<div class="mx-auto max-w-[1400px] space-y-6 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<Reveal>
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="mono-label text-muted-foreground">manage / console</p>
|
||||
<h1 class="display mt-2 text-4xl sm:text-5xl">管理控制台</h1>
|
||||
<p class="mt-3 max-w-2xl text-sm text-muted-foreground">
|
||||
统一管理房间、角色与权限。参考 <span class="font-mono text-acid-cyan">SyncTV</span> 的位掩码与 Allow/Deny 覆盖模型,
|
||||
房主拥有全部权限,管理员/成员/访客基于角色默认值 + 个人覆盖计算生效权限。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="outline" class="gap-1.5">
|
||||
<ShieldCheck class="size-3.5" />
|
||||
{auth.user()?.username || 'guest'} · {permissions.role()}
|
||||
</Badge>
|
||||
<Button variant="ghost" class="h-9 gap-1.5" onClick={() => { refreshOverview(); refreshRooms(); refreshUsers(); refreshPerms(); if (selectedRoom()) refreshMembers(selectedRoom()) }}>
|
||||
<RefreshCw class="size-4" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Reveal>
|
||||
|
||||
<Show when={err()}>
|
||||
<Alert tone="error" class="flex items-center justify-between gap-3">
|
||||
<span class="flex items-center gap-2">
|
||||
<ShieldAlert class="size-4" />
|
||||
{err()}
|
||||
</span>
|
||||
<button class="text-xs underline" onClick={() => setErr('')}>
|
||||
关闭
|
||||
</button>
|
||||
</Alert>
|
||||
</Show>
|
||||
|
||||
<Reveal delay={1}>
|
||||
<Segmented
|
||||
value={tab()}
|
||||
onChange={setTab as any}
|
||||
options={[
|
||||
{ value: 'overview', label: '概览', icon: <Layers class="size-3.5" /> },
|
||||
{ value: 'rooms', label: '房间', icon: <RadioTower class="size-3.5" /> },
|
||||
{ value: 'permissions', label: '权限', icon: <KeyRound class="size-3.5" /> },
|
||||
{ value: 'users', label: '用户', icon: <UsersIcon class="size-3.5" /> },
|
||||
{ value: 'system', label: '系统', icon: <Settings class="size-3.5" /> },
|
||||
]}
|
||||
/>
|
||||
</Reveal>
|
||||
|
||||
{/* Overview */}
|
||||
<Show when={tab() === 'overview'}>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="房间总数" value={overview()?.rooms_total ?? '-'} hint={`${overview()?.rooms_live ?? 0} 个正在直播`} icon={Layers} />
|
||||
<StatCard label="在线房间" value={overview()?.rooms_live ?? '-'} hint="有推流目标的房间" icon={Video} />
|
||||
<StatCard label="房间成员" value={overview()?.members_total ?? '-'} hint="所有房间成员汇总" icon={UsersIcon} />
|
||||
<StatCard label="系统用户" value={overview()?.users_total ?? '-'} hint="已注册账号" icon={ShieldCheck} />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="border border-line bg-card/50 p-4 lg:col-span-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="font-semibold">房间分发状态</h3>
|
||||
<span class="mono-label text-muted-foreground">{rooms().length} rooms</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<For each={rooms().slice(0, 6)}>
|
||||
{(r) => (
|
||||
<div class="flex items-center justify-between gap-3 border border-line/60 bg-background/50 px-3 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<LiveDot active={r.live} />
|
||||
<span class="truncate font-mono text-sm">{r.room.name}</span>
|
||||
<Badge variant={r.live ? 'success' : 'muted'} class="hidden sm:inline-flex">
|
||||
{r.live ? 'LIVE' : 'IDLE'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span class="hidden sm:inline">{r.member_count} 成员</span>
|
||||
<span>{r.targets?.length ?? 0} targets</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={rooms().length === 0}>
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">暂无房间数据</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-line bg-card/50 p-4">
|
||||
<h3 class="font-semibold">后端能力</h3>
|
||||
<div class="mt-4 space-y-2">
|
||||
<For each={overview()?.backends || []}>
|
||||
{(b: any) => (
|
||||
<div class="flex items-center justify-between border border-line/60 px-3 py-2.5">
|
||||
<span class="font-mono text-xs uppercase">{b.name || b.kind}</span>
|
||||
<Badge variant={b.configured ? 'success' : 'muted'}>{b.configured ? '已配置' : '未配置'}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="mt-6 rounded border border-acid-lime/20 bg-acid-lime/5 p-3">
|
||||
<p class="micro-label text-muted-foreground">权限模型</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
角色默认值来自 <span class="font-mono text-acid-cyan">RoomSettings</span>,个人覆盖分
|
||||
<span class="font-mono text-foreground"> added / removed </span>
|
||||
且访客仅可分配 Guest 可用位。生效权限 = base | added & ~removed。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Rooms */}
|
||||
<ManagePage>
|
||||
<ManageHeader
|
||||
title="管理控制台"
|
||||
description="统一管理房间、角色与权限。房主全量,管理员/成员/访客基于角色默认值 + 个人覆盖计算生效权限。"
|
||||
actions={<><Badge variant="outline" class="gap-1.5"><ShieldCheck class="size-3.5" />{auth.user()?.username || 'guest'} · {permissions.role()}</Badge><Button variant="ghost" class="h-9 gap-1.5" onClick={() => { refreshOverview(); refreshRooms(); if (selectedRoom()) refreshMembers(selectedRoom()) }}><RefreshCw class="size-4" />刷新</Button></>}
|
||||
/>
|
||||
<Show when={err()}><Alert tone="error" class="flex items-center justify-between gap-3"><span class="flex items-center gap-2"><ShieldAlert class="size-4" />{err()}</span><button class="text-xs underline" onClick={() => setErr('')}>关闭</button></Alert></Show>
|
||||
<ManageNav value={tab()} onChange={setTab as any} />
|
||||
<Show when={tab() === 'overview'}><ManageOverview overview={overview()} rooms={rooms()} /></Show>
|
||||
<Show when={tab() === 'rooms'}>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-1">
|
||||
<div class="relative border border-line bg-card/60 p-4">
|
||||
<Corners />
|
||||
<h3 class="font-semibold">创建房间</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">房主将自动成为 Creator,拥有全部权限。</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">房间名 *</label>
|
||||
<div class="flex items-center gap-2 border border-line bg-background px-3 py-2 focus-within:border-ring">
|
||||
<Hash class="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full bg-transparent font-mono text-sm outline-none placeholder:text-muted-foreground/50"
|
||||
placeholder="如 live-101"
|
||||
value={newRoomName()}
|
||||
onInput={(e) => setNewRoomName((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">描述</label>
|
||||
<input
|
||||
class="field"
|
||||
placeholder="一句话介绍"
|
||||
value={newRoomDesc()}
|
||||
onInput={(e) => setNewRoomDesc((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">可见性</label>
|
||||
<Select
|
||||
value={newRoomPublic() ? 'public' : 'private'}
|
||||
onChange={(v) => setNewRoomPublic(v === 'public')}
|
||||
options={[
|
||||
{ value: 'public', label: '公开' },
|
||||
{ value: 'private', label: '私密' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">人数上限</label>
|
||||
<input class="field field-mono" value={newRoomMax()} onInput={(e) => setNewRoomMax((e.currentTarget as HTMLInputElement).value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">房间密码(可选)</label>
|
||||
<div class="flex items-center gap-2 border border-line bg-background px-3 py-2 focus-within:border-ring">
|
||||
<Lock class="size-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full bg-transparent font-mono text-sm outline-none"
|
||||
type={showPwd() ? 'text' : 'password'}
|
||||
placeholder="留空为无密码"
|
||||
value={newRoomPwd()}
|
||||
onInput={(e) => setNewRoomPwd((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<button type="button" class="text-muted-foreground" onClick={() => setShowPwd((v) => !v)}>
|
||||
<Show when={showPwd()} fallback={<Eye class="size-4" />}>
|
||||
<EyeOff class="size-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="w-full" onClick={createRoom} disabled={loading() || !newRoomName().trim()}>
|
||||
<Plus class="size-4" />
|
||||
创建房间
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 border border-line bg-card/50">
|
||||
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
|
||||
<h3 class="font-semibold">房间列表</h3>
|
||||
<span class="mono-label text-muted-foreground">{rooms().length}</span>
|
||||
</div>
|
||||
<div class="max-h-[480px] overflow-auto">
|
||||
<For each={rooms()}>
|
||||
{(r) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex w-full items-center justify-between gap-3 border-b border-line/60 px-4 py-3 text-left transition-colors last:border-0 hover:bg-accent/40 ${selectedRoom() === r.room.name ? 'bg-acid-lime/10 dark:bg-primary/10' : ''}`}
|
||||
onClick={() => setSelectedRoom(r.room.name)}
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-sm font-medium">{r.room.name}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{r.room.description || '—'}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||
<LiveDot active={r.live} />
|
||||
<span class="micro-label text-muted-foreground">{r.member_count} 成员</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
<Show when={rooms().length === 0}>
|
||||
<p class="px-4 py-8 text-center text-sm text-muted-foreground">暂无房间</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4 lg:col-span-2">
|
||||
<Show when={selectedRoom()} fallback={<div class="border border-dashed border-line p-8 text-center text-sm text-muted-foreground">选择一个房间以管理</div>}>
|
||||
<div class="relative border border-line bg-card/60 p-4">
|
||||
<Corners />
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="mono-label text-muted-foreground">room / {selectedRoom()}</p>
|
||||
<h3 class="display mt-1 truncate text-2xl">{editingRoom()?.display_name || selectedRoom()}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">创建者 {editingRoom()?.creator || '—'} · {new Date((editingRoom()?.created_at || 0) * 1000).toLocaleString() || ''}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Badge variant={editingRoom()?.is_public ? 'success' : 'warning'} class="gap-1">
|
||||
<Show when={editingRoom()?.is_public} fallback={<Lock class="size-3" />}>
|
||||
<Globe class="size-3" />
|
||||
</Show>
|
||||
{editingRoom()?.is_public ? '公开' : '私密'}
|
||||
</Badge>
|
||||
<Badge variant="outline">{editingRoom()?.status || 'active'}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">描述</label>
|
||||
<input class="field" value={editDesc()} onInput={(e) => setEditDesc((e.currentTarget as HTMLInputElement).value)} placeholder="房间描述" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">人数上限</label>
|
||||
<input class="field field-mono" value={editMax()} onInput={(e) => setEditMax((e.currentTarget as HTMLInputElement).value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button class="gap-1.5" onClick={saveRoom}>
|
||||
<Save class="size-4" />
|
||||
保存
|
||||
</Button>
|
||||
<Button variant="danger" class="gap-1.5" onClick={() => delRoom(selectedRoom())}>
|
||||
<Trash2 class="size-4" />
|
||||
删除房间
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
|
||||
<h4 class="flex items-center gap-2 font-semibold">
|
||||
<UsersIcon class="size-4" />
|
||||
成员 {members().length}
|
||||
</h4>
|
||||
<span class="micro-label text-muted-foreground">点击角色可切换 · 权限可在“权限”页批量编辑</span>
|
||||
</div>
|
||||
<div class="divide-y divide-line/60">
|
||||
<For each={members()}>
|
||||
{(m) => (
|
||||
<div class="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span class={`flex size-8 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[m.role]?.color || ''}`}>
|
||||
{(m.username[0] || '?').toUpperCase()}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-mono text-sm font-medium">{m.username}</p>
|
||||
<p class="micro-label truncate text-muted-foreground">{m.effective_permissions?.join(', ') || ''}</p>
|
||||
</div>
|
||||
<Select
|
||||
class="w-28"
|
||||
value={m.role}
|
||||
onChange={(v) => changeRole(selectedRoom(), m.username, v)}
|
||||
options={[
|
||||
{ value: 'creator', label: '房主' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'member', label: '成员' },
|
||||
{ value: 'guest', label: '访客' },
|
||||
]}
|
||||
/>
|
||||
<Badge class={ROLE_META[m.role]?.color}>{ROLE_META[m.role]?.label || m.role}</Badge>
|
||||
<Show when={m.role !== 'creator'}>
|
||||
<Button variant="ghost" class="h-8 px-2 text-destructive hover:text-destructive" onClick={() => kick(selectedRoom(), m.username)}>
|
||||
<Ban class="size-4" />
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={members().length === 0}>
|
||||
<p class="px-4 py-6 text-center text-sm text-muted-foreground">暂无成员</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-line bg-card/50 p-4">
|
||||
<h4 class="flex items-center gap-2 font-semibold">
|
||||
<SlidersHorizontal class="size-4" />
|
||||
房间设置
|
||||
</h4>
|
||||
<Show when={roomSettings()} fallback={<p class="mt-3 text-sm text-muted-foreground">加载中…</p>}>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<MessageSquare class="size-4 text-muted-foreground" />
|
||||
聊天启用
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!roomSettings()?.chat_enabled}
|
||||
onInput={(e) => setRoomSettings({ ...roomSettings(), chat_enabled: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<UserRound class="size-4 text-muted-foreground" />
|
||||
允许访客加入
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!roomSettings()?.allow_guest_join}
|
||||
onInput={(e) => setRoomSettings({ ...roomSettings(), allow_guest_join: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<ShieldAlert class="size-4 text-muted-foreground" />
|
||||
需审批入房
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!roomSettings()?.require_approval}
|
||||
onInput={(e) => setRoomSettings({ ...roomSettings(), require_approval: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<Globe class="size-4 text-muted-foreground" />
|
||||
公开可见
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!roomSettings()?.is_public}
|
||||
onInput={(e) => setRoomSettings({ ...roomSettings(), is_public: (e.currentTarget as HTMLInputElement).checked })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<Button class="mt-4 gap-1.5" onClick={saveSettings}>
|
||||
<Save class="size-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<ManageRooms rooms={rooms()} selectedRoom={selectedRoom()} onSelectRoom={setSelectedRoom} members={members()} editingRoom={editingRoom()} roomSettings={roomSettings()} setRoomSettings={setRoomSettings as any} newRoom={{ name: newRoomName(), desc: newRoomDesc(), isPublic: newRoomPublic(), max: newRoomMax(), pwd: newRoomPwd(), showPwd: showPwd() }} setNewRoom={(p) => { if (p.name !== undefined) setNewRoomName(p.name); if (p.desc !== undefined) setNewRoomDesc(p.desc); if (p.isPublic !== undefined) setNewRoomPublic(p.isPublic); if (p.max !== undefined) setNewRoomMax(p.max); if (p.pwd !== undefined) setNewRoomPwd(p.pwd); if (p.showPwd !== undefined) setShowPwd(p.showPwd) }} edit={{ desc: editDesc(), max: editMax() }} setEdit={(p) => { if (p.desc !== undefined) setEditDesc(p.desc); if (p.max !== undefined) setEditMax(p.max) }} loading={loading()} onCreate={createRoom} onSave={saveRoom} onDelete={delRoom} onChangeRole={changeRole} onKick={kick} onSaveSettings={saveSettings} />
|
||||
</Show>
|
||||
|
||||
{/* Permissions */}
|
||||
<Show when={tab() === 'permissions'}>
|
||||
<div class="space-y-4">
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<h3 class="font-semibold">角色默认权限(参考 SyncTV)</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Creator = 全部 · Admin 缺 delete_room · Member 为发布订阅开箱即用 · Guest 仅只读,额外仅可被授予 send_chat。</p>
|
||||
<div class="mt-4 overflow-auto">
|
||||
<table class="w-full min-w-[720px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-line">
|
||||
<th class="px-2 py-2 text-left font-mono text-muted-foreground">permission</th>
|
||||
<For each={['creator', 'admin', 'member', 'guest']}>
|
||||
{(role) => <th class="px-2 py-2 text-center font-mono text-muted-foreground">{role}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={ALL_PERMS}>
|
||||
{(perm) => (
|
||||
<tr class="border-b border-line/60">
|
||||
<td class="px-2 py-2 font-mono">{PERM_LABEL[perm] || perm}</td>
|
||||
<For each={['creator', 'admin', 'member', 'guest']}>
|
||||
{(role) => {
|
||||
const has = permDefs()?.roles?.[role]?.perms?.includes(perm)
|
||||
return <td class="px-2 py-2 text-center">{has ? <span class="inline-flex size-2 bg-acid-lime" /> : <span class="inline-flex size-2 border border-line" />}</td>
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line/80 px-4 py-3">
|
||||
<h3 class="font-semibold">成员生效权限 · {selectedRoom() || '未选择房间'}</h3>
|
||||
<Select
|
||||
value={selectedRoom()}
|
||||
onChange={setSelectedRoom}
|
||||
options={rooms().map((r) => ({ value: r.room.name, label: r.room.name }))}
|
||||
class="w-48"
|
||||
/>
|
||||
</div>
|
||||
<Show when={selectedRoom()} fallback={<p class="px-4 py-8 text-center text-sm text-muted-foreground">请选择房间</p>}>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full min-w-[900px] border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-muted/20">
|
||||
<th class="px-3 py-2 text-left text-xs font-mono text-muted-foreground">成员 / 角色</th>
|
||||
<For each={ALL_PERMS}>
|
||||
{(perm) => <th class="px-1 py-2 text-center text-[10px] font-mono text-muted-foreground">{PERM_LABEL[perm] || perm}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={members()}>
|
||||
{(m) => (
|
||||
<tr class="border-b border-line/40">
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs font-medium">{m.username}</span>
|
||||
<Badge class={`text-[10px] ${ROLE_META[m.role]?.color || ''}`}>{m.role}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<For each={ALL_PERMS}>
|
||||
{(perm) => {
|
||||
const has = m.effective_permissions?.includes(perm)
|
||||
const isCreator = m.role === 'creator'
|
||||
return (
|
||||
<td class="px-1 py-1 text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreator}
|
||||
class={`inline-flex size-6 items-center justify-center border text-[10px] transition-colors ${has ? 'border-acid-lime bg-acid-lime text-black' : 'border-line bg-background text-muted-foreground hover:border-ring'} ${isCreator ? 'opacity-50' : ''}`}
|
||||
title={`${m.username} · ${perm} · ${has ? '已授予' : '未授予'}`}
|
||||
onClick={() => togglePerm(selectedRoom(), m.username, perm, !!has)}
|
||||
>
|
||||
{has ? '✓' : ''}
|
||||
</button>
|
||||
</td>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={members().length === 0}>
|
||||
<p class="px-4 py-6 text-center text-sm text-muted-foreground">该房间暂无成员</p>
|
||||
</Show>
|
||||
</div>
|
||||
<p class="px-4 py-3 text-xs text-muted-foreground">点击格子切换该成员的 added/removed 覆盖位。房主始终拥有全部权限,不可修改。</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Users */}
|
||||
<Show when={tab() === 'users'}>
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<UsersIcon class="size-4" />
|
||||
系统用户与全局角色
|
||||
</h3>
|
||||
<span class="mono-label text-muted-foreground">{users().length} users</span>
|
||||
</div>
|
||||
<Show when={!permissions.can('user:list')} fallback={
|
||||
<div>
|
||||
<div class="hidden grid-cols-[minmax(0,1fr)_8rem_10rem] gap-4 border-b border-line/80 px-5 py-3 sm:grid">
|
||||
<span class="micro-label text-muted-foreground">username</span>
|
||||
<span class="micro-label text-muted-foreground">global role</span>
|
||||
<span class="micro-label text-right text-muted-foreground">切换角色</span>
|
||||
</div>
|
||||
<For each={users()}>
|
||||
{(u) => (
|
||||
<div class="grid grid-cols-1 items-center gap-3 px-5 py-4 sm:grid-cols-[minmax(0,1fr)_8rem_10rem] sm:gap-4 border-b border-line/60 last:border-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={`flex size-8 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[u.role]?.color || 'border-line bg-muted'}`}>
|
||||
{(u.username[0] || '?').toUpperCase()}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-sm font-medium">{u.username}</p>
|
||||
<p class="micro-label text-muted-foreground">{new Date((u.created_at || 0) * 1000).toLocaleDateString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge class={ROLE_META[u.role]?.color || ''}>{u.role}</Badge>
|
||||
<div class="flex justify-end">
|
||||
<Select
|
||||
value={u.role}
|
||||
onChange={async (v) => {
|
||||
try {
|
||||
await api.updateUserRole(u.username, v as any)
|
||||
setUsers((p) => p.map((x) => (x.username === u.username ? { ...x, role: v } : x)))
|
||||
} catch (e: any) {
|
||||
setErr(String(e?.message || e))
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: 'viewer', label: 'viewer' },
|
||||
{ value: 'publisher', label: 'publisher' },
|
||||
{ value: 'admin', label: 'admin' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={users().length === 0}>
|
||||
<p class="px-5 py-8 text-sm text-muted-foreground">暂无用户</p>
|
||||
</Show>
|
||||
</div>
|
||||
}>
|
||||
<Alert tone="warning" class="m-4">
|
||||
无权限查看:需要 user:list(管理员)
|
||||
</Alert>
|
||||
</Show>
|
||||
</div>
|
||||
<Alert tone="info">
|
||||
<span class="text-xs leading-relaxed">
|
||||
全局角色(viewer/publisher/admin)由 Casbin 控制接口准入;房间内角色(creator/admin/member/guest)由房间位掩码控制具体动作。二者正交:全局 admin 可管理所有房间,但房间内权限仍以 effective 计算结果为准。
|
||||
</span>
|
||||
</Alert>
|
||||
</Show>
|
||||
|
||||
{/* System */}
|
||||
<Show when={tab() === 'system'}>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="relative border border-line bg-card/60 p-4">
|
||||
<Corners />
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<Settings class="size-4" />
|
||||
系统与 SFU
|
||||
</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
|
||||
<span class="text-muted-foreground">HTTP 端口</span>
|
||||
<span class="font-mono">{overview()?.backends ? '8088' : '-'}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
|
||||
<span class="text-muted-foreground">gRPC</span>
|
||||
<span class="font-mono">9090</span>
|
||||
</div>
|
||||
<For each={overview()?.backends || []}>
|
||||
{(b: any) => (
|
||||
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2">
|
||||
<RadioTower class="size-4 text-muted-foreground" />
|
||||
{b.name || b.kind}
|
||||
</span>
|
||||
<Badge variant={b.configured ? 'success' : 'muted'}>{b.configured ? '已配置' : '未配置'}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<KeyRound class="size-4" />
|
||||
安全与默认策略
|
||||
</h3>
|
||||
<div class="mt-4 space-y-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<p>
|
||||
<span class="font-mono text-foreground">JWT_SECRET</span> 必须显式配置;推流 token 使用
|
||||
<span class="font-mono text-foreground"> HS256 </span>
|
||||
签发,TTL 默认 2h。
|
||||
</p>
|
||||
<p>
|
||||
私密房间支持密码;公开房间受 <span class="font-mono text-foreground">is_public</span> 与{' '}
|
||||
<span class="font-mono text-foreground">allow_guest_join</span> 联动控制访客可见性。
|
||||
</p>
|
||||
<p>
|
||||
权限存储为 <span class="font-mono text-foreground">u64 bits</span>(兼容 Postgres BIGINT),
|
||||
访客可分配集合受 <span class="font-mono text-foreground">guest_assignable</span> 约束。
|
||||
</p>
|
||||
<div class="mt-3 border border-dashed border-line bg-background px-3 py-3">
|
||||
<p class="micro-label">设计参考</p>
|
||||
<p class="mt-1">
|
||||
本系统在 <code>internal/room</code> 实现 SyncTV 风格的四角色(creator/admin/member/guest)与四列覆盖位
|
||||
(added/removed/admin_added/admin_removed),并通过 RoomSettings 维护角色默认值,可平滑扩展至更多权限位而不改表结构。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-line bg-card/50 p-4">
|
||||
<h3 class="font-semibold">快捷操作</h3>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setTab('rooms')}>
|
||||
<Plus class="size-4" />
|
||||
去创建房间
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setTab('permissions')}>
|
||||
<KeyRound class="size-4" />
|
||||
查看权限矩阵
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => window.open('/api/config', '_blank')}>
|
||||
<Globe class="size-4" />
|
||||
查看 /api/config
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => { localStorage.clear(); location.reload() }}>
|
||||
<LogOut class="size-4" />
|
||||
清理本地缓存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={tab() === 'permissions'}><ManagePermissions rooms={rooms()} selectedRoom={selectedRoom()} onSelectRoom={setSelectedRoom} members={members()} permDefs={permDefs()} onTogglePerm={togglePerm} /></Show>
|
||||
<Show when={tab() === 'system'}><ManageSystem overview={overview()} /></Show>
|
||||
</ManagePage>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { For, Show } from 'solid-js'
|
||||
import { Layers, Video } from 'lucide-solid'
|
||||
import { ShieldCheck, RadioTower, Users as UsersIcon } from 'lucide-solid'
|
||||
import { Badge, LiveDot } from '../ui'
|
||||
|
||||
function StatCard(props: { label: string; value: string | number; hint?: string; icon: any }) {
|
||||
const Icon = props.icon
|
||||
return (
|
||||
<div class="relative border border-line bg-card/60 p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="micro-label text-muted-foreground">{props.label}</p>
|
||||
<p class="display mt-2 text-3xl">{props.value}</p>
|
||||
<Show when={props.hint}>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{props.hint}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<span class="flex size-8 items-center justify-center border border-line bg-background text-muted-foreground">
|
||||
<Icon class="size-4" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManageOverview(props: { overview: any; rooms: any[] }) {
|
||||
const overview = () => props.overview
|
||||
const rooms = () => props.rooms
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard label="房间总数" value={overview()?.rooms_total ?? '-'} hint={`${overview()?.rooms_live ?? 0} 个正在直播`} icon={Layers} />
|
||||
<StatCard label="在线房间" value={overview()?.rooms_live ?? '-'} hint="有推流目标的房间" icon={Video} />
|
||||
<StatCard label="房间成员" value={overview()?.members_total ?? '-'} hint="所有房间成员汇总" icon={UsersIcon} />
|
||||
<StatCard label="系统用户" value={overview()?.users_total ?? '-'} hint="已注册账号" icon={ShieldCheck} />
|
||||
</div>
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="border border-line bg-card/50 p-4 lg:col-span-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="font-semibold">房间分发状态</h3>
|
||||
<span class="mono-label text-muted-foreground">{rooms().length} rooms</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2">
|
||||
<For each={rooms().slice(0, 6)}>
|
||||
{(r) => (
|
||||
<div class="flex items-center justify-between gap-3 border border-line/60 bg-background/50 px-3 py-2.5">
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<LiveDot active={r.live} />
|
||||
<span class="truncate font-mono text-sm">{r.room.name}</span>
|
||||
<Badge variant={r.live ? 'success' : 'muted'} class="hidden sm:inline-flex">
|
||||
{r.live ? 'LIVE' : 'IDLE'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span class="hidden sm:inline">{r.member_count} 成员</span>
|
||||
<span>{r.targets?.length ?? 0} targets</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Show when={rooms().length === 0}>
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">暂无房间数据</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/50 p-4">
|
||||
<h3 class="font-semibold">后端能力</h3>
|
||||
<div class="mt-4 space-y-2">
|
||||
<For each={overview()?.backends || []}>
|
||||
{(b: any) => (
|
||||
<div class="flex items-center justify-between border border-line/60 px-3 py-2.5">
|
||||
<span class="font-mono text-xs uppercase">{b.name || b.kind}</span>
|
||||
<Badge variant={b.configured ? 'success' : 'muted'}>{b.configured ? '已配置' : '未配置'}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { For, Show } from 'solid-js'
|
||||
import { Badge, Select } from '../../components/ui'
|
||||
import { ALL_PERMS, PERM_LABEL, ROLE_META } from './constants'
|
||||
|
||||
export function ManagePermissions(props: {
|
||||
rooms: any[]
|
||||
selectedRoom: string
|
||||
onSelectRoom: (v: string) => void
|
||||
members: any[]
|
||||
permDefs: any
|
||||
onTogglePerm: (room: string, user: string, perm: string, had: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<h3 class="font-semibold">角色默认权限</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">Creator 全量 · Guest 只读,个人覆盖 added/removed 生效。</p>
|
||||
<div class="mt-4 overflow-auto">
|
||||
<table class="w-full min-w-[520px] border-collapse text-xs">
|
||||
<thead>
|
||||
<tr class="border-b border-line">
|
||||
<th class="px-2 py-2 text-left font-mono text-muted-foreground">permission</th>
|
||||
<For each={['creator', 'admin', 'member', 'guest']}>
|
||||
{(role) => <th class="px-2 py-2 text-center font-mono text-muted-foreground">{role}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={ALL_PERMS}>
|
||||
{(perm) => (
|
||||
<tr class="border-b border-line/60">
|
||||
<td class="px-2 py-2 font-mono">{PERM_LABEL[perm] || perm}</td>
|
||||
<For each={['creator', 'admin', 'member', 'guest']}>
|
||||
{(role) => {
|
||||
const has = props.permDefs?.roles?.[role]?.perms?.includes(perm)
|
||||
return <td class="px-2 py-2 text-center">{has ? <span class="inline-flex size-2 bg-acid-lime" /> : <span class="inline-flex size-2 border border-line" />}</td>
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-line/80 px-4 py-3">
|
||||
<h3 class="font-semibold">成员生效权限 · {props.selectedRoom || '未选择'}</h3>
|
||||
<Select value={props.selectedRoom} onChange={props.onSelectRoom} options={props.rooms.map((r) => ({ value: r.room.name, label: r.room.name }))} class="w-48" />
|
||||
</div>
|
||||
<Show when={props.selectedRoom} fallback={<p class="px-4 py-8 text-center text-sm text-muted-foreground">请选择房间</p>}>
|
||||
<div class="overflow-auto">
|
||||
<table class="w-full min-w-[900px] border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-line bg-muted/20">
|
||||
<th class="px-3 py-2 text-left text-xs font-mono text-muted-foreground">成员 / 角色</th>
|
||||
<For each={ALL_PERMS}>
|
||||
{(perm) => <th class="px-1 py-2 text-center text-[10px] font-mono text-muted-foreground">{PERM_LABEL[perm] || perm}</th>}
|
||||
</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={props.members}>
|
||||
{(m) => (
|
||||
<tr class="border-b border-line/40">
|
||||
<td class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-mono text-xs font-medium">{m.username}</span>
|
||||
<Badge class={`text-[10px] ${ROLE_META[m.role]?.color || ''}`}>{m.role}</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<For each={ALL_PERMS}>
|
||||
{(perm) => {
|
||||
const has = m.effective_permissions?.includes(perm)
|
||||
const isCreator = m.role === 'creator'
|
||||
return (
|
||||
<td class="px-1 py-1 text-center">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreator}
|
||||
class={`inline-flex size-6 items-center justify-center border text-[10px] ${has ? 'border-acid-lime bg-acid-lime text-black' : 'border-line bg-background text-muted-foreground hover:border-ring'} ${isCreator ? 'opacity-50' : ''}`}
|
||||
onClick={() => props.onTogglePerm(props.selectedRoom, m.username, perm, !!has)}
|
||||
>
|
||||
{has ? '✓' : ''}
|
||||
</button>
|
||||
</td>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
import { For, Show } from 'solid-js'
|
||||
import { Badge, Button, LiveDot, Select } from '../../components/ui'
|
||||
import { Hash, Lock, Eye, EyeOff, Plus, Globe, Save, Trash2, Ban, MessageSquare, UserRound, ShieldAlert, SlidersHorizontal } from 'lucide-solid'
|
||||
import { ROLE_META } from './constants'
|
||||
|
||||
export function ManageRooms(props: {
|
||||
rooms: any[]
|
||||
selectedRoom: string
|
||||
onSelectRoom: (v: string) => void
|
||||
members: any[]
|
||||
editingRoom: any
|
||||
roomSettings: any
|
||||
setRoomSettings: (v: any) => void
|
||||
newRoom: { name: string; desc: string; isPublic: boolean; max: string; pwd: string; showPwd: boolean }
|
||||
setNewRoom: (p: Partial<{ name: string; desc: string; isPublic: boolean; max: string; pwd: string; showPwd: boolean }>) => void
|
||||
edit: { desc: string; max: string }
|
||||
setEdit: (p: Partial<{ desc: string; max: string }>) => void
|
||||
loading: boolean
|
||||
onCreate: () => void
|
||||
onSave: () => void
|
||||
onDelete: (name: string) => void
|
||||
onChangeRole: (room: string, user: string, role: string) => void
|
||||
onKick: (room: string, user: string) => void
|
||||
onSaveSettings: () => void
|
||||
}) {
|
||||
return (
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<h3 class="font-semibold">创建房间</h3>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">房间名 *</label>
|
||||
<div class="flex items-center gap-2 border border-line bg-background px-3 py-2 focus-within:border-ring">
|
||||
<Hash class="size-4 shrink-0 text-muted-foreground" />
|
||||
<input class="w-full bg-transparent font-mono text-sm outline-none" placeholder="如 live-101" value={props.newRoom.name} onInput={(e) => props.setNewRoom({ name: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">描述</label>
|
||||
<input class="field" placeholder="一句话介绍" value={props.newRoom.desc} onInput={(e) => props.setNewRoom({ desc: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">可见性</label>
|
||||
<Select value={props.newRoom.isPublic ? 'public' : 'private'} onChange={(v) => props.setNewRoom({ isPublic: v === 'public' })} options={[{ value: 'public', label: '公开' }, { value: 'private', label: '私密' }]} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">人数上限</label>
|
||||
<input class="field field-mono" value={props.newRoom.max} onInput={(e) => props.setNewRoom({ max: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">房间密码(可选)</label>
|
||||
<div class="flex items-center gap-2 border border-line bg-background px-3 py-2 focus-within:border-ring">
|
||||
<Lock class="size-4 shrink-0 text-muted-foreground" />
|
||||
<input class="w-full bg-transparent font-mono text-sm outline-none" type={props.newRoom.showPwd ? 'text' : 'password'} placeholder="留空为无密码" value={props.newRoom.pwd} onInput={(e) => props.setNewRoom({ pwd: (e.currentTarget as HTMLInputElement).value })} />
|
||||
<button type="button" class="text-muted-foreground" onClick={() => props.setNewRoom({ showPwd: !props.newRoom.showPwd })}>
|
||||
<Show when={props.newRoom.showPwd} fallback={<Eye class="size-4" />}>
|
||||
<EyeOff class="size-4" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button class="w-full" onClick={props.onCreate} disabled={props.loading || !props.newRoom.name.trim()}>
|
||||
<Plus class="size-4" />
|
||||
创建房间
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
|
||||
<h3 class="font-semibold">房间列表</h3>
|
||||
<span class="mono-label text-muted-foreground">{props.rooms.length}</span>
|
||||
</div>
|
||||
<div class="max-h-[480px] overflow-auto">
|
||||
<For each={props.rooms}>
|
||||
{(r) => (
|
||||
<button type="button" class={`flex w-full items-center justify-between gap-3 border-b border-line/60 px-4 py-3 text-left hover:bg-accent/40 ${props.selectedRoom === r.room.name ? 'bg-acid-lime/10' : ''}`} onClick={() => props.onSelectRoom(r.room.name)}>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-sm font-medium">{r.room.name}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">{r.room.description || '—'}</p>
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-col items-end gap-1">
|
||||
<LiveDot active={r.live} />
|
||||
<span class="micro-label text-muted-foreground">{r.member_count} 成员</span>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 lg:col-span-2">
|
||||
<Show when={props.selectedRoom} fallback={<div class="border border-dashed border-line p-8 text-center text-sm text-muted-foreground">选择一个房间以管理</div>}>
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="mono-label text-muted-foreground">room / {props.selectedRoom}</p>
|
||||
<h3 class="display mt-1 truncate text-2xl">{props.editingRoom?.display_name || props.selectedRoom}</h3>
|
||||
</div>
|
||||
<Badge variant={props.editingRoom?.is_public ? 'success' : 'warning'}>{props.editingRoom?.is_public ? '公开' : '私密'}</Badge>
|
||||
</div>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">描述</label>
|
||||
<input class="field" value={props.edit.desc} onInput={(e) => props.setEdit({ desc: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="label">人数上限</label>
|
||||
<input class="field field-mono" value={props.edit.max} onInput={(e) => props.setEdit({ max: (e.currentTarget as HTMLInputElement).value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button class="gap-1.5" onClick={props.onSave}>
|
||||
<Save class="size-4" />
|
||||
保存
|
||||
</Button>
|
||||
<Button variant="danger" class="gap-1.5" onClick={() => props.onDelete(props.selectedRoom)}>
|
||||
<Trash2 class="size-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
|
||||
<h4 class="font-semibold">成员 {props.members.length}</h4>
|
||||
</div>
|
||||
<div class="divide-y divide-line/60">
|
||||
<For each={props.members}>
|
||||
{(m) => (
|
||||
<div class="flex flex-wrap items-center gap-3 px-4 py-3">
|
||||
<span class={`flex size-8 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[m.role]?.color || ''}`}>{(m.username[0] || '?').toUpperCase()}</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate font-mono text-sm font-medium">{m.username}</p>
|
||||
<p class="micro-label truncate text-muted-foreground">{m.effective_permissions?.join(', ') || ''}</p>
|
||||
</div>
|
||||
<Select class="w-28" value={m.role} onChange={(v) => props.onChangeRole(props.selectedRoom, m.username, v)} options={[{ value: 'creator', label: '房主' }, { value: 'admin', label: '管理员' }, { value: 'member', label: '成员' }, { value: 'guest', label: '访客' }]} />
|
||||
<Show when={m.role !== 'creator'}>
|
||||
<Button variant="ghost" class="h-8 px-2 text-destructive" onClick={() => props.onKick(props.selectedRoom, m.username)}>
|
||||
<Ban class="size-4" />
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/50 p-4">
|
||||
<h4 class="flex items-center gap-2 font-semibold">
|
||||
<SlidersHorizontal class="size-4" />
|
||||
房间设置
|
||||
</h4>
|
||||
<Show when={props.roomSettings} fallback={<p class="mt-3 text-sm text-muted-foreground">加载中…</p>}>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<MessageSquare class="size-4 text-muted-foreground" /> 聊天
|
||||
</span>
|
||||
<input type="checkbox" checked={!!props.roomSettings?.chat_enabled} onInput={(e) => props.setRoomSettings({ ...props.roomSettings, chat_enabled: (e.currentTarget as HTMLInputElement).checked })} />
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<UserRound class="size-4 text-muted-foreground" /> 访客加入
|
||||
</span>
|
||||
<input type="checkbox" checked={!!props.roomSettings?.allow_guest_join} onInput={(e) => props.setRoomSettings({ ...props.roomSettings, allow_guest_join: (e.currentTarget as HTMLInputElement).checked })} />
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<ShieldAlert class="size-4 text-muted-foreground" /> 需审批
|
||||
</span>
|
||||
<input type="checkbox" checked={!!props.roomSettings?.require_approval} onInput={(e) => props.setRoomSettings({ ...props.roomSettings, require_approval: (e.currentTarget as HTMLInputElement).checked })} />
|
||||
</label>
|
||||
<label class="flex items-center justify-between gap-3 border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2 text-sm">
|
||||
<Globe class="size-4 text-muted-foreground" /> 公开
|
||||
</span>
|
||||
<input type="checkbox" checked={!!props.roomSettings?.is_public} onInput={(e) => props.setRoomSettings({ ...props.roomSettings, is_public: (e.currentTarget as HTMLInputElement).checked })} />
|
||||
</label>
|
||||
</div>
|
||||
<Button class="mt-4 gap-1.5" onClick={props.onSaveSettings}>
|
||||
<Save class="size-4" />
|
||||
保存设置
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import type { JSX } from 'solid-js'
|
||||
import { Show } from 'solid-js'
|
||||
|
||||
export function ManagePage(props: { children: JSX.Element; class?: string }) {
|
||||
return <div class={`mx-auto max-w-[1400px] space-y-6 px-4 py-8 sm:px-6 sm:py-10 ${props.class ?? ''}`}>{props.children}</div>
|
||||
}
|
||||
|
||||
export function ManageLoading(props: { label?: string }) {
|
||||
return (
|
||||
<div class="flex min-h-40 flex-1 items-center justify-center py-12" aria-busy="true" aria-live="polite">
|
||||
<div class="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<span class="size-6 animate-spin border-2 border-line border-t-acid-lime rounded-full" />
|
||||
<span class="text-xs">{props.label ?? '加载中...'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManageHeader(props: { icon?: JSX.Element; title: string; description?: string; actions?: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="mono-label text-muted-foreground">manage / console</p>
|
||||
<h1 class="display mt-2 text-4xl sm:text-5xl">{props.title}</h1>
|
||||
<Show when={props.description}>
|
||||
<p class="mt-3 max-w-2xl text-sm text-muted-foreground">{props.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.actions}>
|
||||
<div class="flex shrink-0 items-center gap-2">{props.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManageSection(props: {
|
||||
title?: string
|
||||
description?: string
|
||||
actions?: JSX.Element
|
||||
children: JSX.Element
|
||||
class?: string
|
||||
bodyClass?: string
|
||||
padded?: boolean
|
||||
}) {
|
||||
const padded = () => props.padded !== false
|
||||
return (
|
||||
<section class={`flex min-h-0 flex-col overflow-hidden border border-line bg-card/50 ${props.class ?? ''}`}>
|
||||
<Show when={props.title || props.actions}>
|
||||
<div class="flex shrink-0 flex-wrap items-start justify-between gap-3 border-b border-line/80 px-4 py-3">
|
||||
<div class="min-w-0">
|
||||
<Show when={props.title}>
|
||||
<div class="text-sm font-semibold">{props.title}</div>
|
||||
</Show>
|
||||
<Show when={props.description}>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{props.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={props.actions}>
|
||||
<div class="flex shrink-0 items-center gap-2">{props.actions}</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
<div class={`min-h-0 flex-1 ${padded() ? 'p-4' : ''} ${props.bodyClass ?? ''}`}>{props.children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManageNotice(props: { children: JSX.Element; tone?: 'neutral' | 'warning' | 'info'; icon?: JSX.Element }) {
|
||||
const tone = () => props.tone ?? 'neutral'
|
||||
return (
|
||||
<div
|
||||
class="flex items-start gap-2.5 rounded border px-4 py-3 text-sm"
|
||||
classList={{
|
||||
'border-line bg-card/60 text-muted-foreground': tone() === 'neutral',
|
||||
'border-warning/20 bg-warning/10 text-warning': tone() === 'warning',
|
||||
'border-acid-cyan/20 bg-acid-cyan/10 text-acid-cyan': tone() === 'info',
|
||||
}}
|
||||
>
|
||||
<Show when={props.icon}>
|
||||
<span class="mt-0.5 shrink-0">{props.icon}</span>
|
||||
</Show>
|
||||
<div class="min-w-0 flex-1">{props.children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ManageTag(props: { children: JSX.Element; class?: string }) {
|
||||
return <span class={`inline-flex items-center rounded-full border border-line bg-card px-2 py-0.5 text-[11px] font-medium ${props.class ?? ''}`}>{props.children}</span>
|
||||
}
|
||||
|
||||
export const manageTableHeadClass = 'border-b border-line bg-muted/30 text-xs font-semibold text-muted-foreground'
|
||||
export const manageTableRowClass = 'border-b border-line/60 text-sm hover:bg-accent/30'
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import { For, Show } from 'solid-js'
|
||||
import { Settings, KeyRound, RadioTower, Globe, LogOut, Plus } from 'lucide-solid'
|
||||
import { Badge, Button } from '../../components/ui'
|
||||
|
||||
export function ManageSystem(props: { overview: any }) {
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<Settings class="size-4" />
|
||||
系统与 SFU
|
||||
</h3>
|
||||
<div class="mt-4 space-y-3 text-sm">
|
||||
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
|
||||
<span class="text-muted-foreground">HTTP 端口</span>
|
||||
<span class="font-mono">8088</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
|
||||
<span class="text-muted-foreground">gRPC</span>
|
||||
<span class="font-mono">9090</span>
|
||||
</div>
|
||||
<For each={props.overview?.backends || []}>
|
||||
{(b: any) => (
|
||||
<div class="flex items-center justify-between border border-line bg-background px-3 py-2.5">
|
||||
<span class="flex items-center gap-2">
|
||||
<RadioTower class="size-4 text-muted-foreground" />
|
||||
{b.name || b.kind}
|
||||
</span>
|
||||
<Badge variant={b.configured ? 'success' : 'muted'}>{b.configured ? '已配置' : '未配置'}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/60 p-4">
|
||||
<h3 class="flex items-center gap-2 font-semibold">
|
||||
<KeyRound class="size-4" />
|
||||
安全与策略
|
||||
</h3>
|
||||
<div class="mt-4 space-y-2 text-xs leading-relaxed text-muted-foreground">
|
||||
<p>
|
||||
<span class="font-mono text-foreground">JWT_SECRET</span> 需显式配置;推流 token
|
||||
<span class="font-mono text-foreground"> HS256</span> 签发。
|
||||
</p>
|
||||
<p>
|
||||
私密房间支持密码;<span class="font-mono text-foreground">is_public</span> 与
|
||||
<span class="font-mono text-foreground"> allow_guest_join</span> 联动。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border border-line bg-card/50 p-4">
|
||||
<h3 class="font-semibold">快捷操作</h3>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button variant="ghost" onClick={() => window.open('/api/config', '_blank')}>
|
||||
<Globe class="size-4" />
|
||||
/api/config
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => { localStorage.clear(); location.reload() }}>
|
||||
<LogOut class="size-4" />
|
||||
清理缓存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { For, Show } from 'solid-js'
|
||||
import { Badge, Button, Alert, Select } from '../../components/ui'
|
||||
import { ROLE_META } from './constants'
|
||||
|
||||
export function ManageUsersInline(props: { users: any[]; onRoleChange: (u: any, v: string) => Promise<void>; canList: boolean }) {
|
||||
return (
|
||||
<div class="border border-line bg-card/50">
|
||||
<div class="flex items-center justify-between border-b border-line/80 px-4 py-3">
|
||||
<h3 class="font-semibold">系统用户与全局角色</h3>
|
||||
<span class="mono-label text-muted-foreground">{props.users.length} users</span>
|
||||
</div>
|
||||
<Show when={!props.canList} fallback={
|
||||
<div>
|
||||
<For each={props.users}>
|
||||
{(u) => (
|
||||
<div class="flex items-center justify-between gap-3 border-b border-line/60 px-5 py-3 last:border-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class={`flex size-8 items-center justify-center border font-mono text-xs font-bold ${ROLE_META[u.role]?.color || 'border-line bg-muted'}`}>
|
||||
{(u.username[0] || '?').toUpperCase()}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate font-mono text-sm font-medium">{u.username}</p>
|
||||
<p class="micro-label text-muted-foreground">{u.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge class={ROLE_META[u.role]?.color || ''}>{u.role}</Badge>
|
||||
<Select
|
||||
value={u.role}
|
||||
onChange={async (v) => props.onRoleChange(u, v)}
|
||||
options={[
|
||||
{ value: 'viewer', label: 'viewer' },
|
||||
{ value: 'publisher', label: 'publisher' },
|
||||
{ value: 'admin', label: 'admin' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
}>
|
||||
<Alert tone="warning" class="m-4">
|
||||
无权限查看:需要 user:list
|
||||
</Alert>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
export const ALL_PERMS = [
|
||||
'send_chat',
|
||||
'view_chat_history',
|
||||
'view_members',
|
||||
'subscribe',
|
||||
'watch_room',
|
||||
'publish',
|
||||
'stop_stream',
|
||||
'broadcast',
|
||||
'manage_members',
|
||||
'add_members',
|
||||
'remove_members',
|
||||
'manage_permissions',
|
||||
'manage_room',
|
||||
'delete_room',
|
||||
'delete_chat_messages',
|
||||
'control_stream',
|
||||
] as const
|
||||
|
||||
export const PERM_LABEL: Record<string, string> = {
|
||||
send_chat: '发弹幕',
|
||||
view_chat_history: '看记录',
|
||||
view_members: '看成员',
|
||||
subscribe: '拉流',
|
||||
watch_room: '监听',
|
||||
publish: '推流',
|
||||
stop_stream: '停播',
|
||||
broadcast: '广播',
|
||||
manage_members: '管成员',
|
||||
add_members: '邀成员',
|
||||
remove_members: '踢人',
|
||||
manage_permissions: '管权限',
|
||||
manage_room: '管房间',
|
||||
delete_room: '删房间',
|
||||
delete_chat_messages: '删消息',
|
||||
control_stream: '控流',
|
||||
}
|
||||
|
||||
export const ROLE_META: Record<string, { label: string; color: string }> = {
|
||||
creator: { label: '房主', color: 'border-amber-400/40 bg-amber-400/10 text-amber-600' },
|
||||
admin: { label: '管理员', color: 'border-acid-magenta/30 bg-acid-magenta/10 text-acid-magenta' },
|
||||
member: { label: '成员', color: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan' },
|
||||
guest: { label: '访客', color: 'border-line bg-muted text-muted-foreground' },
|
||||
root: { label: '超管', color: 'border-amber-500/40 bg-amber-500/10 text-amber-600' },
|
||||
viewer: { label: '观看者', color: 'border-acid-cyan/30 bg-acid-cyan/10 text-acid-cyan' },
|
||||
publisher: { label: '发布者', color: 'border-warning/30 bg-warning/10 text-warning' },
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { useLocation, Link } from '@tanstack/solid-router'
|
||||
import { Layers, RadioTower, KeyRound, Settings } from 'lucide-solid'
|
||||
import { createMemo, For } from 'solid-js'
|
||||
import { permissions } from '../../lib/permissions'
|
||||
|
||||
type ManagePath = 'overview' | 'rooms' | 'permissions' | 'system'
|
||||
|
||||
type ManageTab = {
|
||||
path: ManagePath
|
||||
label: string
|
||||
icon: typeof Layers
|
||||
perms: string[]
|
||||
}
|
||||
|
||||
export const MANAGE_TABS: ManageTab[] = [
|
||||
{ path: 'overview', label: '概览', icon: Layers, perms: ['room:list'] },
|
||||
{ path: 'rooms', label: '房间', icon: RadioTower, perms: ['room:manage'] },
|
||||
{ path: 'permissions', label: '权限', icon: KeyRound, perms: ['room:manage'] },
|
||||
{ path: 'system', label: '系统', icon: Settings, perms: ['system:manage', 'config:read'] },
|
||||
]
|
||||
|
||||
export function firstAccessibleManageTab(): ManagePath {
|
||||
for (const t of MANAGE_TABS) if (t.perms.some((p) => permissions.can(p)) || permissions.isAdminOrAbove()) return t.path
|
||||
return 'overview'
|
||||
}
|
||||
|
||||
export default function ManageNav(props: { value: ManagePath; onChange: (v: ManagePath) => void }) {
|
||||
const location = useLocation()
|
||||
const visible = createMemo(() => MANAGE_TABS.filter((t) => t.perms.some((p) => permissions.can(p)) || permissions.isAdminOrAbove()))
|
||||
|
||||
void location
|
||||
return (
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<For each={visible()}>
|
||||
{(tab) => {
|
||||
const Icon = tab.icon
|
||||
const active = () => props.value === tab.path
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class={`inline-flex h-9 items-center gap-1.5 border px-3 text-xs font-medium transition-colors ${
|
||||
active() ? 'border-line bg-acid-lime text-black' : 'border-line bg-card text-muted-foreground hover:bg-accent'
|
||||
}`}
|
||||
aria-current={active() ? 'page' : undefined}
|
||||
onClick={() => props.onChange(tab.path)}
|
||||
>
|
||||
<Icon class="size-3.5" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 兼容 GOSpeak 风格的侧边导航(预留:若后续改为 /manage/* 子路由可直接复用)
|
||||
export function ManageSideNav() {
|
||||
const visible = createMemo(() => MANAGE_TABS.filter((t) => t.perms.some((p) => permissions.can(p)) || permissions.isAdminOrAbove()))
|
||||
return (
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<For each={visible()}>
|
||||
{(tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<Link
|
||||
to="/manage"
|
||||
search={{} as any}
|
||||
class="flex h-9 items-center gap-2 rounded px-3 text-sm hover:bg-accent"
|
||||
>
|
||||
<Icon class="size-4" />
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue