feat: 补齐 admin 页面(用户管理/角色权限/审计日志)+ API 路由

This commit is contained in:
aiyimickey 2026-07-01 23:40:56 +08:00
parent de0116ddc8
commit 25365b1626
7 changed files with 551 additions and 12 deletions

View File

@ -1,11 +1,121 @@
// src/app/admin/audit-logs/page.tsx — 审计日志(占位) 'use client'
// src/app/admin/audit-logs/page.tsx — 审计日志
import { useState, useEffect, useCallback } from 'react'
import { RotateCw } from 'lucide-react'
interface AuditLog {
id: number; username: string | null; action: string; entity_type: string
details: string | null; ip_address: string | null; created_at: string
}
export default function AuditLogsPage() { export default function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [action, setAction] = useState('')
const [entityType, setEntityType] = useState('')
const [loading, setLoading] = useState(true)
const pageSize = 20
const load = useCallback(async () => {
setLoading(true)
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize) })
if (action) params.set('action', action)
if (entityType) params.set('entity_type', entityType)
const res = await fetch(`/api/admin/audit-logs?${params}`)
if (res.ok) {
const data = await res.json()
setLogs(data.data)
setTotal(data.total)
}
setLoading(false)
}, [page, action, entityType])
useEffect(() => { load() }, [load])
const totalPages = Math.ceil(total / pageSize)
const renderDetails = (details: string | null) => {
if (!details) return '—'
try { return JSON.stringify(JSON.parse(details)).slice(0, 80) }
catch { return details.slice(0, 80) }
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1> <h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center"> <p className="text-sm text-slate-400 mt-1"></p>
<p className="text-slate-400"></p>
</div> </div>
<div className="flex gap-4 flex-wrap">
<select value={action} onChange={e => { setAction(e.target.value); setPage(1) }}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
<option value=""></option>
<option value="login"></option><option value="login_failed"></option><option value="logout"></option>
<option value="create_service"></option><option value="update_service"></option><option value="delete_service"></option>
<option value="create_channel"></option><option value="update_channel"></option><option value="delete_channel"></option>
<option value="update_user"></option><option value="update_role"></option>
</select>
<select value={entityType} onChange={e => { setEntityType(e.target.value); setPage(1) }}
className="px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800 text-sm">
<option value=""></option>
<option value="auth"></option><option value="service"></option><option value="alert_channel"></option>
<option value="user"></option><option value="role"></option>
</select>
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
<RotateCw size={14} />
</button>
</div>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase">IP</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">...</td></tr>
) : logs.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400"></td></tr>
) : logs.map(log => (
<tr key={log.id} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400 whitespace-nowrap">{log.created_at}</td>
<td className="px-4 py-3 text-sm">{log.username || '—'}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
log.action.includes('login') ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
log.action.includes('failed') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
log.action.includes('create') ? 'bg-green-100 text-green-700 dark:bg-green-500/10 dark:text-green-400' :
log.action.includes('delete') ? 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400' :
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
}`}>{log.action}</span>
</td>
<td className="px-4 py-3 text-sm">{log.entity_type}</td>
<td className="px-4 py-3 text-sm text-slate-500 font-mono text-xs">{renderDetails(log.details)}</td>
<td className="px-4 py-3 text-sm text-slate-400">{log.ip_address || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)}
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40"></button>
<span className="text-sm text-slate-500"> {page}/{totalPages} ( {total} )</span>
<button disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}
className="px-3 py-1.5 text-sm border rounded-lg disabled:opacity-40"></button>
</div>
)}
</div> </div>
) )
} }

View File

@ -1,11 +1,130 @@
// src/app/admin/roles/page.tsx — 角色权限管理(占位) 'use client'
// src/app/admin/roles/page.tsx — 角色权限管理
import { useState, useEffect, useCallback } from 'react'
import { Shield, RotateCw, Save } from 'lucide-react'
interface RoleData { role: string; permissions: { key: string; enabled: boolean }[] }
export default function RolesPage() { export default function RolesPage() {
const [roles, setRoles] = useState<RoleData[]>([])
const [allPermissions, setAllPermissions] = useState<string[]>([])
const [loading, setLoading] = useState(true)
const [editing, setEditing] = useState<string | null>(null)
const [perms, setPerms] = useState<string[]>([])
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
const res = await fetch('/api/admin/roles')
if (res.ok) {
const data = await res.json()
setRoles(data.roles)
setAllPermissions(data.allPermissions)
}
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
const showToast = (type: 'ok' | 'err', msg: string) => {
setToast({ type, msg })
setTimeout(() => setToast(null), 3000)
}
const startEdit = (role: RoleData) => {
setEditing(role.role)
setPerms(role.permissions.filter(p => p.enabled).map(p => p.key))
}
const togglePerm = (key: string) => {
setPerms(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key])
}
const handleSave = async () => {
if (!editing) return
setSaving(true)
try {
const res = await fetch('/api/admin/roles', {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: editing, permissions: perms }),
})
if (res.ok) { showToast('ok', `${editing} 角色权限已更新`); setEditing(null); load() }
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
} catch { showToast('err', '网络错误') }
setSaving(false)
}
const permLabels: Record<string, string> = {
'dashboard:view': '查看仪表盘', 'services:view': '查看服务', 'services:manage': '管理服务',
'alerts:view': '查看告警', 'alerts:manage': '管理告警渠道', 'status_history:view': '查看状态历史',
'status_history:stats': '可用率统计', 'audit:view': '查看审计', 'users:view': '查看用户',
'users:manage': '管理用户', 'roles:manage': '管理角色',
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1> {toast && (
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center"> <div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
<p className="text-slate-400"></p> {toast.msg}
</div> </div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<p className="text-sm text-slate-400 mt-1">admin / editor / viewer</p>
</div>
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
<RotateCw size={14} />
</button>
</div>
{loading ? (
<div className="text-center py-12 text-slate-400">...</div>
) : (
<div className="space-y-4">
{roles.map(role => (
<div key={role.role} className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="font-semibold capitalize flex items-center gap-2">
<Shield size={18} className={role.role === 'admin' ? 'text-indigo-600' : role.role === 'editor' ? 'text-blue-600' : 'text-slate-400'} />
{role.role}
</h2>
{editing === role.role ? (
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving} className="flex items-center gap-2 px-3 py-1.5 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
<Save size={14} /> {saving ? '保存中' : '保存'}
</button>
<button onClick={() => setEditing(null)} className="px-3 py-1.5 text-sm border rounded-lg"></button>
</div>
) : (
<button onClick={() => startEdit(role)} className="px-3 py-1.5 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800"></button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
{role.role === 'admin' ? (
<span className="text-sm text-slate-400">admin {allPermissions.length} </span>
) : editing === role.role ? (
allPermissions.map(p => (
<label key={p} className="flex items-center gap-2 text-sm py-1">
<input type="checkbox" checked={perms.includes(p)} onChange={() => togglePerm(p)}
className="rounded border-slate-300 text-indigo-600" />
{permLabels[p] || p}
</label>
))
) : (
role.permissions.filter(p => p.enabled).map(p => (
<span key={p.key} className="px-2 py-1 rounded bg-slate-50 dark:bg-slate-800 text-xs text-slate-600 dark:text-slate-400">
{permLabels[p.key] || p.key}
</span>
))
)}
</div>
</div>
))}
</div>
)}
</div> </div>
) )
} }

View File

@ -1,10 +1,125 @@
// src/app/admin/users/page.tsx — 用户管理占位Phase 3 完成) 'use client'
// src/app/admin/users/page.tsx — 用户管理
import { useState, useEffect, useCallback } from 'react'
import { Users, RotateCw } from 'lucide-react'
interface User {
id: number; username: string; display_name: string | null; email: string | null
role: string; is_active: number; last_login_at: string | null
}
export default function UsersPage() { export default function UsersPage() {
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [editingId, setEditingId] = useState<number | null>(null)
const [editRole, setEditRole] = useState('')
const [saving, setSaving] = useState(false)
const [toast, setToast] = useState<{ type: 'ok' | 'err'; msg: string } | null>(null)
const load = useCallback(async () => {
setLoading(true)
const res = await fetch('/api/admin/users')
if (res.ok) setUsers(await res.json())
setLoading(false)
}, [])
useEffect(() => { load() }, [load])
const showToast = (type: 'ok' | 'err', msg: string) => {
setToast({ type, msg })
setTimeout(() => setToast(null), 3000)
}
const handleSave = async (id: number) => {
setSaving(true)
try {
const res = await fetch(`/api/admin/users/${id}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: editRole }),
})
if (res.ok) { showToast('ok', '角色已更新'); setEditingId(null); load() }
else { const d = await res.json(); showToast('err', d.error || '保存失败') }
} catch { showToast('err', '网络错误') }
setSaving(false)
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-2 rounded-lg text-sm font-medium shadow-lg ${toast.type === 'ok' ? 'bg-green-600 text-white' : 'bg-red-600 text-white'}`}>
{toast.msg}
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1> <h1 className="text-2xl font-semibold font-[family-name:var(--font-display)]"></h1>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl p-12 text-center"> <p className="text-sm text-slate-400 mt-1"></p>
<p className="text-slate-400"> UI</p> </div>
<button onClick={load} className="px-4 py-2 text-sm border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800 flex items-center gap-2">
<RotateCw size={14} />
</button>
</div>
<div className="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-500 uppercase"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-12 text-center text-slate-400">...</td></tr>
) : users.map(user => (
<tr key={user.id} className="border-b border-slate-100 dark:border-slate-800">
<td className="px-4 py-3 text-sm font-medium">{user.username}</td>
<td className="px-4 py-3 text-sm text-slate-600 dark:text-slate-400">{user.display_name || '—'}</td>
<td className="px-4 py-3 text-sm text-slate-400">{user.email || '—'}</td>
<td className="px-4 py-3 text-sm">
{editingId === user.id ? (
<select value={editRole} onChange={e => setEditRole(e.target.value)}
className="px-2 py-1 rounded border text-sm">
<option value="admin">admin</option>
<option value="editor">editor</option>
<option value="viewer">viewer</option>
</select>
) : (
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
user.role === 'admin' ? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400' :
user.role === 'editor' ? 'bg-blue-100 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400' :
'bg-slate-100 text-slate-700 dark:bg-slate-500/10 dark:text-slate-400'
}`}>{user.role}</span>
)}
</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${user.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{user.is_active ? '启用' : '禁用'}
</span>
</td>
<td className="px-4 py-3 text-sm">
{editingId === user.id ? (
<div className="flex gap-2">
<button onClick={() => handleSave(user.id)} disabled={saving}
className="px-3 py-1 text-xs bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
{saving ? '保存中' : '保存'}
</button>
<button onClick={() => setEditingId(null)} className="px-3 py-1 text-xs border rounded-lg"></button>
</div>
) : (
<button onClick={() => { setEditingId(user.id); setEditRole(user.role) }}
className="px-3 py-1 text-xs border rounded-lg hover:bg-slate-50 dark:hover:bg-slate-800"></button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
</div> </div>
) )

View File

@ -0,0 +1,59 @@
// GET /api/admin/audit-logs — 审计日志列表admin
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec } from '@/lib/db'
import { hasPermission } from '@/lib/permissions'
export async function GET(request: NextRequest) {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'audit:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const action = searchParams.get('action') || ''
const entityType = searchParams.get('entity_type') || ''
const username = searchParams.get('username') || ''
const page = Math.max(1, parseInt(searchParams.get('page') || '1'))
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20')))
const offset = (page - 1) * pageSize
let where = 'WHERE 1=1'
if (action) {
where += ` AND action = '${action.replace(/'/g, "''")}'`
}
if (entityType) {
where += ` AND entity_type = '${entityType.replace(/'/g, "''")}'`
}
if (username) {
where += ` AND username = '${username.replace(/'/g, "''")}'`
}
const countRow = dbQuery<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM audit_logs ${where}`)
const total = countRow[0]?.cnt || 0
const rows = dbQuery(`SELECT * FROM audit_logs ${where} ORDER BY created_at DESC LIMIT ${pageSize} OFFSET ${offset}`)
return NextResponse.json({ data: rows, total, page, pageSize })
}
// DELETE /api/admin/audit-logs — 清理过期日志admin
export async function DELETE(request: NextRequest) {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'audit:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const days = parseInt(searchParams.get('days') || '180')
if (days < 30 || days > 365) {
return NextResponse.json({ error: '保留天数需在 30-365 之间' }, { status: 400 })
}
dbExec(`DELETE FROM audit_logs WHERE created_at < datetime('now', '-${days} days', '+8 hours')`)
return NextResponse.json({ success: true, message: `已清理 ${days} 天前的日志` })
}

View File

@ -0,0 +1,62 @@
// GET/PUT /api/admin/roles — 角色权限管理admin
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { hasPermission, PERMISSIONS } from '@/lib/permissions'
export async function GET(request: NextRequest) {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'roles:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
// 返回所有权限定义 + 各角色的权限映射
const rolePermissions = dbQuery<{ role: string; permission_key: string }>('SELECT role, permission_key FROM role_permissions')
const roles = ['admin', 'editor', 'viewer']
const result = roles.map(role => ({
role,
permissions: PERMISSIONS.map(p => ({
key: p,
enabled: role === 'admin' || rolePermissions.some(rp => rp.role === role && rp.permission_key === p),
})),
}))
return NextResponse.json({ roles: result, allPermissions: PERMISSIONS })
}
export async function PUT(request: NextRequest) {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'roles:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const body = await request.json()
const { role, permissions } = body as { role: string; permissions: string[] }
if (!role || !permissions || !Array.isArray(permissions)) {
return NextResponse.json({ error: '参数错误' }, { status: 400 })
}
// 删除旧权限,写入新权限
dbExec(`DELETE FROM role_permissions WHERE role = '${role.replace(/'/g, "''")}'`)
for (const perm of permissions) {
dbExec(`INSERT OR IGNORE INTO role_permissions (role, permission_key) VALUES ('${role.replace(/'/g, "''")}', '${perm.replace(/'/g, "''")}')`)
}
writeAuditLog({
userId: Number(payload.sub) || null,
username: String(payload.username || ''),
action: 'update_role',
entityType: 'role',
details: { role, permissions },
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,43 @@
// PUT/PATCH /api/admin/users/[id] — 修改用户角色
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { hasPermission } from '@/lib/permissions'
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'users:manage')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { id } = await params
const body = await request.json()
const { role, display_name, email } = body
const updates: string[] = []
if (role) updates.push(`role = '${role.replace(/'/g, "''")}'`)
if (display_name !== undefined) updates.push(`display_name = '${String(display_name).replace(/'/g, "''")}'`)
if (email !== undefined) updates.push(`email = '${String(email).replace(/'/g, "''")}'`)
if (updates.length === 0) {
return NextResponse.json({ error: '无可更新字段' }, { status: 400 })
}
updates.push(`updated_at = datetime('now', '+8 hours')`)
dbExec(`UPDATE users SET ${updates.join(', ')} WHERE id = ${Number(id)}`)
writeAuditLog({
userId: Number(payload.sub) || null,
username: String(payload.username || ''),
action: 'update_user',
entityType: 'user',
entityId: Number(id),
details: body,
ipAddress: request.headers.get('x-forwarded-for') || '127.0.0.1',
})
return NextResponse.json({ success: true })
}

View File

@ -0,0 +1,31 @@
// GET /api/admin/users — 用户列表admin
import { NextRequest, NextResponse } from 'next/server'
import { verifyJwt } from '@shared/lib/auth/jwt'
import { authConfig } from '@/lib/auth-config'
import { dbQuery, dbExec, escapeSql } from '@/lib/db'
import { writeAuditLog } from '@/lib/audit'
import { hasPermission } from '@/lib/permissions'
export async function GET(request: NextRequest) {
const token = request.cookies.get('tlyq_session')?.value
if (!token) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const payload = verifyJwt(token, authConfig.jwtSecret)
if (!payload || !hasPermission(String(payload.role || 'viewer'), 'users:view')) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const { searchParams } = new URL(request.url)
const username = searchParams.get('username') || ''
const role = searchParams.get('role') || ''
let where = 'WHERE 1=1'
if (username) {
where += ` AND username LIKE '%${username.replace(/'/g, "''")}%'`
}
if (role) {
where += ` AND role = '${role.replace(/'/g, "''")}'`
}
const rows = dbQuery(`SELECT id, username, display_name, email, role, is_active, last_login_at FROM users ${where} ORDER BY id`)
return NextResponse.json(rows)
}