日志记录
This commit is contained in:
parent
07d1c2274b
commit
b26fe1e117
|
|
@ -29,6 +29,7 @@ import { SyncMedusaButton as SyncMedusaButton_31e6578e170fdd0bad7013c8202d6e08 }
|
|||
import { default as default_c2e3814fe427263135b1f5931c37f6f2 } from '../../../components/list/ProductGridStyler'
|
||||
import { ForceSyncButton as ForceSyncButton_28396efe36d6238add95cf44109e281c } from '../../../components/sync/ForceSyncButton'
|
||||
import { default as default_767734c8b7b095ea28d54c32abcf46e4 } from '../../../components/views/AdminPanel'
|
||||
import { default as default_a766ef013722c08f9bb937940272cb5f } from '../../../components/views/LogsManagerView'
|
||||
import { S3ClientUploadHandler as S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24 } from '@payloadcms/storage-s3/client'
|
||||
import { CollectionCards as CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1 } from '@payloadcms/next/rsc'
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export const importMap = {
|
|||
"/components/list/ProductGridStyler#default": default_c2e3814fe427263135b1f5931c37f6f2,
|
||||
"/components/sync/ForceSyncButton#ForceSyncButton": ForceSyncButton_28396efe36d6238add95cf44109e281c,
|
||||
"/components/views/AdminPanel#default": default_767734c8b7b095ea28d54c32abcf46e4,
|
||||
"/components/views/LogsManagerView#default": default_a766ef013722c08f9bb937940272cb5f,
|
||||
"@payloadcms/storage-s3/client#S3ClientUploadHandler": S3ClientUploadHandler_f97aa6c64367fa259c5bc0567239ef24,
|
||||
"@payloadcms/next/rsc#CollectionCards": CollectionCards_f9c02e79a4aed9a3924487c0cd4cafb1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { getPayload } from 'payload'
|
||||
import config from '@payload-config'
|
||||
import { NextRequest } from 'next/server'
|
||||
|
||||
/**
|
||||
* 批量删除日志 API
|
||||
* DELETE /api/delete-logs?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
|
||||
*/
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const payload = await getPayload({ config })
|
||||
|
||||
// 检查用户权限
|
||||
const { user } = await payload.auth({ headers: req.headers })
|
||||
if (!user || !user.roles?.includes('admin')) {
|
||||
return Response.json({ error: '需要管理员权限' }, { status: 403 })
|
||||
}
|
||||
|
||||
// 获取日期参数
|
||||
const { searchParams } = new URL(req.url)
|
||||
const startDate = searchParams.get('startDate')
|
||||
const endDate = searchParams.get('endDate')
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return Response.json({ error: '请提供开始和结束日期' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 验证日期格式
|
||||
const start = new Date(startDate)
|
||||
const end = new Date(endDate)
|
||||
|
||||
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
|
||||
return Response.json({ error: '无效的日期格式' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 设置时间范围
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
|
||||
// 查询要删除的日志
|
||||
const logsToDelete = await payload.find({
|
||||
collection: 'logs',
|
||||
where: {
|
||||
and: [
|
||||
{
|
||||
createdAt: {
|
||||
greater_than_equal: start.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
less_than_equal: end.toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: 10000, // 限制一次删除数量
|
||||
})
|
||||
|
||||
// 批量删除
|
||||
let deletedCount = 0
|
||||
for (const log of logsToDelete.docs) {
|
||||
try {
|
||||
await payload.delete({
|
||||
collection: 'logs',
|
||||
id: log.id,
|
||||
context: { skipHooks: true }, // 跳过钩子
|
||||
})
|
||||
deletedCount++
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete log ${log.id}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: `成功删除 ${deletedCount} 条日志记录`,
|
||||
deletedCount,
|
||||
totalFound: logsToDelete.totalDocs,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Delete logs error:', error)
|
||||
return Response.json(
|
||||
{
|
||||
error: '删除失败',
|
||||
message: error instanceof Error ? error.message : '未知错误',
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||
import {
|
||||
BlocksFeature,
|
||||
BoldFeature,
|
||||
|
|
@ -220,5 +221,7 @@ export const Announcements: CollectionConfig = {
|
|||
return data
|
||||
},
|
||||
],
|
||||
afterChange: [logAfterChange],
|
||||
afterDelete: [logAfterDelete],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||
import {
|
||||
BlocksFeature,
|
||||
BoldFeature,
|
||||
|
|
@ -330,5 +331,7 @@ export const Articles: CollectionConfig = {
|
|||
return data
|
||||
},
|
||||
],
|
||||
afterChange: [logAfterChange],
|
||||
afterDelete: [logAfterDelete],
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
|
||||
export const Logs: CollectionConfig = {
|
||||
slug: 'logs',
|
||||
admin: {
|
||||
useAsTitle: 'action',
|
||||
defaultColumns: ['action', 'collection', 'user', 'createdAt'],
|
||||
description: '系统操作日志记录',
|
||||
group: '系统',
|
||||
pagination: {
|
||||
defaultLimit: 50,
|
||||
},
|
||||
listSearchableFields: ['action', 'collection', 'documentId'],
|
||||
},
|
||||
access: {
|
||||
// 只有 admin 和 editor 可以查看日志
|
||||
read: ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return user.roles?.includes('admin') || user.roles?.includes('editor') || false
|
||||
},
|
||||
// 禁止手动创建、更新日志(只能通过系统钩子自动创建)
|
||||
create: () => false,
|
||||
update: () => false,
|
||||
// 只有 admin 可以删除
|
||||
delete: ({ req: { user } }) => {
|
||||
if (!user) return false
|
||||
return user.roles?.includes('admin') || false
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '创建', value: 'create' },
|
||||
{ label: '更新', value: 'update' },
|
||||
{ label: '删除', value: 'delete' },
|
||||
{ label: '同步', value: 'sync' },
|
||||
{ label: '登录', value: 'login' },
|
||||
{ label: '登出', value: 'logout' },
|
||||
],
|
||||
admin: {
|
||||
description: '操作类型',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'collection',
|
||||
type: 'text',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: '操作的集合名称',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentId',
|
||||
type: 'text',
|
||||
index: true,
|
||||
admin: {
|
||||
description: '文档 ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'documentTitle',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '文档标题',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 'relationship',
|
||||
relationTo: 'users',
|
||||
required: true,
|
||||
index: true,
|
||||
admin: {
|
||||
description: '操作用户',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'changes',
|
||||
type: 'json',
|
||||
admin: {
|
||||
description: '变更内容(JSON 格式)',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ip',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: 'IP 地址',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'userAgent',
|
||||
type: 'text',
|
||||
admin: {
|
||||
description: '浏览器信息',
|
||||
},
|
||||
},
|
||||
],
|
||||
timestamps: true,
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||
|
||||
export const Media: CollectionConfig = {
|
||||
slug: 'media',
|
||||
|
|
@ -12,5 +13,9 @@ export const Media: CollectionConfig = {
|
|||
required: true,
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [logAfterChange],
|
||||
afterDelete: [logAfterDelete],
|
||||
},
|
||||
upload: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { CollectionConfig } from 'payload'
|
||||
import { logAfterChange, logAfterDelete } from '../hooks/logAction'
|
||||
import {
|
||||
AlignFeature,
|
||||
BlocksFeature,
|
||||
|
|
@ -200,5 +201,9 @@ export const Products: CollectionConfig = {
|
|||
],
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
afterChange: [logAfterChange],
|
||||
afterDelete: [logAfterDelete],
|
||||
},
|
||||
timestamps: true,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,500 @@
|
|||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button, useAuth } from '@payloadcms/ui'
|
||||
|
||||
interface Log {
|
||||
id: string
|
||||
action: string
|
||||
collection: string
|
||||
documentId?: string
|
||||
documentTitle?: string
|
||||
user: {
|
||||
id: string
|
||||
email: string
|
||||
}
|
||||
createdAt: string
|
||||
ip?: string
|
||||
}
|
||||
|
||||
export default function LogsManagerView() {
|
||||
const { user } = useAuth()
|
||||
const [logs, setLogs] = useState<Log[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [totalDocs, setTotalDocs] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [limit] = useState(50)
|
||||
|
||||
// 筛选条件
|
||||
const [days, setDays] = useState(7) // 默认显示最近7天
|
||||
const [selectedCollection, setSelectedCollection] = useState('all')
|
||||
const [selectedAction, setSelectedAction] = useState('all')
|
||||
|
||||
// 删除相关
|
||||
const [deleteStartDate, setDeleteStartDate] = useState('')
|
||||
const [deleteEndDate, setDeleteEndDate] = useState('')
|
||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||
const [deleteMessage, setDeleteMessage] = useState('')
|
||||
|
||||
const isAdmin = user?.roles?.includes('admin')
|
||||
|
||||
// 加载日志
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// 计算日期范围
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - days)
|
||||
|
||||
// 构建查询条件
|
||||
const whereConditions: any[] = [
|
||||
{
|
||||
createdAt: {
|
||||
greater_than_equal: startDate.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
createdAt: {
|
||||
less_than_equal: endDate.toISOString(),
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (selectedCollection !== 'all') {
|
||||
whereConditions.push({
|
||||
collection: {
|
||||
equals: selectedCollection,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (selectedAction !== 'all') {
|
||||
whereConditions.push({
|
||||
action: {
|
||||
equals: selectedAction,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const query = new URLSearchParams({
|
||||
depth: '1',
|
||||
limit: limit.toString(),
|
||||
page: page.toString(),
|
||||
sort: '-createdAt',
|
||||
where: JSON.stringify({
|
||||
and: whereConditions,
|
||||
}),
|
||||
})
|
||||
|
||||
const response = await fetch(`/admin/api/logs?${query}`)
|
||||
const data = await response.json()
|
||||
|
||||
setLogs(data.docs || [])
|
||||
setTotalDocs(data.totalDocs || 0)
|
||||
} catch (error) {
|
||||
console.error('Failed to load logs:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除指定日期范围的日志
|
||||
const handleDeleteLogs = async () => {
|
||||
if (!deleteStartDate || !deleteEndDate) {
|
||||
setDeleteMessage('请选择开始和结束日期')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`确定删除 ${deleteStartDate} 至 ${deleteEndDate} 的所有日志吗?此操作不可撤销!`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleteLoading(true)
|
||||
setDeleteMessage('')
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/delete-logs?startDate=${deleteStartDate}&endDate=${deleteEndDate}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
},
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
setDeleteMessage(data.message)
|
||||
// 重新加载日志
|
||||
loadLogs()
|
||||
// 清空日期选择
|
||||
setDeleteStartDate('')
|
||||
setDeleteEndDate('')
|
||||
} else {
|
||||
setDeleteMessage(`删除失败: ${data.error}`)
|
||||
}
|
||||
} catch (error) {
|
||||
setDeleteMessage(`删除出错: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
} finally {
|
||||
setDeleteLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 快速删除按钮
|
||||
const handleQuickDelete = (daysAgo: number) => {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(start.getDate() - daysAgo)
|
||||
|
||||
setDeleteStartDate(start.toISOString().split('T')[0])
|
||||
setDeleteEndDate(end.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [days, selectedCollection, selectedAction, page])
|
||||
|
||||
const actionLabels: Record<string, string> = {
|
||||
create: '创建',
|
||||
update: '更新',
|
||||
delete: '删除',
|
||||
sync: '同步',
|
||||
login: '登录',
|
||||
logout: '登出',
|
||||
}
|
||||
|
||||
const actionColors: Record<string, string> = {
|
||||
create: '#10b981',
|
||||
update: '#3b82f6',
|
||||
delete: '#ef4444',
|
||||
sync: '#8b5cf6',
|
||||
login: '#14b8a6',
|
||||
logout: '#6b7280',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '2rem', maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<h1 style={{ marginBottom: '2rem', fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
📋 操作日志管理
|
||||
</h1>
|
||||
|
||||
{/* 筛选区域 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
🔍 筛选条件
|
||||
</h2>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1rem' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
时间范围
|
||||
</label>
|
||||
<select
|
||||
value={days}
|
||||
onChange={(e) => setDays(Number(e.target.value))}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
}}
|
||||
>
|
||||
<option value={1}>最近 1 天</option>
|
||||
<option value={3}>最近 3 天</option>
|
||||
<option value={7}>最近 7 天</option>
|
||||
<option value={15}>最近 15 天</option>
|
||||
<option value={30}>最近 30 天</option>
|
||||
<option value={90}>最近 90 天</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
集合
|
||||
</label>
|
||||
<select
|
||||
value={selectedCollection}
|
||||
onChange={(e) => setSelectedCollection(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
}}
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="products">商品</option>
|
||||
<option value="announcements">公告</option>
|
||||
<option value="articles">文章</option>
|
||||
<option value="media">媒体文件</option>
|
||||
<option value="users">用户</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
操作类型
|
||||
</label>
|
||||
<select
|
||||
value={selectedAction}
|
||||
onChange={(e) => setSelectedAction(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
}}
|
||||
>
|
||||
<option value="all">全部</option>
|
||||
<option value="create">创建</option>
|
||||
<option value="update">更新</option>
|
||||
<option value="delete">删除</option>
|
||||
<option value="sync">同步</option>
|
||||
<option value="login">登录</option>
|
||||
<option value="logout">登出</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
|
||||
<Button onClick={loadLogs} disabled={loading}>
|
||||
{loading ? '加载中...' : '刷新'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除日志区域 (仅管理员) */}
|
||||
{isAdmin && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
marginBottom: '1.5rem',
|
||||
border: '1px solid var(--theme-error-500)',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ marginBottom: '1rem', fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
🗑️ 删除日志(管理员)
|
||||
</h2>
|
||||
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<p style={{ marginBottom: '0.5rem' }}>快速选择:</p>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleQuickDelete(7)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
7天前
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickDelete(30)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
30天前
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleQuickDelete(90)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
fontSize: '0.875rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
90天前
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr auto', gap: '1rem', alignItems: 'end' }}>
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
开始日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={deleteStartDate}
|
||||
onChange={(e) => setDeleteStartDate(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>
|
||||
结束日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={deleteEndDate}
|
||||
onChange={(e) => setDeleteEndDate(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0.5rem',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid var(--theme-elevation-400)',
|
||||
backgroundColor: 'var(--theme-elevation-0)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleDeleteLogs} disabled={deleteLoading} buttonStyle="error">
|
||||
{deleteLoading ? '删除中...' : '删除日志'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{deleteMessage && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '1rem',
|
||||
padding: '1rem',
|
||||
backgroundColor:
|
||||
deleteMessage.includes('失败') || deleteMessage.includes('出错')
|
||||
? 'var(--theme-error-50)'
|
||||
: 'var(--theme-success-50)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '0.875rem',
|
||||
border: `1px solid ${
|
||||
deleteMessage.includes('失败') || deleteMessage.includes('出错')
|
||||
? 'var(--theme-error-500)'
|
||||
: 'var(--theme-success-500)'
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
{deleteMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 日志列表 */}
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'var(--theme-elevation-50)',
|
||||
borderRadius: '8px',
|
||||
padding: '1.5rem',
|
||||
border: '1px solid var(--theme-elevation-100)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||
<h2 style={{ fontSize: '1.25rem', fontWeight: '600' }}>
|
||||
📊 日志记录(共 {totalDocs} 条)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem' }}>加载中...</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '2rem', color: 'var(--theme-elevation-600)' }}>
|
||||
没有找到日志记录
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--theme-elevation-200)' }}>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>时间</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>操作</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>集合</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>文档</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>用户</th>
|
||||
<th style={{ padding: '0.75rem', textAlign: 'left', fontWeight: '600' }}>IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--theme-elevation-150)',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.25rem 0.75rem',
|
||||
borderRadius: '9999px',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: '600',
|
||||
backgroundColor: actionColors[log.action] + '20',
|
||||
color: actionColors[log.action],
|
||||
}}
|
||||
>
|
||||
{actionLabels[log.action] || log.action}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>{log.collection}</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
|
||||
{log.documentTitle || log.documentId || '-'}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>
|
||||
{typeof log.user === 'object' ? log.user.email : log.user}
|
||||
</td>
|
||||
<td style={{ padding: '0.75rem', fontSize: '0.875rem' }}>{log.ip || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalDocs > limit && (
|
||||
<div style={{ marginTop: '1rem', display: 'flex', justifyContent: 'center', gap: '0.5rem' }}>
|
||||
<Button onClick={() => setPage(Math.max(1, page - 1))} disabled={page === 1}>
|
||||
上一页
|
||||
</Button>
|
||||
<span style={{ padding: '0.5rem 1rem', alignItems: 'center', display: 'flex' }}>
|
||||
第 {page} / {Math.ceil(totalDocs / limit)} 页
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= Math.ceil(totalDocs / limit)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { GlobalConfig } from 'payload'
|
||||
|
||||
export const LogsManager: GlobalConfig = {
|
||||
slug: 'logs-manager',
|
||||
access: {
|
||||
read: ({ req: { user } }) => {
|
||||
// admin 和 editor 可以访问
|
||||
if (!user) return false
|
||||
return user.roles?.includes('admin') || user.roles?.includes('editor') || false
|
||||
},
|
||||
update: ({ req: { user } }) => {
|
||||
// 只有 admin 可以删除日志
|
||||
if (!user) return false
|
||||
return user.roles?.includes('admin') || false
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
group: '系统',
|
||||
description: '日志查看和管理工具',
|
||||
components: {
|
||||
views: {
|
||||
edit: {
|
||||
default: {
|
||||
Component: '/components/views/LogsManagerView',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fields: [
|
||||
{
|
||||
name: 'placeholder',
|
||||
type: 'text',
|
||||
admin: {
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import type { CollectionAfterChangeHook, CollectionAfterDeleteHook } from 'payload'
|
||||
|
||||
/**
|
||||
* 记录操作日志的通用函数
|
||||
*/
|
||||
export async function logAction({
|
||||
req,
|
||||
action,
|
||||
collection,
|
||||
documentId,
|
||||
documentTitle,
|
||||
changes,
|
||||
}: {
|
||||
req: any
|
||||
action: 'create' | 'update' | 'delete' | 'sync' | 'login' | 'logout'
|
||||
collection: string
|
||||
documentId?: string
|
||||
documentTitle?: string
|
||||
changes?: any
|
||||
}) {
|
||||
try {
|
||||
const { payload, user } = req
|
||||
|
||||
if (!user) return // 无用户信息则不记录
|
||||
|
||||
// 获取 IP 地址
|
||||
const ip =
|
||||
req.headers?.['x-forwarded-for'] ||
|
||||
req.headers?.['x-real-ip'] ||
|
||||
req.ip ||
|
||||
req.connection?.remoteAddress ||
|
||||
'unknown'
|
||||
|
||||
// 获取 User Agent
|
||||
const userAgent = req.headers?.['user-agent'] || 'unknown'
|
||||
|
||||
// 创建日志记录
|
||||
await payload.create({
|
||||
collection: 'logs',
|
||||
data: {
|
||||
action,
|
||||
collection,
|
||||
documentId: documentId?.toString(),
|
||||
documentTitle,
|
||||
user: user.id,
|
||||
changes,
|
||||
ip,
|
||||
userAgent,
|
||||
},
|
||||
// 不触发 hooks,避免递归
|
||||
context: { skipHooks: true },
|
||||
})
|
||||
} catch (error) {
|
||||
// 静默失败,避免影响主要操作
|
||||
console.error('[Log Hook Error]:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* afterChange 钩子:记录创建和更新操作
|
||||
*/
|
||||
export const logAfterChange: CollectionAfterChangeHook = async ({
|
||||
doc,
|
||||
req,
|
||||
operation,
|
||||
collection,
|
||||
}) => {
|
||||
// 跳过日志自身的操作,避免递归
|
||||
if (req.context?.skipHooks) return doc
|
||||
|
||||
const collectionSlug = collection?.slug as string
|
||||
|
||||
// 不记录 logs 和 users-sessions 自身
|
||||
if (collectionSlug === 'logs' || collectionSlug === 'users-sessions') return doc
|
||||
|
||||
await logAction({
|
||||
req,
|
||||
action: operation === 'create' ? 'create' : 'update',
|
||||
collection: collectionSlug,
|
||||
documentId: doc.id,
|
||||
documentTitle: doc.title || doc.name || doc.email || doc.alt || `ID: ${doc.id}`,
|
||||
changes: operation === 'update' ? { updatedFields: Object.keys(doc) } : undefined,
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
||||
/**
|
||||
* afterDelete 钩子:记录删除操作
|
||||
*/
|
||||
export const logAfterDelete: CollectionAfterDeleteHook = async ({ doc, req, collection }) => {
|
||||
if (req.context?.skipHooks) return doc
|
||||
|
||||
const collectionSlug = collection?.slug as string
|
||||
|
||||
if (collectionSlug === 'logs' || collectionSlug === 'users-sessions') return doc
|
||||
|
||||
await logAction({
|
||||
req,
|
||||
action: 'delete',
|
||||
collection: collectionSlug,
|
||||
documentId: doc.id,
|
||||
documentTitle: doc.title || doc.name || doc.email || doc.alt || `ID: ${doc.id}`,
|
||||
})
|
||||
|
||||
return doc
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ export interface Config {
|
|||
products: Product;
|
||||
announcements: Announcement;
|
||||
articles: Article;
|
||||
logs: Log;
|
||||
'payload-kv': PayloadKv;
|
||||
'payload-jobs': PayloadJob;
|
||||
'payload-locked-documents': PayloadLockedDocument;
|
||||
|
|
@ -85,6 +86,7 @@ export interface Config {
|
|||
products: ProductsSelect<false> | ProductsSelect<true>;
|
||||
announcements: AnnouncementsSelect<false> | AnnouncementsSelect<true>;
|
||||
articles: ArticlesSelect<false> | ArticlesSelect<true>;
|
||||
logs: LogsSelect<false> | LogsSelect<true>;
|
||||
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
|
||||
'payload-jobs': PayloadJobsSelect<false> | PayloadJobsSelect<true>;
|
||||
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
|
||||
|
|
@ -97,9 +99,11 @@ export interface Config {
|
|||
fallbackLocale: null;
|
||||
globals: {
|
||||
'admin-settings': AdminSetting;
|
||||
'logs-manager': LogsManager;
|
||||
};
|
||||
globalsSelect: {
|
||||
'admin-settings': AdminSettingsSelect<false> | AdminSettingsSelect<true>;
|
||||
'logs-manager': LogsManagerSelect<false> | LogsManagerSelect<true>;
|
||||
};
|
||||
locale: null;
|
||||
user: User;
|
||||
|
|
@ -386,6 +390,57 @@ export interface Article {
|
|||
createdAt: string;
|
||||
_status?: ('draft' | 'published') | null;
|
||||
}
|
||||
/**
|
||||
* 系统操作日志记录
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "logs".
|
||||
*/
|
||||
export interface Log {
|
||||
id: number;
|
||||
/**
|
||||
* 操作类型
|
||||
*/
|
||||
action: 'create' | 'update' | 'delete' | 'sync' | 'login' | 'logout';
|
||||
/**
|
||||
* 操作的集合名称
|
||||
*/
|
||||
collection: string;
|
||||
/**
|
||||
* 文档 ID
|
||||
*/
|
||||
documentId?: string | null;
|
||||
/**
|
||||
* 文档标题
|
||||
*/
|
||||
documentTitle?: string | null;
|
||||
/**
|
||||
* 操作用户
|
||||
*/
|
||||
user: number | User;
|
||||
/**
|
||||
* 变更内容(JSON 格式)
|
||||
*/
|
||||
changes?:
|
||||
| {
|
||||
[k: string]: unknown;
|
||||
}
|
||||
| unknown[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
/**
|
||||
* IP 地址
|
||||
*/
|
||||
ip?: string | null;
|
||||
/**
|
||||
* 浏览器信息
|
||||
*/
|
||||
userAgent?: string | null;
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv".
|
||||
|
|
@ -521,6 +576,10 @@ export interface PayloadLockedDocument {
|
|||
| ({
|
||||
relationTo: 'articles';
|
||||
value: number | Article;
|
||||
} | null)
|
||||
| ({
|
||||
relationTo: 'logs';
|
||||
value: number | Log;
|
||||
} | null);
|
||||
globalSlug?: string | null;
|
||||
user: {
|
||||
|
|
@ -664,6 +723,22 @@ export interface ArticlesSelect<T extends boolean = true> {
|
|||
createdAt?: T;
|
||||
_status?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "logs_select".
|
||||
*/
|
||||
export interface LogsSelect<T extends boolean = true> {
|
||||
action?: T;
|
||||
collection?: T;
|
||||
documentId?: T;
|
||||
documentTitle?: T;
|
||||
user?: T;
|
||||
changes?: T;
|
||||
ip?: T;
|
||||
userAgent?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "payload-kv_select".
|
||||
|
|
@ -747,6 +822,18 @@ export interface AdminSetting {
|
|||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* 日志查看和管理工具
|
||||
*
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "logs-manager".
|
||||
*/
|
||||
export interface LogsManager {
|
||||
id: number;
|
||||
placeholder?: string | null;
|
||||
updatedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "admin-settings_select".
|
||||
|
|
@ -757,6 +844,16 @@ export interface AdminSettingsSelect<T extends boolean = true> {
|
|||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "logs-manager_select".
|
||||
*/
|
||||
export interface LogsManagerSelect<T extends boolean = true> {
|
||||
placeholder?: T;
|
||||
updatedAt?: T;
|
||||
createdAt?: T;
|
||||
globalType?: T;
|
||||
}
|
||||
/**
|
||||
* This interface was referenced by `Config`'s JSON-Schema
|
||||
* via the `definition` "TaskSchedulePublish".
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import { Media } from './collections/Media'
|
|||
import { Products } from './collections/Products'
|
||||
import { Announcements } from './collections/Announcements'
|
||||
import { Articles } from './collections/Articles'
|
||||
import { Logs } from './collections/Logs'
|
||||
import { AdminSettings } from './globals/AdminSettings'
|
||||
import { LogsManager } from './globals/LogsManager'
|
||||
import { s3Storage } from '@payloadcms/storage-s3'
|
||||
import { en } from '@payloadcms/translations/languages/en'
|
||||
import { zh } from '@payloadcms/translations/languages/zh'
|
||||
|
|
@ -42,8 +44,8 @@ export default buildConfig({
|
|||
},
|
||||
fallbackLanguage: 'zh',
|
||||
},
|
||||
collections: [Users, Media, Products, Announcements, Articles],
|
||||
globals: [AdminSettings],
|
||||
collections: [Users, Media, Products, Announcements, Articles, Logs],
|
||||
globals: [AdminSettings, LogsManager],
|
||||
editor: lexicalEditor(),
|
||||
secret: process.env.PAYLOAD_SECRET || '',
|
||||
typescript: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue