Files
chinese-family-tree-2/components/settings/activity-log-viewer.tsx
T
freedakgmail b25f4bea02 0.0.9.0
2025-11-24 16:07:24 +08:00

490 lines
17 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.
"use client"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from "@/components/ui/badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { History, Trash2, RefreshCw, User, FileText, Image, Settings, Globe, Filter } from "lucide-react"
import { format } from "date-fns"
import { useFamily } from "@/context/family-context"
import { useSession } from "next-auth/react"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
// 定义日志类型
interface ActivityLog {
id: string
action: "CREATE" | "UPDATE" | "DELETE" | "IMPORT" | "EXPORT"
entityType: "MEMBER" | "PHOTO" | "STORY" | "SETTINGS"
entityId?: string | null
entityName?: string | null
changes?: any
timestamp: string
treeId: string
userId: string
user?: {
name?: string | null
email?: string | null
}
tree?: {
id: string
name: string
}
}
const actionLabels: Record<string, string> = {
CREATE: "创建",
UPDATE: "更新",
DELETE: "删除",
IMPORT: "导入",
EXPORT: "导出",
}
const actionColors: Record<string, string> = {
CREATE: "bg-green-500",
UPDATE: "bg-blue-500",
DELETE: "bg-red-500",
IMPORT: "bg-purple-500",
EXPORT: "bg-orange-500",
}
const entityTypeLabels: Record<string, string> = {
MEMBER: "成员",
PHOTO: "照片",
STORY: "故事",
SETTINGS: "设置",
}
const entityTypeIcons: Record<string, any> = {
MEMBER: User,
PHOTO: Image,
STORY: FileText,
SETTINGS: Settings,
}
interface FamilyTree {
id: string
name: string
ownerId: string
currentUserRole?: "OWNER" | "EDITOR" | "VIEWER"
}
export function ActivityLogViewer() {
const { currentTree } = useFamily()
const { data: session } = useSession()
const { showAlert, showConfirm, showPrompt } = useDialog()
const searchParams = useSearchParams()
const [logs, setLogs] = useState<ActivityLog[]>([])
const [trees, setTrees] = useState<FamilyTree[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [filterTreeId, setFilterTreeId] = useState<string>("all")
const [clearing, setClearing] = useState(false)
// 加载家族树列表
useEffect(() => {
// 只在用户登录时加载
if (!session?.user?.id) return
const controller = new AbortController()
fetch('/api/trees', { signal: controller.signal })
.then(res => res.json())
.then(data => {
if (data.trees) {
setTrees(data.trees)
}
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error("加载家族树列表失败:", err)
}
})
return () => controller.abort()
}, [session?.user?.id])
// 从 URL 参数或 currentTree 获取初始 treeId
// 只有在 trees 加载完成后才设置 filterTreeId
useEffect(() => {
if (trees.length === 0) return // 等待 trees 加载完成
const urlTreeId = searchParams.get('treeId')
if (urlTreeId) {
// 优先使用 URL 中的 treeId
// 确保这个 treeId 在 trees 列表中存在
const treeExists = trees.some(t => t.id === urlTreeId)
if (treeExists) {
setFilterTreeId(urlTreeId)
}
} else if (currentTree?.id) {
// 如果 URL 中没有,使用 currentTree
const treeExists = trees.some(t => t.id === currentTree.id)
if (treeExists) {
setFilterTreeId(currentTree.id)
}
}
}, [searchParams, currentTree?.id, trees])
const loadLogs = useCallback(async (signal?: AbortSignal) => {
setLoading(true)
setError(null)
try {
let url = ""
if (filterTreeId === "all") {
url = `/api/user/activity-logs?limit=100`
} else {
url = `/api/trees/${filterTreeId}/activity-logs?limit=100`
}
const res = await fetch(url, { signal })
if (!res.ok) throw new Error("获取日志失败")
const data = await res.json()
setLogs(data.logs || [])
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error("加载日志失败:", err)
setError(err.message)
}
} finally {
setLoading(false)
}
}, [filterTreeId])
useEffect(() => {
// 只在用户登录时加载日志
if (!session?.user?.id) return
const controller = new AbortController()
loadLogs(controller.signal)
return () => controller.abort()
}, [loadLogs, session?.user?.id])
// 检查当前用户是否是选中家族树的所有者
const isOwner = () => {
if (filterTreeId === "all") return false
const selectedTree = trees.find(t => t.id === filterTreeId)
if (!selectedTree) return false
// 检查是否是所有者
return selectedTree.ownerId === session?.user?.id || selectedTree.currentUserRole === "OWNER"
}
// 格式化变更详情,使其更易读
const formatChanges = (changes: any, action: string) => {
if (!changes || typeof changes !== 'object') return null
const fieldLabels: Record<string, string> = {
fullName: '姓名',
surname: '姓氏',
givenName: '名字',
gender: '性别',
birthDate: '出生日期',
deathDate: '去世日期',
birthPlace: '出生地',
ancestralHome: '祖籍',
generation: '世代',
generationName: '字辈',
courtesyName: '字',
artName: '号',
posthumousName: '谥号',
rank: '排行',
bio: '简介',
phone: '手机',
telephone: '电话',
email: '邮箱',
address: '地址',
photoIds: '照片',
spouseIds: '配偶',
childrenIds: '子女',
motherId: '母亲',
fatherId: '父亲',
isFounder: '始祖',
isLunarDate: '农历日期',
burialPlace: '安葬地',
tags: '标签',
}
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '无'
if (key === 'gender') {
return value === 'MALE' ? '男' : value === 'FEMALE' ? '女' : '未知'
}
if (key === 'tags' && Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '无'
}
if (Array.isArray(value)) {
return `${value.length}`
}
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (typeof value === 'string' && value.length > 50) {
return value.substring(0, 50) + '...'
}
return String(value)
}
const getChangeDescription = (key: string, value: any) => {
const label = fieldLabels[key] || key
// 如果是对象且包含 old 和 new,说明是修改
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
const oldVal = formatValue(key, value.old)
const newVal = formatValue(key, value.new)
// 特殊处理数组变化
if (key === 'photoIds') {
const oldCount = Array.isArray(value.old) ? value.old.length : 0
const newCount = Array.isArray(value.new) ? value.new.length : 0
if (newCount > oldCount) {
return `添加了${label}:新增 ${newCount - oldCount}`
} else if (newCount < oldCount) {
return `删除了${label}:减少 ${oldCount - newCount}`
}
}
if (key === 'childrenIds' || key === 'spouseIds') {
const oldCount = Array.isArray(value.old) ? value.old.length : 0
const newCount = Array.isArray(value.new) ? value.new.length : 0
if (newCount > oldCount) {
return `添加了${label}:新增 ${newCount - oldCount}`
} else if (newCount < oldCount) {
return `删除了${label}:减少 ${oldCount - newCount}`
}
}
return `修改了${label}:从 "${oldVal}" → "${newVal}"`
}
return null
}
const entries = Object.entries(changes)
.filter(([key]) => fieldLabels[key]) // 只显示有标签的字段
.map(([key, value]: [string, any]) => {
const description = getChangeDescription(key, value)
if (!description) return null
return (
<div key={key} className="text-xs py-1.5 text-muted-foreground">
{description}
</div>
)
})
.filter(Boolean) // 移除 null 值
return entries.length > 0 ? entries : null
}
const handleClearLogs = async () => {
if (filterTreeId === "all") {
await showAlert("请先选择一个具体的家族树再清空日志", "提示")
return
}
const selectedTree = trees.find(t => t.id === filterTreeId)
if (!selectedTree) {
await showAlert("未找到选中的家族树", "错误")
return
}
// 第一次确认
const confirmMessage = `⚠️ 警告:即将清空"${selectedTree.name}"的所有操作日志!\n\n此操作将永久删除该家族树的所有历史记录,且不可恢复。\n\n确定要继续吗?`
const confirmed = await showConfirm(confirmMessage, "清空操作日志", "destructive")
if (!confirmed) {
return
}
// 第二次确认
const finalConfirm = await showPrompt(
`请输入家族树名称"${selectedTree.name}"以确认清空操作:\n\n(此操作不可撤销)`,
"",
"确认清空"
)
if (finalConfirm !== selectedTree.name) {
await showAlert("名称不匹配,操作已取消", "操作已取消")
return
}
setClearing(true)
try {
const res = await fetch(`/api/trees/${filterTreeId}/activity-logs`, {
method: 'DELETE',
})
if (!res.ok) {
const error = await res.json()
throw new Error(error.error || "清空失败")
}
const data = await res.json()
await showAlert(data.message || "操作日志已清空", "成功")
// 重新加载日志
await loadLogs()
} catch (err) {
console.error("清空日志失败:", err)
await showAlert("清空失败:" + (err instanceof Error ? err.message : "未知错误"), "错误")
} finally {
setClearing(false)
}
}
// 如果用户未登录,不显示日志查看器
if (!session?.user?.id) {
return null
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
</CardTitle>
<CardDescription>
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={() => loadLogs()}
disabled={loading || clearing}
className="h-9 w-9"
title="刷新日志"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
{isOwner() && (
<Button
variant="destructive"
size="sm"
onClick={handleClearLogs}
disabled={loading || clearing}
className="gap-2"
title="清空当前家族树的操作日志(仅所有者可用)"
>
<Trash2 className="h-4 w-4" />
{clearing ? "清空中..." : "清空日志"}
</Button>
)}
<div className="w-[200px]">
<Select value={filterTreeId} onValueChange={setFilterTreeId}>
<SelectTrigger>
<div className="flex items-center gap-2">
<Filter className="h-4 w-4 text-muted-foreground" />
<SelectValue placeholder="筛选家族树" />
</div>
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{trees.map(tree => (
<SelectItem key={tree.id} value={tree.id}>
{tree.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
{error ? (
<div className="text-center py-8 text-destructive">
: {error}
</div>
) : loading && logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
...
</div>
) : logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
</div>
) : (
<div className="space-y-3">
{logs.map((log) => {
const EntityIcon = entityTypeIcons[log.entityType] || FileText
return (
<div
key={log.id}
className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg hover:bg-muted transition-colors"
>
<div className={`p-2 rounded-full ${actionColors[log.action]} bg-opacity-10`}>
<EntityIcon className={`h-4 w-4 ${actionColors[log.action].replace('bg-', 'text-')}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<Badge variant="outline" className="text-xs">
{actionLabels[log.action] || log.action}
</Badge>
<Badge variant="secondary" className="text-xs">
{entityTypeLabels[log.entityType] || log.entityType}
</Badge>
{filterTreeId === "all" && log.tree && (
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
{log.tree.name}
</Badge>
)}
{log.entityName && (
log.entityId && log.entityType === 'MEMBER' ? (
<Link
href={`/members/${log.entityId}?treeId=${log.treeId}`}
className="text-sm font-medium truncate hover:text-primary hover:underline transition-colors"
>
{log.entityName}
</Link>
) : (
<span className="text-sm font-medium truncate">
{log.entityName}
</span>
)
)}
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{log.user?.name || log.user?.email || "未知用户"}
</p>
<p className="text-xs text-muted-foreground">
{format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}
</p>
</div>
{log.action === 'UPDATE' && (
<div className="mt-2 pl-2 border-l-2 border-muted">
{log.changes && formatChanges(log.changes, log.action) ? (
formatChanges(log.changes, log.action)
) : (
<div className="text-xs py-1.5 text-muted-foreground italic">
</div>
)}
</div>
)}
</div>
</div>
)
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
</div>
)
}