chenpei c59a627554 fix: 修复操作日志关键词搜索截断 + 重做数据统计面板 UI
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 结构
2026-06-26 14:20:08 +08:00

290 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 })
}
}