1. material-operation-logs 接口 P1-1 修复: - 关键词搜索从 take:5000 内存过滤改为 DB where 下推 - 修复 total 被截断的问题(线上日志 5.7 万条,原搜索漏报率 60%) - 行为完全等价:匹配用户名/姓名/部门/素材型号/类型/snapshot 兜底 - 无关键词分支逻辑零改动 2. DataStatisticsTab 面板 UI 重做(对齐首页 Apple 基线): - 修复柱状图文字不可见 bug(text-gray-100 → text-gray-700) - 柱子加宽(w-4 → w-8)+ 间距优化(gap-0.5 → gap-2) - 汇总卡片:白底卡片 + 左侧色条 + 图标徽章 - 统一 rounded-apple / shadow-apple 设计语言 - 配色收敛为 sky/teal/violet 克制体系 - 补全 CalendarDateInput 日历弹层样式(之前遗漏) - 所有功能逻辑零改动,只动 className 和 JSX 结构
290 lines
10 KiB
TypeScript
290 lines
10 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getServerSession } from 'next-auth'
|
||
import { authOptions } from '@/lib/auth'
|
||
import { prisma } from '@/lib/prisma'
|
||
import { isSystemAdmin } from '@/lib/department-permission'
|
||
import { safeJsonParse } from '@/lib/safe-json'
|
||
|
||
const VALID_ACTIONS = new Set(['ALL', 'UPLOAD', 'UPDATE', 'DELETE', 'DOWNLOAD', 'RESTORE'])
|
||
|
||
function toDateStart(dateString: string): Date {
|
||
return new Date(`${dateString}T00:00:00`)
|
||
}
|
||
|
||
function toDateEnd(dateString: string): Date {
|
||
return new Date(`${dateString}T23:59:59.999`)
|
||
}
|
||
|
||
function normalizeKeyword(keyword: string | null): string {
|
||
return (keyword || '').trim()
|
||
}
|
||
|
||
function getSnapshotMaterialInfo(changes: Record<string, unknown> | null): {
|
||
productModel?: string
|
||
materialType?: string
|
||
} {
|
||
if (!changes) return {}
|
||
const snapshot = changes.materialSnapshot
|
||
if (!snapshot || typeof snapshot !== 'object') return {}
|
||
|
||
const info = snapshot as Record<string, unknown>
|
||
return {
|
||
productModel: typeof info.productModel === 'string' ? info.productModel : undefined,
|
||
materialType: typeof info.materialType === 'string' ? info.materialType : undefined,
|
||
}
|
||
}
|
||
|
||
export async function GET(request: NextRequest) {
|
||
try {
|
||
const session = await getServerSession(authOptions)
|
||
|
||
if (!session?.user || !isSystemAdmin((session.user as any).role)) {
|
||
return NextResponse.json({ error: '无权限' }, { status: 403 })
|
||
}
|
||
|
||
const { searchParams } = new URL(request.url)
|
||
const requestedAction = (searchParams.get('action') || 'ALL').toUpperCase()
|
||
const action = VALID_ACTIONS.has(requestedAction) ? requestedAction : 'ALL'
|
||
|
||
const page = Math.max(Number(searchParams.get('page') || '1'), 1)
|
||
const pageSize = Math.min(Math.max(Number(searchParams.get('pageSize') || '20'), 1), 100)
|
||
const startDate = searchParams.get('startDate')
|
||
const endDate = searchParams.get('endDate')
|
||
const keyword = normalizeKeyword(searchParams.get('keyword'))
|
||
|
||
const materialLogWhere: any = {}
|
||
const downloadLogWhere: any = {}
|
||
|
||
if (startDate || endDate) {
|
||
const timestampFilter: { gte?: Date; lte?: Date } = {}
|
||
if (startDate) {
|
||
timestampFilter.gte = toDateStart(startDate)
|
||
}
|
||
if (endDate) {
|
||
timestampFilter.lte = toDateEnd(endDate)
|
||
}
|
||
materialLogWhere.timestamp = timestampFilter
|
||
downloadLogWhere.downloadedAt = timestampFilter
|
||
}
|
||
|
||
if (action === 'ALL') {
|
||
materialLogWhere.action = { not: 'DOWNLOAD' }
|
||
} else if (action !== 'DOWNLOAD') {
|
||
materialLogWhere.action = action
|
||
}
|
||
|
||
const includeMaterialLogs = action !== 'DOWNLOAD'
|
||
const includeDownloadLogs = action === 'ALL' || action === 'DOWNLOAD'
|
||
|
||
// 关键词过滤下推到 DB 端:把原"take:5000 内存过滤"改为精确 where,避免 total 被截断。
|
||
// 匹配范围与原内存过滤完全一致:
|
||
// user: username / realName / department / departmentRef.name
|
||
// material: id / productModel / materialType
|
||
// snapshot 兜底: changes.materialSnapshot.productModel / materialType(JSON 字符串,无法 DB 端匹配,单独补齐)
|
||
if (keyword) {
|
||
const [matchedUsers, matchedMaterials, snapshotMaterialLogs] = await Promise.all([
|
||
prisma.user.findMany({
|
||
where: {
|
||
OR: [
|
||
{ username: { contains: keyword, mode: 'insensitive' } },
|
||
{ realName: { contains: keyword, mode: 'insensitive' } },
|
||
{ department: { contains: keyword, mode: 'insensitive' } },
|
||
{ departmentRef: { name: { contains: keyword, mode: 'insensitive' } } },
|
||
],
|
||
},
|
||
select: { id: true },
|
||
take: 2000,
|
||
}),
|
||
prisma.material.findMany({
|
||
where: {
|
||
OR: [
|
||
{ id: { contains: keyword, mode: 'insensitive' } },
|
||
{ productModel: { contains: keyword, mode: 'insensitive' } },
|
||
{ materialType: { contains: keyword, mode: 'insensitive' } },
|
||
],
|
||
},
|
||
select: { id: true },
|
||
take: 2000,
|
||
}),
|
||
// snapshot 兜底:changes 是 JSON 字符串,DB 端无法精确匹配嵌套字段,
|
||
// 用 contains 匹配 changes 字符串本身,命中后再取 materialId 补进结果集。
|
||
// (为避免全表扫描,限定只扫 material_logs,download_logs 无 changes 字段)
|
||
prisma.materialLog.findMany({
|
||
where: {
|
||
changes: { contains: keyword, mode: 'insensitive' },
|
||
},
|
||
select: { materialId: true },
|
||
take: 2000,
|
||
}),
|
||
])
|
||
|
||
const matchedUserIds = matchedUsers.map((u) => u.id)
|
||
const matchedMaterialIds = new Set<string>(matchedMaterials.map((m) => m.id))
|
||
snapshotMaterialLogs.forEach((log) => matchedMaterialIds.add(log.materialId))
|
||
const allMatchedMaterialIds = Array.from(matchedMaterialIds)
|
||
|
||
// 既不命中用户也不命中素材 → 直接返回空结果,避免全表扫描
|
||
if (matchedUserIds.length === 0 && allMatchedMaterialIds.length === 0) {
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: { logs: [], page, pageSize, total: 0, totalPages: 0 },
|
||
})
|
||
}
|
||
|
||
// OR 关系:日志命中用户 或 命中素材都算匹配
|
||
const keywordFilter = {
|
||
OR: [
|
||
...(matchedUserIds.length > 0 ? [{ userId: { in: matchedUserIds } }] : []),
|
||
...(allMatchedMaterialIds.length > 0 ? [{ materialId: { in: allMatchedMaterialIds } }] : []),
|
||
],
|
||
}
|
||
materialLogWhere.OR = keywordFilter.OR
|
||
downloadLogWhere.OR = keywordFilter.OR
|
||
}
|
||
|
||
// 统一使用真实分页的 take(keyword 已下推到 where,不再需要内存过滤的 5000 上限)
|
||
const fetchLimit = page * pageSize
|
||
|
||
const [materialLogTotal, downloadLogTotal, materialLogs, downloadLogs] = await Promise.all([
|
||
includeMaterialLogs ? prisma.materialLog.count({ where: materialLogWhere }) : Promise.resolve(0),
|
||
includeDownloadLogs ? prisma.materialDownloadLog.count({ where: downloadLogWhere }) : Promise.resolve(0),
|
||
includeMaterialLogs
|
||
? prisma.materialLog.findMany({
|
||
where: materialLogWhere,
|
||
orderBy: { timestamp: 'desc' },
|
||
take: fetchLimit,
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
department: true,
|
||
departmentId: true,
|
||
departmentRef: {
|
||
select: {
|
||
name: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
: Promise.resolve([]),
|
||
includeDownloadLogs
|
||
? prisma.materialDownloadLog.findMany({
|
||
where: downloadLogWhere,
|
||
orderBy: { downloadedAt: 'desc' },
|
||
take: fetchLimit,
|
||
include: {
|
||
user: {
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
department: true,
|
||
departmentId: true,
|
||
departmentRef: {
|
||
select: {
|
||
name: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
})
|
||
: Promise.resolve([]),
|
||
])
|
||
|
||
const materialIds = Array.from(
|
||
new Set([
|
||
...materialLogs.map((log) => log.materialId),
|
||
...downloadLogs.map((log) => log.materialId),
|
||
])
|
||
)
|
||
|
||
const materials = materialIds.length
|
||
? await prisma.material.findMany({
|
||
where: { id: { in: materialIds } },
|
||
select: {
|
||
id: true,
|
||
productModel: true,
|
||
materialType: true,
|
||
},
|
||
})
|
||
: []
|
||
|
||
const materialMap = new Map(materials.map((material) => [material.id, material]))
|
||
|
||
const formattedMaterialLogs = materialLogs.map((log) => {
|
||
const parsedChanges = log.changes
|
||
? safeJsonParse<Record<string, unknown>>(log.changes, {}, false)
|
||
: null
|
||
const relatedMaterial = materialMap.get(log.materialId)
|
||
const snapshotInfo = getSnapshotMaterialInfo(parsedChanges)
|
||
|
||
return {
|
||
id: `material-${log.id}`,
|
||
source: 'material_log',
|
||
action: log.action,
|
||
timestamp: log.timestamp.toISOString(),
|
||
user: {
|
||
id: log.user.id,
|
||
username: log.user.username,
|
||
realName: log.user.realName,
|
||
departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门',
|
||
},
|
||
material: {
|
||
id: log.materialId,
|
||
productModel: relatedMaterial?.productModel || snapshotInfo.productModel || '已删除素材',
|
||
materialType: relatedMaterial?.materialType || snapshotInfo.materialType || '未知类型',
|
||
},
|
||
changes: parsedChanges,
|
||
}
|
||
})
|
||
|
||
const formattedDownloadLogs = downloadLogs.map((log) => ({
|
||
id: `download-${log.id}`,
|
||
source: 'download_log',
|
||
action: 'DOWNLOAD',
|
||
timestamp: log.downloadedAt.toISOString(),
|
||
user: {
|
||
id: log.user.id,
|
||
username: log.user.username,
|
||
realName: log.user.realName,
|
||
departmentName: log.user.departmentRef?.name || log.user.department || '未分配部门',
|
||
},
|
||
material: {
|
||
id: log.materialId,
|
||
productModel: materialMap.get(log.materialId)?.productModel || '已删除素材',
|
||
materialType: materialMap.get(log.materialId)?.materialType || '未知类型',
|
||
},
|
||
changes: null,
|
||
}))
|
||
|
||
const mergedLogs = [...formattedMaterialLogs, ...formattedDownloadLogs].sort(
|
||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
||
)
|
||
|
||
// keyword 已下推到 DB where,mergedLogs 即为命中结果,无需内存过滤
|
||
const offset = (page - 1) * pageSize
|
||
const paginatedLogs = mergedLogs.slice(offset, offset + pageSize)
|
||
// total 来自 DB 真实 count(keyword 已注入 where,不再截断)
|
||
const total = materialLogTotal + downloadLogTotal
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
logs: paginatedLogs,
|
||
page,
|
||
pageSize,
|
||
total,
|
||
totalPages: Math.ceil(total / pageSize),
|
||
},
|
||
})
|
||
} catch (error) {
|
||
console.error('[Admin Material Operation Logs] GET error:', error)
|
||
return NextResponse.json({ error: '获取素材操作日志失败' }, { status: 500 })
|
||
}
|
||
}
|