+
+
+
IPv6 地址分配
+
+ {ipv6Search ? `搜索 "${ipv6Search}" 结果 ${filteredIPv6.length} 条,` : ''}共 {ipv6Assignments.length} 个地址
+
+
+
+
+ setIPv6Search(e.target.value)}
+ placeholder="搜索地址/容器..."
+ className="w-full pl-8 pr-7 py-1.5 text-xs border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-black dark:text-white focus:outline-none focus:ring-1 focus:ring-black dark:focus:ring-white"
+ />
+ {ipv6Search && (
+
+ )}
+
{ipv6Assignments.length === 0 ? (
} text="暂无 IPv6 地址分配" />
@@ -184,7 +254,7 @@ export default function Routing() {
diff --git a/frontend/src/pages/Snapshots.tsx b/frontend/src/pages/Snapshots.tsx
index 4b3935b..d61aec4 100644
--- a/frontend/src/pages/Snapshots.tsx
+++ b/frontend/src/pages/Snapshots.tsx
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useState } from 'react'
-import { Camera, RefreshCw, Server } from 'lucide-react'
+import { Camera, RefreshCw, Server, Trash2 } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
-import { getSnapshots, Snapshot } from '../services/api'
+import { deleteContainerSnapshot, getSnapshots, Snapshot } from '../services/api'
+import { useDialog } from '../components/Dialog'
export default function Snapshots() {
const navigate = useNavigate()
+ const dialog = useDialog()
const [snapshots, setSnapshots] = useState
([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
+ const [deleting, setDeleting] = useState(null)
const fetchData = useCallback(async () => {
try {
@@ -23,6 +26,25 @@ export default function Snapshots() {
useEffect(() => { fetchData() }, [fetchData])
+ const handleDelete = async (snapshot: Snapshot) => {
+ const confirmed = await dialog.confirm(
+ '删除快照',
+ `确认删除容器 ${snapshot.container_name} 的快照吗?此操作不可恢复。`
+ )
+ if (!confirmed) return
+
+ setDeleting(snapshot.id)
+ try {
+ await deleteContainerSnapshot(snapshot.container_id, snapshot.id)
+ setSnapshots(prev => prev.filter(s => s.id !== snapshot.id))
+ } catch (err: unknown) {
+ const error = err as { response?: { data?: { message?: string } } }
+ dialog.alert('删除失败', error.response?.data?.message || '请稍后重试')
+ } finally {
+ setDeleting(null)
+ }
+ }
+
if (loading) {
return (
@@ -66,6 +88,7 @@ export default function Snapshots() {
类型 |
创建者 |
大小 |
+
操作 |
@@ -89,6 +112,16 @@ export default function Snapshots() {
{snapshot.created_by || '-'} |
{formatBytes(snapshot.size_bytes || 0)} |
+
+
+ |
))}
diff --git a/frontend/src/pages/SubUserManagement.tsx b/frontend/src/pages/SubUserManagement.tsx
new file mode 100644
index 0000000..91a02c9
--- /dev/null
+++ b/frontend/src/pages/SubUserManagement.tsx
@@ -0,0 +1,328 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Copy, KeyRound, LogIn, RefreshCw, ScrollText, UserCog, X } from 'lucide-react'
+import { useDialog } from '../components/Dialog'
+import api, { AuditLog, LoginLog } from '../services/api'
+import { copyToClipboard } from '../utils/clipboard'
+
+interface SubUserItem {
+ id: string
+ username: string
+ container_names: string[]
+ container_uuids: string[]
+ container_name: string
+ container_uuid: string
+ access_code: string
+ password?: string
+ created_at: string
+ last_login: string
+ last_login_ip: string
+ last_login_ua: string
+}
+
+interface AuditLogExt extends AuditLog {
+ ip?: string
+ user_agent?: string
+ success?: boolean
+ error?: string
+}
+
+export default function SubUserManagement() {
+ const dialog = useDialog()
+ const [users, setUsers] = useState
([])
+ const [loading, setLoading] = useState(true)
+ const [auditLogs, setAuditLogs] = useState(null)
+ const [loginLogs, setLoginLogs] = useState(null)
+ const [modalTitle, setModalTitle] = useState('')
+ const [passwordUser, setPasswordUser] = useState(null)
+ const [rotatingPassword, setRotatingPassword] = useState(false)
+
+ const fetchUsers = useCallback(async () => {
+ try {
+ const res = await api.get<{ success: boolean; data: SubUserItem[] }>('/sub-users')
+ setUsers(res.data.data || [])
+ } catch (err) {
+ console.error(err)
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { fetchUsers() }, [fetchUsers])
+
+ const managementUrl = (user: SubUserItem) => `${window.location.origin}/login?code=${user.access_code}`
+
+ const copyText = async (text: string) => {
+ await copyToClipboard(text)
+ }
+
+ const rotatePassword = async (user: SubUserItem) => {
+ setRotatingPassword(true)
+ try {
+ const res = await api.post(`/sub-users/${user.id}/rotate-password`)
+ const data = res.data.data
+ const updatedUser = {
+ ...user,
+ username: data?.username || user.username,
+ access_code: data?.access_code || user.access_code,
+ password: data?.password || '',
+ }
+ setUsers((prev) => prev.map((item) => (item.id === user.id ? updatedUser : item)))
+ setPasswordUser(updatedUser)
+ } catch (err: unknown) {
+ const error = err as { response?: { data?: { message?: string } } }
+ dialog.alert('轮换失败', error.response?.data?.message || '请稍后重试')
+ } finally {
+ setRotatingPassword(false)
+ }
+ }
+
+ const showAuditLogs = async (user: SubUserItem) => {
+ try {
+ const res = await api.get(`/sub-users/${user.id}/audit-logs`)
+ setAuditLogs(res.data.data || [])
+ setLoginLogs(null)
+ setModalTitle(`${user.username} - 操作日志`)
+ } catch {
+ dialog.alert('错误', '获取操作日志失败')
+ }
+ }
+
+ const showLoginLogs = async (user: SubUserItem) => {
+ try {
+ const res = await api.get(`/sub-users/${user.id}/login-logs`)
+ setLoginLogs(res.data.data || [])
+ setAuditLogs(null)
+ setModalTitle(`${user.username} - 登录日志`)
+ } catch {
+ dialog.alert('错误', '获取登录日志失败')
+ }
+ }
+
+ const closeModal = () => {
+ setAuditLogs(null)
+ setLoginLogs(null)
+ }
+
+ if (loading) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
子用户管理
+
容器分配的子用户列表,共 {users.length} 个
+
+
+
+ {users.length === 0 ? (
+
+ ) : (
+
+
+
+ | # |
+ 容器名称 |
+ UUID |
+ 最后登录 |
+ 操作 |
+
+
+
+ {users.map((user, index) => (
+
+ | {index + 1} |
+ {user.container_name || '-'} |
+ {user.container_uuid || '-'} |
+
+ {user.last_login ? (
+
+ {user.last_login}
+ {user.last_login_ip}
+
+ ) : (
+ 从未登录
+ )}
+ |
+
+
+
+
+
+
+ |
+
+ ))}
+
+
+ )}
+
+
+ {passwordUser && (
+
+
+
+
查看密码
+
+
+
+
+
+
+
+
+ 用户
+ {passwordUser.username}
+
+
+
地址
+
+ {managementUrl(passwordUser)}
+
+
+
+
+
密码
+
+
+ {passwordUser.password || '未保存,请轮换生成新密码'}
+
+ {passwordUser.password && (
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* Log Modal */}
+ {(auditLogs || loginLogs) && (
+
+
+
+
{modalTitle}
+
+
+
+ {auditLogs && (
+
+
+
+ | 操作时间 |
+ 操作 |
+ IP |
+ UA |
+ 结果 |
+
+
+
+ {auditLogs.length === 0 ? (
+ | 暂无操作日志 |
+ ) : auditLogs.map((log, i) => (
+
+ | {log.time} |
+ {log.action} |
+ {log.ip || '-'} |
+ {log.user_agent || '-'} |
+
+ {log.success !== undefined ? (
+ log.success ? (
+ 成功
+ ) : (
+ {log.error ? '失败' : '失败'}
+ )
+ ) : (
+ -
+ )}
+ |
+
+ ))}
+
+
+ )}
+ {loginLogs && (
+
+
+
+ | 登录时间 |
+ 登录 IP |
+ UA |
+ 结果 |
+
+
+
+ {loginLogs.length === 0 ? (
+ | 暂无登录日志 |
+ ) : loginLogs.map((log, i) => (
+
+ | {log.time} |
+ {log.ip} |
+ {log.user_agent} |
+
+ {log.success ? (
+ 成功
+ ) : (
+ 失败
+ )}
+ |
+
+ ))}
+
+
+ )}
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index 6ec1ca3..59e2a83 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -197,6 +197,14 @@ export interface LoginLog {
success: boolean
}
+export interface AuditLog {
+ time: string
+ action: string
+ target: string
+ detail: string
+ user: string
+}
+
export const getLoginLogs = () =>
api.get>('/login-logs')
diff --git a/frontend/src/utils/clipboard.ts b/frontend/src/utils/clipboard.ts
new file mode 100644
index 0000000..5dcc0a9
--- /dev/null
+++ b/frontend/src/utils/clipboard.ts
@@ -0,0 +1,44 @@
+export async function copyToClipboard(text: string): Promise {
+ if (!text) return false
+
+ if (navigator.clipboard?.writeText) {
+ try {
+ await navigator.clipboard.writeText(text)
+ return true
+ } catch {
+ // Fall through for non-secure HTTP origins where Clipboard API is blocked.
+ }
+ }
+
+ const textarea = document.createElement('textarea')
+ textarea.value = text
+ textarea.setAttribute('readonly', '')
+ textarea.style.position = 'fixed'
+ textarea.style.top = '0'
+ textarea.style.left = '0'
+ textarea.style.width = '1px'
+ textarea.style.height = '1px'
+ textarea.style.opacity = '0'
+ textarea.style.pointerEvents = 'none'
+
+ const selection = document.getSelection()
+ const selectedRange = selection?.rangeCount ? selection.getRangeAt(0) : null
+
+ document.body.appendChild(textarea)
+ textarea.focus({ preventScroll: true })
+ textarea.select()
+ textarea.setSelectionRange(0, textarea.value.length)
+
+ let copied = false
+ try {
+ copied = document.execCommand('copy')
+ } finally {
+ document.body.removeChild(textarea)
+ if (selection && selectedRange) {
+ selection.removeAllRanges()
+ selection.addRange(selectedRange)
+ }
+ }
+
+ return copied
+}