0.0.0.5
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode
|
||||
fallback?: React.ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error("ErrorBoundary 捕获错误:", error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
this.props.fallback || (
|
||||
<div className="p-4 border border-destructive rounded-lg bg-destructive/10">
|
||||
<h3 className="text-lg font-bold text-destructive mb-2">组件加载失败</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{this.state.error?.message || "未知错误"}
|
||||
</p>
|
||||
<button
|
||||
className="mt-4 px-4 py-2 bg-primary text-primary-foreground rounded"
|
||||
onClick={() => this.setState({ hasError: false })}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
+192
-127
@@ -23,6 +23,11 @@ interface MemberFormProps {
|
||||
}
|
||||
|
||||
export function MemberForm({ initialData, existingMembers = [], onSubmit, onCancel }: MemberFormProps) {
|
||||
// 计算最大世系
|
||||
const maxGeneration = existingMembers.length > 0
|
||||
? Math.max(...existingMembers.map(m => m.generation || 1))
|
||||
: 1
|
||||
|
||||
// Initialize state with default values or initialData
|
||||
const [formData, setFormData] = useState<Partial<FamilyMember>>({
|
||||
id: initialData?.id || crypto.randomUUID(),
|
||||
@@ -30,7 +35,7 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
givenName: initialData?.givenName || "",
|
||||
fullName: initialData?.fullName || "",
|
||||
gender: initialData?.gender || "male",
|
||||
generation: initialData?.generation || 1,
|
||||
generation: initialData?.generation || maxGeneration,
|
||||
spouseIds: initialData?.spouseIds || [],
|
||||
childrenIds: initialData?.childrenIds || [],
|
||||
fatherId: initialData?.fatherId,
|
||||
@@ -43,10 +48,32 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
const handleChange = (field: keyof FamilyMember, value: any) => {
|
||||
setFormData((prev) => {
|
||||
const newData = { ...prev, [field]: value }
|
||||
|
||||
// Auto-update full name if surname or given name changes
|
||||
if (field === "surname" || field === "givenName") {
|
||||
newData.fullName = (newData.surname || "") + (newData.givenName || "")
|
||||
}
|
||||
|
||||
// 选择父亲时自动带出母亲
|
||||
if (field === "fatherId" && value) {
|
||||
const father = existingMembers.find(m => m.id === value)
|
||||
if (father && father.spouseIds && father.spouseIds.length > 0) {
|
||||
// 找到父亲的配偶(母亲)
|
||||
const motherId = father.spouseIds[0]
|
||||
newData.motherId = motherId
|
||||
}
|
||||
}
|
||||
|
||||
// 选择母亲时自动带出父亲
|
||||
if (field === "motherId" && value) {
|
||||
const mother = existingMembers.find(m => m.id === value)
|
||||
if (mother && mother.spouseIds && mother.spouseIds.length > 0) {
|
||||
// 找到母亲的配偶(父亲)
|
||||
const fatherId = mother.spouseIds[0]
|
||||
newData.fatherId = fatherId
|
||||
}
|
||||
}
|
||||
|
||||
return newData
|
||||
})
|
||||
// Clear errors when user makes changes
|
||||
@@ -174,67 +201,180 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Identity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息 (Identity)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ImageUpload
|
||||
value={formData.avatarImageId}
|
||||
onChange={(imageId) => handleChange("avatarImageId", imageId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="surname">姓 (Surname)</Label>
|
||||
<Input
|
||||
id="surname"
|
||||
value={formData.surname}
|
||||
onChange={(e) => handleChange("surname", e.target.value)}
|
||||
<div className="space-y-6">
|
||||
{/* 基本信息和家庭关系 - 两列布局 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 左列:基本信息 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<User className="h-5 w-5" />
|
||||
基本信息 (Identity)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<ImageUpload
|
||||
value={formData.avatarImageId}
|
||||
onChange={(imageId) => handleChange("avatarImageId", imageId)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="givenName">名 (Given Name)</Label>
|
||||
<Input
|
||||
id="givenName"
|
||||
value={formData.givenName}
|
||||
onChange={(e) => handleChange("givenName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="surname">姓 (Surname)</Label>
|
||||
<Input
|
||||
id="surname"
|
||||
value={formData.surname}
|
||||
onChange={(e) => handleChange("surname", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="givenName">名 (Given Name)</Label>
|
||||
<Input
|
||||
id="givenName"
|
||||
value={formData.givenName}
|
||||
onChange={(e) => handleChange("givenName", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">性别 (Gender)</Label>
|
||||
<Select value={formData.gender} onValueChange={(val) => handleChange("gender", val)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">男 (Male)</SelectItem>
|
||||
<SelectItem value="female">女 (Female)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generation">世系 (Generation)</Label>
|
||||
<Input
|
||||
id="generation"
|
||||
type="number"
|
||||
value={formData.generation}
|
||||
onChange={(e) => handleChange("generation", Number.parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右列:家庭关系 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<Users className="h-5 w-5" />
|
||||
家庭关系 (Relationships)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="gender">性别 (Gender)</Label>
|
||||
<Select value={formData.gender} onValueChange={(val) => handleChange("gender", val)}>
|
||||
<Label htmlFor="fatherId">父亲 (Father)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formData.fatherId || "none"}
|
||||
onValueChange={(val) => handleChange("fatherId", val === "none" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="选择父亲" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">无 (None)</SelectItem>
|
||||
{potentialRelatives
|
||||
.filter((m) => m.gender === "male" && m.generation === (formData.generation || 1) - 1)
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.fullName} ({m.generation}世)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!formData.fatherId && (
|
||||
<Input
|
||||
placeholder="非家族成员填写姓名"
|
||||
value={formData.spouseFatherName || ""}
|
||||
onChange={(e) => handleChange("spouseFatherName", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motherId">母亲 (Mother)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={formData.motherId || "none"}
|
||||
onValueChange={(val) => handleChange("motherId", val === "none" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="选择母亲" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">无 (None)</SelectItem>
|
||||
{potentialRelatives
|
||||
.filter((m) => m.gender === "female" && m.generation === (formData.generation || 1) - 1)
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.fullName} ({m.generation}世)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!formData.motherId && (
|
||||
<Input
|
||||
placeholder="非家族成员填写姓名"
|
||||
value={formData.spouseMotherName || ""}
|
||||
onChange={(e) => handleChange("spouseMotherName", e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spouseId">配偶 (Spouse)</Label>
|
||||
<Select value={formData.spouseIds?.[0] || "none"} onValueChange={handleSpouseChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="选择配偶 Select Spouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">男 (Male)</SelectItem>
|
||||
<SelectItem value="female">女 (Female)</SelectItem>
|
||||
<SelectItem value="none">无 (None)</SelectItem>
|
||||
{potentialRelatives
|
||||
.filter((m) => {
|
||||
// 必须是同世系
|
||||
if (m.generation !== formData.generation) return false
|
||||
|
||||
// 排除直系亲属
|
||||
// 1. 排除父母
|
||||
if (m.id === formData.fatherId || m.id === formData.motherId) return false
|
||||
|
||||
// 2. 排除子女
|
||||
if (formData.childrenIds?.includes(m.id)) return false
|
||||
|
||||
// 3. 排除兄弟姐妹(同父或同母)
|
||||
if (formData.fatherId && m.fatherId === formData.fatherId) return false
|
||||
if (formData.motherId && m.motherId === formData.motherId) return false
|
||||
|
||||
return true
|
||||
})
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.fullName} ({m.generation}世)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="generation">世系 (Generation)</Label>
|
||||
<Input
|
||||
id="generation"
|
||||
type="number"
|
||||
value={formData.generation}
|
||||
onChange={(e) => handleChange("generation", Number.parseInt(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Traditional Names */}
|
||||
<Card>
|
||||
@@ -439,81 +579,6 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Relationships */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<Users className="h-5 w-5" />
|
||||
家庭关系 (Relationships)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fatherId">父亲 (Father)</Label>
|
||||
<Select
|
||||
value={formData.fatherId || "none"}
|
||||
onValueChange={(val) => handleChange("fatherId", val === "none" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择父亲 Select Father" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">无 (None)</SelectItem>
|
||||
{potentialRelatives
|
||||
.filter((m) => m.gender === "male")
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.fullName} ({m.generation}世)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="motherId">母亲 (Mother)</Label>
|
||||
<Select
|
||||
value={formData.motherId || "none"}
|
||||
onValueChange={(val) => handleChange("motherId", val === "none" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择母亲 Select Mother" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">无 (None)</SelectItem>
|
||||
{potentialRelatives
|
||||
.filter((m) => m.gender === "female")
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.fullName} ({m.generation}世)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="spouseId">配偶 (Spouse)</Label>
|
||||
<Select value={formData.spouseIds?.[0] || "none"} onValueChange={handleSpouseChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择配偶 Select Spouse" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">无 (None)</SelectItem>
|
||||
{potentialRelatives
|
||||
.filter((m) => m.gender !== formData.gender) // Suggest opposite gender
|
||||
.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.fullName} ({m.generation}世)
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-4 sticky bottom-4 bg-background/90 p-4 border-t border-border backdrop-blur rounded-lg">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client"
|
||||
|
||||
export function ActivityLogTest() {
|
||||
console.log("ActivityLogTest: 组件被渲染")
|
||||
|
||||
return (
|
||||
<div className="p-4 border-2 border-red-500 rounded">
|
||||
<h3 className="text-lg font-bold">测试组件</h3>
|
||||
<p>如果你能看到这个,说明组件渲染正常</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } 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 { History, Trash2, RefreshCw, User, FileText, Image, Settings } from "lucide-react"
|
||||
import { getActivityLogs, cleanOldLogs, getActivityStats } from "@/lib/activity-logger"
|
||||
import type { ActivityLog } from "@/lib/db"
|
||||
import { format } from "date-fns"
|
||||
|
||||
const actionLabels = {
|
||||
create: "创建",
|
||||
update: "更新",
|
||||
delete: "删除",
|
||||
import: "导入",
|
||||
export: "导出",
|
||||
}
|
||||
|
||||
const actionColors = {
|
||||
create: "bg-green-500",
|
||||
update: "bg-blue-500",
|
||||
delete: "bg-red-500",
|
||||
import: "bg-purple-500",
|
||||
export: "bg-orange-500",
|
||||
}
|
||||
|
||||
const entityTypeLabels = {
|
||||
member: "成员",
|
||||
photo: "照片",
|
||||
story: "故事",
|
||||
settings: "设置",
|
||||
}
|
||||
|
||||
const entityTypeIcons = {
|
||||
member: User,
|
||||
photo: Image,
|
||||
story: FileText,
|
||||
settings: Settings,
|
||||
}
|
||||
|
||||
export function ActivityLogViewer() {
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([])
|
||||
const [stats, setStats] = useState<any>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadLogs = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [activityLogs, activityStats] = await Promise.all([
|
||||
getActivityLogs(100),
|
||||
getActivityStats()
|
||||
])
|
||||
setLogs(activityLogs)
|
||||
setStats(activityStats)
|
||||
} catch (err) {
|
||||
console.error("加载日志失败:", err)
|
||||
setError(err instanceof Error ? err.message : "加载失败")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLogs()
|
||||
}, [])
|
||||
|
||||
const handleCleanOldLogs = async () => {
|
||||
if (confirm("确定要清除90天前的日志吗?")) {
|
||||
const count = await cleanOldLogs(90)
|
||||
alert(`已清除 ${count} 条旧日志`)
|
||||
loadLogs()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-5 w-5" />
|
||||
操作日志
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
查看所有修谱操作记录
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">总记录数</p>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">24小时内</p>
|
||||
<p className="text-2xl font-bold">{stats.recentCount}</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">创建操作</p>
|
||||
<p className="text-2xl font-bold">{stats.byAction.create || 0}</p>
|
||||
</div>
|
||||
<div className="bg-muted p-4 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">更新操作</p>
|
||||
<p className="text-2xl font-bold">{stats.byAction.update || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadLogs}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCleanOldLogs}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
清除旧日志
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
{error ? (
|
||||
<div className="text-center py-8 text-destructive">
|
||||
加载失败: {error}
|
||||
</div>
|
||||
) : loading ? (
|
||||
<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]
|
||||
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">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{actionLabels[log.action]}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{entityTypeLabels[log.entityType]}
|
||||
</Badge>
|
||||
{log.entityName && (
|
||||
<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.userName || "系统"}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(new Date(log.timestamp), "yyyy-MM-dd HH:mm:ss")}
|
||||
</p>
|
||||
</div>
|
||||
{log.changes && Object.keys(log.changes).length > 0 && (
|
||||
<details className="mt-2">
|
||||
<summary className="text-xs text-muted-foreground cursor-pointer hover:text-foreground">
|
||||
查看变更详情
|
||||
</summary>
|
||||
<pre className="text-xs mt-1 p-2 bg-background rounded overflow-x-auto">
|
||||
{JSON.stringify(log.changes, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,8 +3,40 @@ import { BookOpen, Search, Bell, Settings } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
export function SiteHeader() {
|
||||
const { searchMembers, searchResults } = useFamily()
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [showResults, setShowResults] = useState(false)
|
||||
const searchRef = useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
|
||||
// 点击外部关闭搜索结果
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (searchRef.current && !searchRef.current.contains(event.target as Node)) {
|
||||
setShowResults(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside)
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query)
|
||||
searchMembers(query)
|
||||
setShowResults(query.length > 0)
|
||||
}
|
||||
|
||||
const handleSelectMember = (memberId: string) => {
|
||||
router.push(`/members/${memberId}`)
|
||||
setSearchQuery("")
|
||||
setShowResults(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<div className="container mx-auto flex h-16 items-center px-4">
|
||||
@@ -34,13 +66,51 @@ export function SiteHeader() {
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-4">
|
||||
<div className="relative hidden sm:block">
|
||||
<div className="relative hidden sm:block" ref={searchRef}>
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="搜索成员..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
className="h-9 w-64 rounded-full bg-muted pl-9 text-sm focus-visible:ring-primary"
|
||||
/>
|
||||
|
||||
{/* 搜索结果下拉框 */}
|
||||
{showResults && searchResults.length > 0 && (
|
||||
<div className="absolute top-full mt-2 w-full bg-background border border-border rounded-lg shadow-lg max-h-96 overflow-y-auto z-50">
|
||||
{searchResults.map((member) => (
|
||||
<button
|
||||
key={member.id}
|
||||
onClick={() => handleSelectMember(member.id)}
|
||||
className="w-full px-4 py-3 text-left hover:bg-muted transition-colors border-b border-border last:border-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{member.fullName}</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded ${member.gender === "male" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}>
|
||||
{member.gender === "male" ? "男" : "女"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">第{member.generation}世</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{member.birthDate && `生于 ${member.birthDate}`}
|
||||
{member.birthPlace && ` · ${member.birthPlace}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 无结果提示 */}
|
||||
{showResults && searchQuery && searchResults.length === 0 && (
|
||||
<div className="absolute top-full mt-2 w-full bg-background border border-border rounded-lg shadow-lg p-4 text-center text-sm text-muted-foreground z-50">
|
||||
未找到匹配的成员
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user