This commit is contained in:
freedakgmail
2025-11-24 14:02:34 +08:00
parent b7a8c9ee6e
commit 3d075c6076
941 changed files with 25613 additions and 27641 deletions
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from "next/server"
import { getServerSession } from "next-auth"
import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { checkPermission } from "@/lib/permissions"
// 获取成员的历史版本
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ treeId: string; memberId: string }> }
) {
try {
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 })
}
const { treeId, memberId } = await params
// 检查权限
const { hasPermission } = await checkPermission(session.user.id, treeId)
if (!hasPermission) {
return NextResponse.json({ error: "无权访问此家族树" }, { status: 403 })
}
// 获取成员历史记录
const historyRecords = await prisma.memberHistory.findMany({
where: {
memberId,
treeId,
},
orderBy: {
version: 'desc',
},
})
// 获取所有相关用户ID
const userIds = [...new Set(historyRecords.map(h => h.changedBy))]
// 批量获取用户信息
const users = await prisma.user.findMany({
where: {
id: {
in: userIds
}
},
select: {
id: true,
name: true,
email: true,
}
})
// 创建用户映射
const userMap = new Map(users.map(u => [u.id, u]))
// 组合历史记录和用户信息
const history = historyRecords.map(record => ({
...record,
user: userMap.get(record.changedBy) || null
}))
return NextResponse.json({ history })
} catch (error) {
console.error("获取成员历史失败:", error)
return NextResponse.json(
{ error: "获取成员历史失败" },
{ status: 500 }
)
}
}
@@ -4,6 +4,7 @@ import { authOptions } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { checkPermission } from "@/lib/permissions"
import { Role } from "@prisma/client"
import { recordMemberHistory } from "@/lib/member-history"
// 获取单个成员
export async function GET(
@@ -257,7 +258,17 @@ export async function PATCH(
}
}
// 记录操作日志
// 记录成员历史版本
const historyRecord = await recordMemberHistory({
memberId: member.id,
treeId,
changedBy: session.user.id,
changeType: 'UPDATE',
oldData: existingMember,
newData: member
})
// 记录操作日志,使用历史记录中的详细变更信息
await prisma.activityLog.create({
data: {
treeId,
@@ -266,7 +277,7 @@ export async function PATCH(
entityType: 'MEMBER',
entityId: member.id,
entityName: member.fullName,
changes: body,
changes: historyRecord?.changes || body,
}
})
+10
View File
@@ -5,6 +5,7 @@ import { prisma } from "@/lib/prisma"
import { checkPermission } from "@/lib/permissions"
import { Role, Gender } from "@prisma/client"
import { z } from "zod"
import { recordMemberHistory } from "@/lib/member-history"
const createMemberSchema = z.object({
surname: z.string().min(1),
@@ -211,6 +212,15 @@ export async function POST(
}
}
// 记录成员历史版本
await recordMemberHistory({
memberId: member.id,
treeId,
changedBy: session.user.id,
changeType: 'CREATE',
newData: member
})
// 记录操作日志
await prisma.activityLog.create({
data: {
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { writeFile, mkdir } from 'fs/promises'
import { join } from 'path'
import { existsSync } from 'fs'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
export async function POST(req: NextRequest) {
try {
// 验证用户登录
const session = await getServerSession(authOptions)
if (!session?.user?.id) {
return NextResponse.json({ error: '未授权' }, { status: 401 })
}
const formData = await req.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: '没有文件' }, { status: 400 })
}
// 验证文件类型
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: '只能上传图片文件' }, { status: 400 })
}
// 验证文件大小(限制为 5MB
const maxSize = 5 * 1024 * 1024 // 5MB
if (file.size > maxSize) {
return NextResponse.json({ error: '文件大小不能超过 5MB' }, { status: 400 })
}
// 读取文件内容
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// 确保上传目录存在
const uploadDir = join(process.cwd(), 'public', 'uploads')
if (!existsSync(uploadDir)) {
await mkdir(uploadDir, { recursive: true })
}
// 生成唯一文件名
const timestamp = Date.now()
const randomStr = Math.random().toString(36).substring(2, 8)
const ext = file.name.split('.').pop()
const fileName = `${timestamp}-${randomStr}.${ext}`
// 保存文件
const filePath = join(uploadDir, fileName)
await writeFile(filePath, buffer)
// 返回可访问的 URL
const url = `/uploads/${fileName}`
return NextResponse.json({
url,
fileName,
size: file.size,
type: file.type,
})
} catch (error) {
console.error('上传文件失败:', error)
return NextResponse.json(
{ error: '上传文件失败' },
{ status: 500 }
)
}
}
+1
View File
@@ -48,6 +48,7 @@ export async function GET(req: NextRequest) {
},
tree: {
select: {
id: true,
name: true
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ export default function RegisterPage() {
<Input
id="email"
type="email"
placeholder="your@email.com"
placeholder="请输入邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
+1 -1
View File
@@ -81,7 +81,7 @@ export default function SignInPage() {
<Input
id="email"
type="email"
placeholder="your@email.com"
placeholder="请输入邮箱"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
+4 -1
View File
@@ -174,7 +174,7 @@
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
@apply bg-background text-foreground font-serif;
}
h1,
h2,
@@ -184,4 +184,7 @@
h6 {
@apply font-serif tracking-tight;
}
p {
@apply font-serif;
}
}
+3 -3
View File
@@ -153,7 +153,7 @@ export default function HelpPage() {
<div className="p-4 bg-primary/5 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-primary"></div>
<h3 className="font-medium"> (Owner)</h3>
<h3 className="font-medium"></h3>
</div>
<p className="text-sm text-muted-foreground font-light mb-3">
@@ -170,7 +170,7 @@ export default function HelpPage() {
<div className="p-4 bg-muted/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<h3 className="font-medium"> (Editor)</h3>
<h3 className="font-medium"></h3>
</div>
<p className="text-sm text-muted-foreground font-light mb-3">
@@ -187,7 +187,7 @@ export default function HelpPage() {
<div className="p-4 bg-muted/30 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<h3 className="font-medium"> (Viewer)</h3>
<h3 className="font-medium"></h3>
</div>
<p className="text-sm text-muted-foreground font-light mb-3">
+426 -199
View File
@@ -7,29 +7,59 @@ import { useFamily } from "@/context/family-context"
import { SiteHeader } from "@/components/site-header"
import { MemberForm } from "@/components/members/member-form"
import { Button } from "@/components/ui/button"
import { ArrowLeft, Edit, Trash, Share2, Printer, CircleUser, CircleUserRound, Images, BookOpen, UserPlus } from "lucide-react"
import { ArrowLeft, Edit, Trash, Share2, Printer, CircleUser, CircleUserRound, Images, BookOpen, UserPlus, Camera } from "lucide-react"
import type { FamilyMember } from "@/types/family"
import { db } from "@/lib/db"
import { PhotoGallery } from "@/components/members/photo-gallery"
import { StoryManager } from "@/components/members/story-manager"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import { permissions } from "@/lib/permissions"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { MemberVersionHistory } from "@/components/members/member-version-history"
export default function MemberProfilePage() {
const { id } = useParams()
const { treeData, currentTree, getMember, updateMember, deleteMember } = useFamily()
const { treeData, currentTree, getMember, updateMember, deleteMember, isLoading } = useFamily()
const role = currentTree?.currentUserRole
const { showConfirm } = useDialog()
const router = useRouter()
const [isEditing, setIsEditing] = useState(false)
const [avatarBlobUrl, setAvatarBlobUrl] = useState<string | undefined>(undefined)
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false)
const member = getMember(id as string)
const allMembers = Object.values(treeData.members)
// 始祖专用边框样式
const founderBorderClass = member?.isFounder ? "border-2 border-amber-400 shadow-md" : "border border-border"
// 计算年龄或享年
const calculateAge = () => {
if (!member?.birthDate) return null
const birthYear = new Date(member.birthDate).getFullYear()
if (member.deathDate) {
// 已故,计算享年
const deathYear = new Date(member.deathDate).getFullYear()
return { age: deathYear - birthYear, type: 'deceased' }
} else {
// 在世,计算年龄
const currentYear = new Date().getFullYear()
return { age: currentYear - birthYear, type: 'living' }
}
}
const ageInfo = calculateAge()
// Load avatar blob if exists
// Load avatar
useEffect(() => {
if (member?.avatarImageId) {
// 优先使用 avatarUrl(服务器存储)
if (member?.avatarUrl) {
setAvatarBlobUrl(member.avatarUrl)
}
// 兼容旧的 IndexedDB 存储
else if (member?.avatarImageId) {
db.images.get(member.avatarImageId).then((image) => {
if (image) {
const url = URL.createObjectURL(image.blob)
@@ -40,15 +70,28 @@ export default function MemberProfilePage() {
} else {
setAvatarBlobUrl(undefined)
}
}, [member?.avatarImageId])
}, [member?.avatarUrl, member?.avatarImageId])
// 显示加载状态
if (isLoading) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">...</p>
</div>
</div>
)
}
// 成员未找到
if (!member) {
return (
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="flex-1 flex flex-col items-center justify-center gap-4">
<p className="text-muted-foreground">Member not found</p>
<Button onClick={() => router.push("/members")}>Back to List</Button>
<p className="text-muted-foreground"></p>
<Button onClick={() => router.push(`/members${typeof window !== 'undefined' ? window.location.search : ''}`)}></Button>
</div>
</div>
)
@@ -63,13 +106,14 @@ export default function MemberProfilePage() {
const confirmed = await showConfirm("确定要删除这位成员吗?这将同时删除其关联关系。", "删除成员", "destructive")
if (confirmed) {
deleteMember(member.id)
router.push("/members")
const params = new URLSearchParams(window.location.search)
router.push(`/members?${params.toString()}`)
}
}
const handleQuickAddChild = () => {
// 跳转到新增成员页面,并预填父母信息
const params = new URLSearchParams()
const params = new URLSearchParams(window.location.search)
if (member.gender === 'MALE') {
params.set('fatherId', member.id)
} else {
@@ -81,7 +125,7 @@ export default function MemberProfilePage() {
const handleQuickAddSpouse = () => {
// 跳转到新增成员页面,并预填配偶信息
const params = new URLSearchParams()
const params = new URLSearchParams(window.location.search)
params.set('spouseId', member.id)
params.set('generation', member.generation.toString())
// 配偶性别与当前成员相反
@@ -91,7 +135,7 @@ export default function MemberProfilePage() {
const handleQuickAddFather = () => {
// 跳转到新增成员页面,并预填父亲信息
const params = new URLSearchParams()
const params = new URLSearchParams(window.location.search)
params.set('childId', member.id)
params.set('generation', (member.generation - 1).toString())
params.set('gender', 'MALE')
@@ -102,9 +146,47 @@ export default function MemberProfilePage() {
router.push(`/members/new?${params.toString()}`)
}
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
try {
setIsUploadingAvatar(true)
// 上传到服务器
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || '上传失败')
}
const { url } = await response.json()
// 更新成员头像 URL
await updateMember(member.id, {
avatarUrl: url,
})
// 显示新头像
setAvatarBlobUrl(url)
} catch (error) {
console.error('上传头像失败:', error)
alert('上传头像失败: ' + (error instanceof Error ? error.message : '未知错误'))
} finally {
setIsUploadingAvatar(false)
}
}
const handleQuickAddMother = () => {
// 跳转到新增成员页面,并预填母亲信息
const params = new URLSearchParams()
const params = new URLSearchParams(window.location.search)
params.set('childId', member.id)
params.set('generation', (member.generation - 1).toString())
params.set('gender', 'FEMALE')
@@ -120,17 +202,47 @@ export default function MemberProfilePage() {
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<div className="container mx-auto py-8">
<div className="mb-6 flex items-center gap-2">
<Button variant="ghost" onClick={() => setIsEditing(false)}>
<div className="mb-6">
<Button variant="ghost" onClick={() => setIsEditing(false)} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<h1 className="text-2xl font-bold font-serif">: {member.fullName}</h1>
{member.isFounder && (
<div className="mb-6 p-6 bg-gradient-to-br from-amber-50 via-orange-50 to-amber-50 border-2 border-amber-400 rounded-xl shadow-md">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full flex items-center justify-center shadow-sm">
<span className="text-3xl">👑</span>
</div>
<div className="flex-1">
<h2 className="text-2xl font-serif font-bold text-amber-900 mb-3">
</h2>
<p className="text-base text-amber-800 leading-relaxed font-serif">
<span className="font-bold text-amber-900"></span>
<span className="font-bold text-amber-900"></span>
<br />
</p>
</div>
</div>
</div>
)}
<h1 className="text-3xl font-serif font-bold mb-2">
{member.isFounder ? '编辑家族始祖' : `编辑成员: ${member.fullName}`}
</h1>
<p className="text-muted-foreground">
{member.isFounder
? '修改始祖信息将影响整个家族树'
: '更新成员信息'}
</p>
</div>
<MemberForm
initialData={member}
existingMembers={allMembers}
onSubmit={handleSave}
onCancel={() => setIsEditing(false)}
onCancel={() => setIsEditing(false)}
isFounder={member.isFounder}
/>
</div>
</div>
@@ -141,32 +253,92 @@ export default function MemberProfilePage() {
<div className="min-h-screen flex flex-col bg-background">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6">
{/* 始祖特殊提示框 */}
{member.isFounder && (
<div className="mb-6 p-6 bg-gradient-to-br from-amber-50 via-orange-50 to-amber-50 border-2 border-amber-400 rounded-xl shadow-md">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full flex items-center justify-center shadow-sm">
<span className="text-3xl">👑</span>
</div>
<div className="flex-1">
<h2 className="text-2xl font-serif font-bold text-amber-900 mb-3">
</h2>
<p className="text-base text-amber-800 leading-relaxed font-serif">
<span className="font-bold text-amber-900"></span>
<span className="font-bold text-amber-900"></span>
<br />
</p>
</div>
</div>
</div>
)}
{/* Header Section */}
<div className="flex flex-col md:flex-row justify-between items-start gap-6 mb-8 border-b border-border pb-8">
<div className="flex items-start gap-6">
<div className="h-32 w-32 rounded-lg bg-muted flex items-center justify-center border-2 border-primary/20 overflow-hidden">
{avatarBlobUrl ? (
<img
src={avatarBlobUrl}
alt={member.fullName}
className="h-full w-full object-cover"
/>
) : (
member.gender === 'FEMALE' ? (
<CircleUserRound className="h-20 w-20 text-pink-400" />
<div className="relative group">
<div className="h-32 w-32 rounded-lg bg-muted flex items-center justify-center border-2 border-primary/20 overflow-hidden">
{avatarBlobUrl ? (
<img
src={avatarBlobUrl}
alt={member.fullName}
className="h-full w-full object-cover"
/>
) : (
<CircleUser className="h-20 w-20 text-blue-400" />
)
member.gender === 'FEMALE' ? (
<CircleUserRound className="h-20 w-20 text-pink-400" />
) : (
<CircleUser className="h-20 w-20 text-blue-400" />
)
)}
</div>
{permissions.canUpdate(role) && (
<label className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer rounded-lg">
<Camera className="h-8 w-8 text-white" />
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarUpload}
disabled={isUploadingAvatar}
/>
</label>
)}
{isUploadingAvatar && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-lg">
<div className="text-white text-xs">...</div>
</div>
)}
</div>
<div>
<div className="flex items-center gap-3 mb-2">
<h1 className="text-4xl font-bold font-serif text-foreground">{member.fullName}</h1>
<span className="px-2 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full border border-primary/20">
{member.generation}
</span>
<h1 className="text-4xl font-bold font-serif text-foreground flex items-center gap-2">
{member.isFounder && <span className="text-amber-600 text-3xl">👑</span>}
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
/>
</h1>
{member.isFounder ? (
<span className="px-3 py-1 bg-gradient-to-r from-amber-100 to-orange-100 text-amber-800 text-sm font-semibold rounded-full border-2 border-amber-300 shadow-sm">
· {member.generation}
</span>
) : (
<span className="px-2 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full border border-primary/20">
{member.generation}
</span>
)}
</div>
<div className="space-y-1 text-muted-foreground">
{member.isFounder && (
<p className="text-amber-700 font-semibold text-sm flex items-center gap-1">
<span></span>
<span className="text-amber-600"></span>
<span></span>
</p>
)}
{member.courtesyName && (
<p>
<span className="text-foreground">{member.courtesyName}</span>
@@ -197,12 +369,12 @@ export default function MemberProfilePage() {
<div className="flex gap-2 flex-wrap">
{permissions.canCreate(role) && (
<>
{!member.fatherId && (
{!member.isFounder && !member.fatherId && (
<Button variant="outline" onClick={handleQuickAddFather} className="gap-2">
<UserPlus className="h-4 w-4" />
</Button>
)}
{!member.motherId && (
{!member.isFounder && !member.motherId && (
<Button variant="outline" onClick={handleQuickAddMother} className="gap-2">
<UserPlus className="h-4 w-4" />
</Button>
@@ -236,14 +408,222 @@ export default function MemberProfilePage() {
{/* Content Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Main Biography */}
{/* Left Column - Details and Biography */}
<div className="lg:col-span-2 space-y-8">
{/* 家族关系 */}
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-secondary rounded-full"></span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className={`bg-card p-4 rounded-lg ${founderBorderClass}`}>
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
</h3>
{member.fatherId ? (
<Link
href={`/members/${member.fatherId}${typeof window !== 'undefined' ? window.location.search : ''}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
<MemberNameWithStatus
name={getMember(member.fatherId)?.fullName || member.fatherId}
isDead={!!getMember(member.fatherId)?.deathDate}
/>
</Link>
) : member.spouseFatherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseFatherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className={`bg-card p-4 rounded-lg ${founderBorderClass}`}>
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
</h3>
{member.motherId ? (
<Link
href={`/members/${member.motherId}${typeof window !== 'undefined' ? window.location.search : ''}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
<MemberNameWithStatus
name={getMember(member.motherId)?.fullName || member.motherId}
isDead={!!getMember(member.motherId)?.deathDate}
/>
</Link>
) : member.spouseMotherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseMotherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className={`bg-card p-4 rounded-lg ${founderBorderClass}`}>
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
</h3>
<div className="flex flex-wrap gap-2">
{member.spouseIds?.length > 0 ? (
member.spouseIds.map((spouseId) => (
<Link
href={`/members/${spouseId}${typeof window !== 'undefined' ? window.location.search : ''}`}
key={spouseId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
<MemberNameWithStatus
name={getMember(spouseId)?.fullName || spouseId}
isDead={!!getMember(spouseId)?.deathDate}
/>
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
<div className={`bg-card p-4 rounded-lg ${founderBorderClass}`}>
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
</h3>
<div className="flex flex-wrap gap-2">
{member.childrenIds?.length > 0 ? (
member.childrenIds.map((childId) => (
<Link
href={`/members/${childId}${typeof window !== 'undefined' ? window.location.search : ''}`}
key={childId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
<MemberNameWithStatus
name={getMember(childId)?.fullName || childId}
isDead={!!getMember(childId)?.deathDate}
/>
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
</section>
{/* 详细信息 */}
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-secondary rounded-full"></span>
</h2>
<div className={`bg-card rounded-lg ${founderBorderClass} p-6 shadow-sm`}>
<dl className="grid grid-cols-2 gap-x-8 gap-y-4 text-sm">
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium flex items-center gap-2">
{member.birthDate || "-"}
{member.birthDate && member.isLunarDate && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200">
</span>
)}
</dd>
</div>
{ageInfo && (
<div>
<dt className="text-muted-foreground mb-1">{ageInfo.type === 'deceased' ? '享年' : '年龄'}</dt>
<dd className="font-medium text-primary">{ageInfo.age} </dd>
</div>
)}
{member.deathDate && (
<>
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium flex items-center gap-2">
{member.deathDate}
{member.isLunarDate && (
<span className="text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded border border-amber-200">
</span>
)}
</dd>
</div>
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.posthumousName || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.burialPlace || "-"}</dd>
</div>
</>
)}
{member.generationName && (
<div>
<dt className="text-muted-foreground mb-1"></dt>
<dd className="font-medium">{member.generationName}</dd>
</div>
)}
</dl>
</div>
</section>
{/* 联系方式 */}
{(member.phone || member.telephone || member.email || member.address) && (
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-secondary rounded-full"></span>
</h2>
<div className={`bg-card rounded-lg ${founderBorderClass} p-6 shadow-sm`}>
<dl className="space-y-4 text-sm">
{member.phone && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
<a href={`tel:${member.phone}`} className="hover:text-primary">
{member.phone}
</a>
</dd>
</div>
)}
{member.telephone && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
<a href={`tel:${member.telephone}`} className="hover:text-primary">
{member.telephone}
</a>
</dd>
</div>
)}
{member.email && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
<a href={`mailto:${member.email}`} className="hover:text-primary">
{member.email}
</a>
</dd>
</div>
)}
{member.address && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.address}</dd>
</div>
)}
</dl>
</div>
</section>
)}
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-primary rounded-full"></span>
</h2>
<div className="prose prose-stone dark:prose-invert max-w-none bg-card p-6 rounded-lg border border-border shadow-sm">
<div className={`prose prose-stone dark:prose-invert max-w-none bg-card p-6 rounded-lg ${founderBorderClass} shadow-sm`}>
{member.bio ? (
<p className="whitespace-pre-line leading-relaxed">{member.bio}</p>
) : (
@@ -259,7 +639,7 @@ export default function MemberProfilePage() {
<Images className="h-6 w-6 text-primary" />
</h2>
<div className="bg-card p-6 rounded-lg border border-border shadow-sm">
<div className={`bg-card p-6 rounded-lg ${founderBorderClass} shadow-sm`}>
<PhotoGallery
photoIds={member.photoIds}
onChange={(photoIds) => updateMember(member.id, { photoIds })}
@@ -276,7 +656,7 @@ export default function MemberProfilePage() {
<BookOpen className="h-6 w-6 text-primary" />
</h2>
<div className="bg-card p-6 rounded-lg border border-border shadow-sm">
<div className={`bg-card p-6 rounded-lg ${founderBorderClass} shadow-sm`}>
<StoryManager
stories={member.stories}
onChange={(stories) => updateMember(member.id, { stories })}
@@ -285,174 +665,21 @@ export default function MemberProfilePage() {
</div>
</section>
)}
</div>
{/* Right Column - Version History */}
<div className="space-y-8">
<section>
<h2 className="text-2xl font-serif font-bold mb-4 flex items-center gap-2">
<span className="w-1 h-6 bg-secondary rounded-full"></span>
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-card p-4 rounded-lg border border-border">
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
(Father)
</h3>
{member.fatherId ? (
<Link
href={`/members/${member.fatherId}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
{getMember(member.fatherId)?.fullName || member.fatherId}
</Link>
) : member.spouseFatherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseFatherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="bg-card p-4 rounded-lg border border-border">
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
(Mother)
</h3>
{member.motherId ? (
<Link
href={`/members/${member.motherId}`}
className="text-lg font-serif font-bold hover:underline text-primary"
>
{getMember(member.motherId)?.fullName || member.motherId}
</Link>
) : member.spouseMotherName ? (
<span className="text-lg font-serif font-bold text-foreground">
{member.spouseMotherName}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
<div className="bg-card p-4 rounded-lg border border-border">
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
(Spouse)
</h3>
<div className="flex flex-wrap gap-2">
{member.spouseIds?.length > 0 ? (
member.spouseIds.map((spouseId) => (
<Link
href={`/members/${spouseId}`}
key={spouseId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
{getMember(spouseId)?.fullName || spouseId}
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
<div className="bg-card p-4 rounded-lg border border-border">
<h3 className="font-medium text-muted-foreground mb-2 text-sm uppercase tracking-wider">
(Children)
</h3>
<div className="flex flex-wrap gap-2">
{member.childrenIds?.length > 0 ? (
member.childrenIds.map((childId) => (
<Link
href={`/members/${childId}`}
key={childId}
className="px-3 py-1 bg-muted rounded hover:bg-muted/80 text-sm font-medium"
>
{getMember(childId)?.fullName || childId}
</Link>
))
) : (
<span className="text-muted-foreground"></span>
)}
</div>
</div>
</div>
<MemberVersionHistory
memberId={member.id}
treeId={currentTree?.id || ''}
/>
</section>
</div>
{/* Sidebar Info */}
<div className="space-y-6">
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
<h3 className="font-serif font-bold text-lg mb-4 border-b border-border pb-2"></h3>
<dl className="space-y-4 text-sm">
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.birthDate || "-"}</dd>
</div>
{member.deathDate && (
<>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.deathDate}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.posthumousName || "-"}</dd>
</div>
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.burialPlace || "-"}</dd>
</div>
</>
)}
{member.generationName && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.generationName}</dd>
</div>
)}
</dl>
</div>
{/* 联系方式 */}
{(member.phone || member.telephone || member.email || member.address) && (
<div className="bg-card rounded-lg border border-border p-6 shadow-sm">
<h3 className="font-serif font-bold text-lg mb-4 border-b border-border pb-2"></h3>
<dl className="space-y-4 text-sm">
{member.phone && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
<a href={`tel:${member.phone}`} className="hover:text-primary">
{member.phone}
</a>
</dd>
</div>
)}
{member.telephone && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
<a href={`tel:${member.telephone}`} className="hover:text-primary">
{member.telephone}
</a>
</dd>
</div>
)}
{member.email && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">
<a href={`mailto:${member.email}`} className="hover:text-primary">
{member.email}
</a>
</dd>
</div>
)}
{member.address && (
<div>
<dt className="text-muted-foreground"></dt>
<dd className="font-medium">{member.address}</dd>
</div>
)}
</dl>
</div>
)}
</div>
</div>
</main>
</div>
+50 -7
View File
@@ -15,6 +15,7 @@ export default function NewMemberPage() {
const { addMember, treeData, getMember } = useFamily()
const existingMembers = Object.values(treeData.members)
const isFirstMember = existingMembers.length === 0
// 从URL参数获取预填信息
const initialData = useMemo(() => {
@@ -73,13 +74,23 @@ export default function NewMemberPage() {
const handleSave = async (newMember: FamilyMember) => {
try {
await addMember({
// 如果是第一个成员,自动设置为始祖
const memberData = isFirstMember ? {
...newMember,
generation: 1,
isFounder: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} : {
...newMember,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})
// 跳转到成员列表页
router.push('/members')
}
await addMember(memberData)
// 跳转到成员列表页,保留treeId参数
const params = new URLSearchParams(window.location.search)
router.push(`/members?${params.toString()}`)
} catch (error) {
console.error('添加成员失败:', error)
alert('添加成员失败: ' + (error as Error).message)
@@ -94,12 +105,44 @@ export default function NewMemberPage() {
<Button variant="ghost" onClick={() => router.back()} className="mb-4">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<h1 className="text-3xl font-serif font-bold mb-2"></h1>
{isFirstMember && (
<div className="mb-6 p-6 bg-gradient-to-br from-amber-50 via-orange-50 to-amber-50 border-2 border-amber-400 rounded-xl shadow-md">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-14 h-14 bg-gradient-to-br from-amber-200 to-orange-200 rounded-full flex items-center justify-center shadow-sm">
<span className="text-3xl">👑</span>
</div>
<div className="flex-1">
<h2 className="text-2xl font-serif font-bold text-amber-900 mb-3">
</h2>
<p className="text-base text-amber-800 leading-relaxed font-serif">
<span className="font-bold text-amber-900"></span>
<span className="font-bold text-amber-900"></span>
<br />
</p>
</div>
</div>
</div>
)}
<h1 className="text-3xl font-serif font-bold mb-2">
{isFirstMember ? '添加家族始祖' : '新增家族成员'}
</h1>
<p className="text-muted-foreground">
{isFirstMember
? '请认真填写始祖信息,这将是家族历史的起点'
: '填写成员信息,完善家族树'}
</p>
</div>
<MemberForm initialData={initialData} existingMembers={existingMembers} onSubmit={handleSave} onCancel={() => router.back()} />
<MemberForm
initialData={initialData}
existingMembers={existingMembers}
onSubmit={handleSave}
onCancel={() => router.back()}
isFounder={isFirstMember}
/>
</div>
</div>
)
+42 -34
View File
@@ -17,6 +17,7 @@ import { Badge } from "@/components/ui/badge"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import type { FamilyMember } from "@/types/family"
import { permissions } from "@/lib/permissions"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
// 占位符类型
interface PlaceholderMember {
@@ -72,7 +73,7 @@ const MemberCard = memo(function MemberCard({
onLocateInTree: (id: string) => void
}) {
// 判断是否是配偶
const isFounder = member.tags?.includes('始祖')
const isFounder = member.isFounder === true
const isSpouse = !member.fatherId && !member.motherId &&
member.spouseIds && member.spouseIds.length > 0 &&
!isFounder
@@ -85,7 +86,7 @@ const MemberCard = memo(function MemberCard({
return (
<div className="relative group">
<Link href={`/members/${member.id}`}>
<Link href={`/members/${member.id}${typeof window !== 'undefined' ? window.location.search : ''}`}>
<Card className={`hover:border-primary transition-colors cursor-pointer h-full ${
isSpouse
? 'bg-pink-50/50 dark:bg-pink-950/20 border-pink-200/50 dark:border-pink-800/30'
@@ -94,24 +95,21 @@ const MemberCard = memo(function MemberCard({
<CardContent className="p-3 flex items-center gap-5">
<div className="relative flex-shrink-0">
<AvatarDisplay
imageId={member.avatarImageId}
fallbackUrl={member.avatarUrl}
imageUrl={member.avatarUrl}
fallbackText={member.surname}
gender={member.gender}
className="h-20 w-20 border-2 border-border"
/>
{/* 在世/去世标识 */}
{member.deathDate && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-gray-500 border-2 border-white" title="已故" />
)}
{!member.deathDate && (
<div className="absolute -bottom-1 -right-1 w-4 h-4 rounded-full bg-green-500 border-2 border-white" title="在世" />
)}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center">
<div className="flex items-baseline justify-between gap-1.5">
<h3 className="text-lg font-bold font-serif truncate">
{member.fullName}
<h3 className="text-lg font-bold font-serif truncate flex items-center gap-1">
{isFounder && <span className="text-amber-600">👑</span>}
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
dotPosition="top-left"
/>
{member.courtesyName && (
<span className="text-xs font-normal text-muted-foreground ml-1">
{member.courtesyName}
@@ -120,7 +118,7 @@ const MemberCard = memo(function MemberCard({
</h3>
</div>
{isFounder ? (
<p className="text-xs text-amber-700 font-medium mt-0.5 truncate">
<p className="text-xs text-amber-700 font-semibold mt-0.5 truncate">
</p>
) : isSpouse && spouseName ? (
@@ -140,10 +138,17 @@ const MemberCard = memo(function MemberCard({
{/* 生卒年和年龄 */}
{(member.birthDate || member.deathDate) && (
<div className="text-xs text-muted-foreground text-right space-y-0.5 flex-shrink-0">
<div>
{member.birthDate && new Date(member.birthDate).getFullYear()}
{member.deathDate && ` - ${new Date(member.deathDate).getFullYear()}`}
{!member.deathDate && member.birthDate && ' - '}
<div className="flex items-center gap-1 justify-end">
<span>
{member.birthDate && new Date(member.birthDate).getFullYear()}
{member.deathDate && ` - ${new Date(member.deathDate).getFullYear()}`}
{!member.deathDate && member.birthDate && ' - '}
</span>
{member.isLunarDate && (
<span className="text-[10px] text-amber-600 bg-amber-50 px-1 py-0.5 rounded border border-amber-200">
</span>
)}
</div>
{(() => {
if (!member.birthDate) return null
@@ -165,9 +170,11 @@ const MemberCard = memo(function MemberCard({
{member.tags.map((tag) => (
<span
key={tag}
className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded border border-primary/20"
className={tag === '始祖'
? "text-xs bg-amber-100 text-amber-800 px-2 py-0.5 rounded border border-amber-300 font-semibold"
: "text-xs bg-primary/10 text-primary px-2 py-0.5 rounded border border-primary/20"}
>
{tag}
{tag === '始祖' ? '👑 始祖' : tag}
</span>
))}
</div>
@@ -303,7 +310,7 @@ export default function MembersPage() {
.map(([generation, members]) => {
// 区分家族成员和配偶
const familyMembers = members.filter(m => {
const isFounder = m.tags?.includes('始祖')
const isFounder = m.isFounder === true
const hasFamilyParents = m.fatherId || m.motherId
if (hasFamilyParents || isFounder) return true
@@ -314,8 +321,8 @@ export default function MembersPage() {
// 对家族成员按年龄排序
const sortedFamilyMembers = familyMembers.sort((a, b) => {
const aIsFounder = a.tags?.includes('始祖')
const bIsFounder = b.tags?.includes('始祖')
const aIsFounder = a.isFounder === true
const bIsFounder = b.isFounder === true
if (aIsFounder && !bIsFounder) return -1
if (!aIsFounder && bIsFounder) return 1
@@ -388,7 +395,7 @@ export default function MembersPage() {
)}
</Button>
{permissions.canCreate(currentTree?.currentUserRole) && (
<Link href="/members/new">
<Link href={`/members/new${typeof window !== 'undefined' ? window.location.search : ''}`}>
<Button className="gap-2 bg-primary text-primary-foreground">
<Plus className="h-4 w-4" />
@@ -409,13 +416,13 @@ export default function MembersPage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<Label className="text-xs"> (Surname)</Label>
<Label className="text-xs"></Label>
<Select value={selectedSurname} onValueChange={setSelectedSurname}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (All)</SelectItem>
<SelectItem value="all"></SelectItem>
{uniqueSurnames.map((s) => (
<SelectItem key={s} value={s}>
{s}
@@ -426,13 +433,13 @@ export default function MembersPage() {
</div>
<div className="space-y-2">
<Label className="text-xs"> (Ancestral Home)</Label>
<Label className="text-xs"></Label>
<Select value={selectedHome} onValueChange={setSelectedHome}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (All)</SelectItem>
<SelectItem value="all"></SelectItem>
{uniqueHomes.map((h) => (
<SelectItem key={h} value={h}>
{h}
@@ -443,22 +450,22 @@ export default function MembersPage() {
</div>
<div className="space-y-2">
<Label className="text-xs"> (Status)</Label>
<Label className="text-xs"></Label>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"> (All)</SelectItem>
<SelectItem value="living"> (Living)</SelectItem>
<SelectItem value="deceased"> (Deceased)</SelectItem>
<SelectItem value="all"></SelectItem>
<SelectItem value="living"></SelectItem>
<SelectItem value="deceased"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="flex justify-between">
<Label className="text-xs"> (Generation)</Label>
<Label className="text-xs"></Label>
<span className="text-xs text-muted-foreground">
{generationRange[0]} - {generationRange[1]}
</span>
@@ -511,7 +518,8 @@ export default function MembersPage() {
allMembers={allMembers}
onLocateInTree={(id) => {
setHighlightedMemberId(id)
router.push('/tree')
const params = new URLSearchParams(window.location.search)
router.push(`/tree?${params.toString()}`)
}}
/>
)
+387 -167
View File
@@ -17,6 +17,8 @@ import { useSession } from "next-auth/react"
import { useSearchParams } from "next/navigation"
import { CollaboratorDialog } from "@/components/tree/collaborator-dialog"
import { permissions } from "@/lib/permissions"
import { isInCurrentMonth, solar2lunar, lunar2solar } from "@/lib/lunar-calendar"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
interface ActivityLog {
id: string
@@ -24,6 +26,7 @@ interface ActivityLog {
entityType: string
entityId?: string | null
entityName?: string | null
changes?: any
timestamp: string
user?: {
name?: string | null
@@ -49,26 +52,37 @@ export default function DashboardPage() {
// 加载最近的操作日志
useEffect(() => {
if (currentTree?.id && session?.user?.id) {
fetch(`/api/trees/${currentTree.id}/activity-logs?limit=10`)
.then(res => {
if (!res.ok) {
throw new Error('获取活动日志失败')
}
return res.json()
})
.then(data => {
if (data.logs) {
setRecentActivities(data.logs)
}
})
.catch(err => {
console.error('获取活动日志失败:', err)
setRecentActivities([])
})
} else {
// 只有在有家族树且已登录时才获取活动日志
if (!currentTree?.id || !session?.user?.id) {
setRecentActivities([])
return
}
const controller = new AbortController()
fetch(`/api/trees/${currentTree.id}/activity-logs?limit=10`, {
signal: controller.signal
})
.then(res => {
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`)
}
return res.json()
})
.then(data => {
if (data.logs && Array.isArray(data.logs)) {
setRecentActivities(data.logs)
}
})
.catch(err => {
// 忽略 abort 错误
if (err.name !== 'AbortError') {
console.error('获取活动日志失败:', err)
}
setRecentActivities([])
})
return () => controller.abort()
}, [currentTree?.id, session?.user?.id])
// 计算统计数据
@@ -126,9 +140,8 @@ export default function DashboardPage() {
.slice(0, 5)
}, [treeData])
// 获取本月纪念日(生日和忌日)
// 获取本月纪念日(生日和忌日)- 支持农历
const monthlyAnniversaries = useMemo(() => {
const currentMonth = new Date().getMonth() + 1
const members = Object.values(treeData.members)
const anniversaries: Array<{
@@ -136,33 +149,58 @@ export default function DashboardPage() {
type: 'birth' | 'death'
date: string
day: number
month: number
isLunar: boolean
lunarDisplay?: string
}> = []
members.forEach(member => {
// 检查生日
if (member.birthDate) {
const birthMonth = new Date(member.birthDate).getMonth() + 1
const birthDay = new Date(member.birthDate).getDate()
if (birthMonth === currentMonth) {
const isLunar = member.isLunarDate || false
const result = isInCurrentMonth(member.birthDate, isLunar)
if (result) {
let lunarDisplay = undefined
if (isLunar) {
const lunar = solar2lunar(new Date(member.birthDate))
if (lunar) {
lunarDisplay = `${lunar.monthName}${lunar.dayName}`
}
}
anniversaries.push({
member,
type: 'birth',
date: member.birthDate,
day: birthDay
day: result.day,
month: result.month,
isLunar,
lunarDisplay
})
}
}
// 检查忌日
if (member.deathDate) {
const deathMonth = new Date(member.deathDate).getMonth() + 1
const deathDay = new Date(member.deathDate).getDate()
if (deathMonth === currentMonth) {
const isLunar = member.isLunarDate || false
const result = isInCurrentMonth(member.deathDate, isLunar)
if (result) {
let lunarDisplay = undefined
if (isLunar) {
const lunar = solar2lunar(new Date(member.deathDate))
if (lunar) {
lunarDisplay = `${lunar.monthName}${lunar.dayName}`
}
}
anniversaries.push({
member,
type: 'death',
date: member.deathDate,
day: deathDay
day: result.day,
month: result.month,
isLunar,
lunarDisplay
})
}
}
@@ -195,11 +233,33 @@ export default function DashboardPage() {
<div className="flex min-h-screen flex-col bg-background font-sans">
<SiteHeader />
<main className="flex-1 flex items-center justify-center px-4 md:px-6 relative">
<main className="flex-1 flex items-center justify-center px-4 md:px-6 relative -mt-24">
{/* 背景装饰图片 */}
<div className="absolute inset-0 pointer-events-none overflow-hidden">
{/* 建筑图片 - 左 */}
<div className="absolute left-24 top-[60%] -translate-y-1/2 w-[30rem] h-[30rem] opacity-30 rotate-12">
{/* 文字图片 - 左上角,轻微向右倾斜 */}
<div className="absolute left-32 top-[20%] w-[20rem] h-[20rem] opacity-20 rotate-3">
<Image
src="/wxz.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 树图片1 - 右上角,轻微向左倾斜,对称布局 */}
<div className="absolute right-32 top-[20%] w-[20rem] h-[20rem] opacity-20 -rotate-3">
<Image
src="/tree1.png"
alt=""
fill
className="object-contain"
priority
/>
</div>
{/* 建筑图片 - 左下角,向右倾斜,营造动感 */}
<div className="absolute left-24 bottom-[8%] w-[28rem] h-[28rem] opacity-15 rotate-6">
<Image
src="/building.png"
alt=""
@@ -209,8 +269,8 @@ export default function DashboardPage() {
/>
</div>
{/* 树图片 - 右 */}
<div className="absolute right-24 top-[40%] -translate-y-1/2 w-[30rem] h-[30rem] opacity-30 -rotate-12">
{/* 树图片 - 右下角,向左倾斜,与建筑对称 */}
<div className="absolute right-24 bottom-[8%] w-[28rem] h-[28rem] opacity-15 -rotate-6">
<Image
src="/tree.png"
alt=""
@@ -223,6 +283,11 @@ export default function DashboardPage() {
{/* 内容区域 */}
<div className="max-w-3xl mx-auto text-center space-y-12">
{/* Slogan */}
<p className="text-2xl md:text-3xl text-foreground font-serif font-medium tracking-wider">
·
</p>
{/* 图标 */}
<div className="inline-flex items-center justify-center w-16 h-16 text-primary/20">
<BookOpen className="h-16 w-16" strokeWidth={1} />
@@ -237,33 +302,24 @@ export default function DashboardPage() {
{/* 哲理文字 - 滚动显示 */}
<div className="h-48 overflow-hidden relative">
<div className="space-y-8 text-muted-foreground antialiased animate-scroll">
<p className="text-lg md:text-xl font-normal leading-relaxed">
<p className="text-lg md:text-xl font-serif font-normal leading-relaxed tracking-wide">
·
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-85">
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-85 tracking-wide">
·
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-70">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-60">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-50">
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-40">
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-70 tracking-wide">
·
</p>
{/* 重复内容实现无缝滚动 */}
<p className="text-lg md:text-xl font-normal leading-relaxed">
<p className="text-lg md:text-xl font-serif font-normal leading-relaxed tracking-wide">
·
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-85">
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-85 tracking-wide">
·
</p>
<p className="text-base md:text-lg font-normal leading-relaxed opacity-70">
<p className="text-base md:text-lg font-serif font-normal leading-relaxed opacity-70 tracking-wide">
·
</p>
</div>
</div>
@@ -274,10 +330,10 @@ export default function DashboardPage() {
<Link href="/trees/new">
<Button
size="lg"
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300"
className="gap-3 px-8 py-6 text-lg font-serif bg-primary text-primary-foreground hover:bg-primary/90 transition-all duration-300 shadow-lg hover:shadow-xl"
>
<ArrowRight className="h-4 w-4" />
<ArrowRight className="h-5 w-5" />
</Button>
</Link>
</div>
@@ -321,7 +377,7 @@ export default function DashboardPage() {
<CollaboratorDialog />
)}
{permissions.canCreate(currentTree?.currentUserRole) && (
<Link href="/members/new">
<Link href={currentTree?.id ? `/members/new?treeId=${currentTree.id}` : '/members/new'}>
<Button className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90">
<UserPlus className="h-4 w-4" />
@@ -336,57 +392,64 @@ export default function DashboardPage() {
title="家族成员"
value={stats.totalMembers.toString()}
subtitle={`${stats.maleCount} · 女 ${stats.femaleCount}`}
icon={<Users className="h-4 w-4" />}
icon={<Users className="h-4 w-4" />}
href={currentTree?.id ? `/members?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="记录年代"
value={`${stats.yearsSpan}`}
subtitle={`始于 ${stats.earliestYear}`}
icon={<Clock className="h-4 w-4" />}
icon={<Clock className="h-4 w-4" />}
href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="繁衍代数"
value={`${stats.maxGeneration}`}
subtitle={`当前最高世代`}
icon={<Network className="h-4 w-4" />}
icon={<Network className="h-4 w-4" />}
href={currentTree?.id ? `/tree?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="在世 / 已故"
value={`${stats.livingMembers} / ${stats.deceasedMembers}`}
subtitle={`在世率 ${stats.totalMembers > 0 ? Math.round(stats.livingMembers / stats.totalMembers * 100) : 0}%`}
icon={<Users className="h-4 w-4" />}
icon={<Users className="h-4 w-4" />}
href={currentTree?.id ? `/members?treeId=${currentTree.id}` : undefined}
/>
<StatCard
title="平均寿命"
value={stats.averageLifespan > 0 ? `${stats.averageLifespan}` : '暂无数据'}
subtitle={`已统计 ${stats.deceasedMembers}`}
icon={<Clock className="h-4 w-4" />}
icon={<Clock className="h-4 w-4" />}
href={currentTree?.id ? `/timeline?treeId=${currentTree.id}` : undefined}
/>
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<Users className="h-4 w-4" />
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '#'}>
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide"></p>
<div className="text-primary/70 bg-primary/10 p-2.5 rounded-lg group-hover:bg-primary/20 transition-colors">
<Users className="h-4 w-4" />
</div>
</div>
</div>
<div className="flex flex-col space-y-1">
<div className="flex items-center justify-center gap-3">
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? '1' : '0'}
</span>
<span className="text-2xl font-bold text-muted-foreground">:</span>
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? (stats.femaleCount / stats.maleCount).toFixed(2) : '0'}
</span>
<div className="flex flex-col space-y-1">
<div className="flex items-center justify-center gap-3">
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? '1' : '0'}
</span>
<span className="text-2xl font-bold text-muted-foreground">:</span>
<span className="text-3xl font-serif font-bold text-primary tracking-tight">
{stats.maleCount > 0 ? (stats.femaleCount / stats.maleCount).toFixed(2) : '0'}
</span>
</div>
<div className="text-center text-xs text-muted-foreground/80 font-medium mt-1">
{stats.maleCount} · {stats.femaleCount}
</div>
</div>
<div className="text-center text-xs text-muted-foreground/80 font-medium mt-1">
{stats.maleCount} · {stats.femaleCount}
</div>
</div>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
</CardContent>
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
</Link>
</div>
<Tabs defaultValue="recent" className="w-full">
@@ -397,7 +460,7 @@ export default function DashboardPage() {
<TabsTrigger value="statistics"></TabsTrigger>
<TabsTrigger value="migration"></TabsTrigger>
</TabsList>
<Link href="/members">
<Link href={currentTree?.id ? `/members?treeId=${currentTree.id}` : '/members'}>
<Button variant="ghost" size="sm" className="text-muted-foreground gap-1">
<ArrowRight className="h-3 w-3" />
</Button>
@@ -405,63 +468,7 @@ export default function DashboardPage() {
</div>
<TabsContent value="recent" className="mt-0">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-2 border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivities.length > 0 ? (
recentActivities.map((activity, i) => (
<div key={activity.id} className="flex gap-3 group hover:bg-muted/30 p-2 rounded-lg transition-colors">
<div className="relative flex flex-col items-center">
<div className="h-full w-px bg-border group-last:hidden"></div>
<div className={`absolute top-2 h-2 w-2 rounded-full ring-4 ring-background ${
activity.action === 'create' ? 'bg-green-500' :
activity.action === 'update' ? 'bg-blue-500' :
activity.action === 'delete' ? 'bg-red-500' :
'bg-purple-500'
}`}></div>
</div>
<div className="pb-2 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${
activity.action === 'create' ? 'bg-green-100 text-green-700' :
activity.action === 'update' ? 'bg-blue-100 text-blue-700' :
activity.action === 'delete' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{activity.action === 'create' ? '创建' :
activity.action === 'update' ? '更新' :
activity.action === 'delete' ? '删除' : '导入'}
</span>
<span className="font-medium">{activity.entityName || '未知'}</span>
<span className="text-xs text-muted-foreground">
{format(new Date(activity.timestamp), 'MM-dd HH:mm')}
</span>
</div>
<p className="text-xs text-muted-foreground">
{activity.action === 'create' && '新增了家族成员'}
{activity.action === 'update' && '更新了成员信息'}
{activity.action === 'delete' && '删除了成员'}
{activity.action === 'import' && '导入了家族数据'}
</p>
</div>
</div>
))
) : (
<div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-6">
<Card className="border-none shadow-sm bg-primary/5">
<CardHeader>
@@ -475,21 +482,42 @@ export default function DashboardPage() {
monthlyAnniversaries.map((anniversary, index) => {
const yearsAgo = new Date().getFullYear() - new Date(anniversary.date).getFullYear()
return (
<Link href={`/members/${anniversary.member.id}`} key={`${anniversary.member.id}-${anniversary.type}`}>
<Link href={`/members/${anniversary.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${anniversary.member.id}-${anniversary.type}`}>
<div className="flex items-center gap-4 p-3 rounded-lg bg-background/60 border border-border/50 hover:bg-background transition-colors cursor-pointer">
<div className="flex flex-col items-center justify-center w-12 h-12 rounded bg-primary/10 text-primary font-serif">
<span className="text-xs">
{new Date(anniversary.date).getMonth() + 1}
</span>
<span className="font-bold text-lg">{anniversary.day}</span>
</div>
{anniversary.isLunar && anniversary.lunarDisplay ? (
// 农历日期框 - 红色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border-2 border-red-200/50 text-red-800 font-serif shadow-sm">
<span className="text-[10px] leading-tight text-center px-1">
{anniversary.lunarDisplay.replace('月', '')}
</span>
</div>
) : (
// 公历日期框 - 蓝色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border-2 border-blue-200/50 text-blue-800 font-serif shadow-sm">
<span className="text-xs font-medium">
{anniversary.month}
</span>
<span className="font-bold text-xl leading-tight">{anniversary.day}</span>
</div>
)}
<div className="flex-1">
<p className="font-medium">
{anniversary.member.fullName} {anniversary.type === 'birth' ? '诞辰' : '忌日'}
<p className="font-medium flex items-center gap-2">
<MemberNameWithStatus
name={anniversary.member.fullName}
isDead={!!anniversary.member.deathDate}
/> {anniversary.type === 'birth' ? '诞辰' : '忌日'}
{anniversary.isLunar && (
<span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
</span>
)}
</p>
<p className="text-sm text-muted-foreground">
{anniversary.member.generation} · {yearsAgo}
{anniversary.type === 'death' && ' · 已故'}
{anniversary.isLunar && anniversary.lunarDisplay && (
<span className="ml-1">· {anniversary.lunarDisplay}</span>
)}
</p>
</div>
</div>
@@ -504,6 +532,102 @@ export default function DashboardPage() {
</CardContent>
</Card>
</div>
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="font-serif"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivities.length > 0 ? (
recentActivities.map((activity, i) => (
<div key={activity.id} className="flex gap-3 group hover:bg-muted/30 p-2 rounded-lg transition-colors">
<div className="relative flex flex-col items-center">
<div className="h-full w-px bg-border group-last:hidden"></div>
<div className={`absolute top-2 h-2 w-2 rounded-full ring-4 ring-background ${
activity.action === 'CREATE' ? 'bg-green-500' :
activity.action === 'UPDATE' ? 'bg-blue-500' :
activity.action === 'DELETE' ? 'bg-red-500' :
'bg-purple-500'
}`}></div>
</div>
<div className="pb-2 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${
activity.action === 'CREATE' ? 'bg-green-100 text-green-700' :
activity.action === 'UPDATE' ? 'bg-blue-100 text-blue-700' :
activity.action === 'DELETE' ? 'bg-red-100 text-red-700' :
'bg-purple-100 text-purple-700'
}`}>
{activity.action === 'CREATE' ? '创建' :
activity.action === 'UPDATE' ? '更新' :
activity.action === 'DELETE' ? '删除' : '导入'}
</span>
{activity.entityId && activity.entityType === 'MEMBER' ? (
<Link
href={`/members/${activity.entityId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
className="font-medium hover:text-primary hover:underline transition-colors"
>
{activity.entityName || '未知'}
</Link>
) : (
<span className="font-medium">{activity.entityName || '未知'}</span>
)}
<span className="text-xs text-muted-foreground">
{format(new Date(activity.timestamp), 'MM-dd HH:mm')}
</span>
</div>
{activity.action === 'UPDATE' && activity.changes ? (
<div className="text-xs text-muted-foreground space-y-0.5">
{(() => {
const changes = activity.changes as Record<string, { old: any, new: any }>
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 entries = Object.entries(changes)
.filter(([key]) => fieldLabels[key])
.slice(0, 2) // 只显示前2个变更
return entries.length > 0 ? (
<>
{entries.map(([key]) => (
<div key={key}> {fieldLabels[key]}</div>
))}
{Object.keys(changes).length > 2 && (
<div className="italic"> {Object.keys(changes).length} </div>
)}
</>
) : <div></div>
})()}
</div>
) : (
<p className="text-xs text-muted-foreground">
{activity.action === 'CREATE' && '创建了成员'}
{activity.action === 'UPDATE' && '更新了成员'}
{activity.action === 'DELETE' && '删除了成员'}
{activity.action === 'IMPORT' && '导入了数据'}
</p>
)}
</div>
</div>
))
) : (
<div className="text-center py-12">
<p className="text-sm text-foreground font-light tracking-wide"></p>
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</TabsContent>
@@ -525,19 +649,54 @@ export default function DashboardPage() {
type: 'birth' | 'death'
date: Date
originalDate: string
isLunar: boolean
lunarDisplay?: string
month: number
day: number
}> = []
members.forEach(member => {
// 生日
if (member.birthDate) {
const birthDate = new Date(member.birthDate)
const thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
let thisYearBirth: Date
let lunarDisplay: string | undefined
// 检查是否按农历计算
if (member.isLunarDate) {
// 农历生日:计算今年对应的公历日期
const lunarInfo = solar2lunar(birthDate)
if (lunarInfo) {
const thisYearLunar = lunar2solar(
now.getFullYear(),
lunarInfo.lunarMonth,
lunarInfo.lunarDay,
lunarInfo.isLeap
)
if (thisYearLunar) {
thisYearBirth = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
} else {
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
} else {
// 公历生日
thisYearBirth = new Date(now.getFullYear(), birthDate.getMonth(), birthDate.getDate())
}
if (thisYearBirth >= now && thisYearBirth <= threeMonthsLater) {
upcomingEvents.push({
member,
type: 'birth',
date: thisYearBirth,
originalDate: member.birthDate
originalDate: member.birthDate,
isLunar: member.isLunarDate || false,
lunarDisplay,
month: thisYearBirth.getMonth() + 1,
day: thisYearBirth.getDate()
})
}
}
@@ -545,13 +704,44 @@ export default function DashboardPage() {
// 忌日
if (member.deathDate) {
const deathDate = new Date(member.deathDate)
const thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
let thisYearDeath: Date
let lunarDisplay: string | undefined
// 检查是否按农历计算
if (member.isLunarDate) {
// 农历忌日:计算今年对应的公历日期
const lunarInfo = solar2lunar(deathDate)
if (lunarInfo) {
const thisYearLunar = lunar2solar(
now.getFullYear(),
lunarInfo.lunarMonth,
lunarInfo.lunarDay,
lunarInfo.isLeap
)
if (thisYearLunar) {
thisYearDeath = thisYearLunar
lunarDisplay = `${lunarInfo.monthName}${lunarInfo.dayName}`
} else {
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
} else {
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
} else {
// 公历忌日
thisYearDeath = new Date(now.getFullYear(), deathDate.getMonth(), deathDate.getDate())
}
if (thisYearDeath >= now && thisYearDeath <= threeMonthsLater) {
upcomingEvents.push({
member,
type: 'death',
date: thisYearDeath,
originalDate: member.deathDate
originalDate: member.deathDate,
isLunar: member.isLunarDate || false,
lunarDisplay,
month: thisYearDeath.getMonth() + 1,
day: thisYearDeath.getDate()
})
}
}
@@ -563,21 +753,44 @@ export default function DashboardPage() {
upcomingEvents.map((event, index) => {
const yearsAgo = now.getFullYear() - new Date(event.originalDate).getFullYear()
return (
<Link href={`/members/${event.member.id}`} key={`${event.member.id}-${event.type}-${index}`}>
<Link href={`/members/${event.member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={`${event.member.id}-${event.type}-${index}`}>
<Card className="hover:border-primary transition-colors cursor-pointer">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-primary/10 text-primary font-serif">
<span className="text-xs">{event.date.getMonth() + 1}</span>
<span className="font-bold text-lg">{event.date.getDate()}</span>
</div>
{event.isLunar && event.lunarDisplay ? (
// 农历日期框 - 红色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border-2 border-red-200/50 text-red-800 font-serif shadow-sm">
<span className="text-[10px] leading-tight text-center px-1">
{event.lunarDisplay}
</span>
</div>
) : (
// 公历日期框 - 蓝色主题
<div className="flex flex-col items-center justify-center w-14 h-14 rounded-lg bg-gradient-to-br from-blue-50 to-sky-50 border-2 border-blue-200/50 text-blue-800 font-serif shadow-sm">
<span className="text-xs font-medium">
{event.month}
</span>
<span className="font-bold text-xl leading-tight">{event.day}</span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-sm">
{event.member.fullName} {event.type === 'birth' ? '诞辰' : '忌日'}
<p className="font-medium text-sm flex items-center gap-2">
<MemberNameWithStatus
name={event.member.fullName}
isDead={!!event.member.deathDate}
/> {event.type === 'birth' ? '诞辰' : '忌日'}
{event.isLunar && (
<span className="text-[10px] text-amber-600 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{event.member.generation} · {yearsAgo}
{event.type === 'death' && ' · 已故'}
{event.isLunar && event.lunarDisplay && (
<span className="ml-1">· {event.lunarDisplay}</span>
)}
</p>
</div>
</div>
@@ -622,7 +835,7 @@ export default function DashboardPage() {
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{locationMembers.map((member: any) => (
<Link href={`/members/${member.id}`} key={member.id}>
<Link href={`/members/${member.id}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`} key={member.id}>
<div className="px-3 py-2 bg-muted rounded hover:bg-muted/80 transition-colors">
<p className="text-sm font-medium truncate">{member.fullName}</p>
<p className="text-xs text-muted-foreground"> {member.generation} </p>
@@ -652,9 +865,10 @@ function StatCard({
value,
subtitle,
icon,
}: { title: string; value: string; subtitle: string; icon: React.ReactNode }) {
return (
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group">
href,
}: { title: string; value: string; subtitle: string; icon: React.ReactNode; href?: string }) {
const cardContent = (
<Card className="relative overflow-hidden border border-border/50 shadow-sm bg-gradient-to-br from-card to-card/50 backdrop-blur transition-all hover:shadow-lg hover:border-primary/30 group cursor-pointer">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">{title}</p>
@@ -670,4 +884,10 @@ function StatCard({
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-primary/50 via-primary to-primary/50 opacity-0 group-hover:opacity-100 transition-opacity" />
</Card>
)
if (href) {
return <Link href={href}>{cardContent}</Link>
}
return cardContent
}
+93 -21
View File
@@ -11,6 +11,8 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Check, ChevronsUpDown } from 'lucide-react'
import { cn } from '@/lib/utils'
import { MemberNameWithStatus } from '@/components/member-name-with-status'
import { RelationshipPathDisplay } from '@/components/relationship-path-display'
export default function RelationshipTestPage() {
const { treeData } = useFamily()
@@ -71,7 +73,15 @@ export default function RelationshipTestPage() {
className="w-full justify-between"
>
{fromId
? members.find((member) => member.id === fromId)?.fullName
? (() => {
const selectedMember = members.find((member) => member.id === fromId)
return selectedMember ? (
<MemberNameWithStatus
name={selectedMember.fullName}
isDead={!!selectedMember.deathDate}
/>
) : "搜索或选择成员A..."
})()
: "搜索或选择成员A..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -97,7 +107,10 @@ export default function RelationshipTestPage() {
fromId === member.id ? "opacity-100" : "opacity-0"
)}
/>
{member.fullName} ({member.generation})
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
/> ({member.generation})
</CommandItem>
))}
</CommandGroup>
@@ -118,7 +131,15 @@ export default function RelationshipTestPage() {
className="w-full justify-between"
>
{toId
? members.find((member) => member.id === toId)?.fullName
? (() => {
const selectedMember = members.find((member) => member.id === toId)
return selectedMember ? (
<MemberNameWithStatus
name={selectedMember.fullName}
isDead={!!selectedMember.deathDate}
/>
) : "搜索或选择成员B..."
})()
: "搜索或选择成员B..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
@@ -144,7 +165,10 @@ export default function RelationshipTestPage() {
toId === member.id ? "opacity-100" : "opacity-0"
)}
/>
{member.fullName} ({member.generation})
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
/> ({member.generation})
</CommandItem>
))}
</CommandGroup>
@@ -175,7 +199,10 @@ export default function RelationshipTestPage() {
<div className="flex items-center justify-center gap-6 p-8 bg-muted/30 rounded-lg">
<div className="text-center">
<div className="text-2xl font-serif font-light mb-1">
{result.from.fullName}
<MemberNameWithStatus
name={result.from.fullName}
isDead={!!result.from.deathDate}
/>
</div>
<div className="text-sm text-muted-foreground font-light">
{result.from.generation}
@@ -187,15 +214,19 @@ export default function RelationshipTestPage() {
{result.relationship.term}
</Badge>
{result.relationship.path && (
<span className="text-xs text-muted-foreground">
: {result.relationship.path}
</span>
<div className="text-xs text-muted-foreground">
<div className="mb-1">:</div>
<RelationshipPathDisplay path={result.relationship.path} />
</div>
)}
</div>
<div className="text-center">
<div className="text-2xl font-serif font-light mb-1">
{result.to.fullName}
<MemberNameWithStatus
name={result.to.fullName}
isDead={!!result.to.deathDate}
/>
</div>
<div className="text-sm text-muted-foreground font-light">
{result.to.generation}
@@ -207,7 +238,7 @@ export default function RelationshipTestPage() {
<div className="p-6 bg-primary/5 rounded-lg">
<div className="text-sm font-light mb-2 text-center"></div>
<div className="text-muted-foreground font-light text-center">
<strong>{result.from.fullName}</strong> <strong>{result.to.fullName}</strong> <strong className="text-primary">{result.relationship.term}</strong>
<strong><MemberNameWithStatus name={result.from.fullName} isDead={!!result.from.deathDate} /></strong> <strong><MemberNameWithStatus name={result.to.fullName} isDead={!!result.to.deathDate} /></strong> <strong className="text-primary">{result.relationship.term}</strong>
</div>
</div>
)}
@@ -230,7 +261,10 @@ export default function RelationshipTestPage() {
<Card className="border-none shadow-sm bg-card/50 backdrop-blur">
<CardHeader>
<CardTitle className="text-xl font-serif font-light">
{treeData.members[fromId]?.fullName}
<MemberNameWithStatus
name={treeData.members[fromId]?.fullName || ''}
isDead={!!treeData.members[fromId]?.deathDate}
/>
</CardTitle>
</CardHeader>
<CardContent>
@@ -244,13 +278,23 @@ export default function RelationshipTestPage() {
{relatives.father && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{relatives.father.fullName}</div>
<div className="font-medium">
<MemberNameWithStatus
name={relatives.father.fullName}
isDead={!!relatives.father.deathDate}
/>
</div>
</div>
)}
{relatives.mother && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{relatives.mother.fullName}</div>
<div className="font-medium">
<MemberNameWithStatus
name={relatives.mother.fullName}
isDead={!!relatives.mother.deathDate}
/>
</div>
</div>
)}
{relatives.spouses.length > 0 && (
@@ -258,7 +302,10 @@ export default function RelationshipTestPage() {
<div className="text-sm text-muted-foreground mb-1"></div>
{relatives.spouses.map((spouse: any) => (
<div key={spouse.id} className="font-medium">
{spouse.fullName}
<MemberNameWithStatus
name={spouse.fullName}
isDead={!!spouse.deathDate}
/>
</div>
))}
</div>
@@ -271,7 +318,10 @@ export default function RelationshipTestPage() {
<div className="space-y-1">
{relatives.children.slice(0, 3).map((child: any) => (
<div key={child.id} className="font-medium text-sm">
{child.fullName}
<MemberNameWithStatus
name={child.fullName}
isDead={!!child.deathDate}
/>
</div>
))}
{relatives.children.length > 3 && (
@@ -296,7 +346,10 @@ export default function RelationshipTestPage() {
<div className="flex flex-wrap gap-2">
{siblings.map((sibling: any) => (
<Badge key={sibling.id} variant="outline">
{sibling.fullName}
<MemberNameWithStatus
name={sibling.fullName}
isDead={!!sibling.deathDate}
/>
</Badge>
))}
</div>
@@ -328,13 +381,23 @@ export default function RelationshipTestPage() {
{relatives.father && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{relatives.father.fullName}</div>
<div className="font-medium">
<MemberNameWithStatus
name={relatives.father.fullName}
isDead={!!relatives.father.deathDate}
/>
</div>
</div>
)}
{relatives.mother && (
<div className="p-3 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-1"></div>
<div className="font-medium">{relatives.mother.fullName}</div>
<div className="font-medium">
<MemberNameWithStatus
name={relatives.mother.fullName}
isDead={!!relatives.mother.deathDate}
/>
</div>
</div>
)}
{relatives.spouses.length > 0 && (
@@ -342,7 +405,10 @@ export default function RelationshipTestPage() {
<div className="text-sm text-muted-foreground mb-1"></div>
{relatives.spouses.map((spouse: any) => (
<div key={spouse.id} className="font-medium">
{spouse.fullName}
<MemberNameWithStatus
name={spouse.fullName}
isDead={!!spouse.deathDate}
/>
</div>
))}
</div>
@@ -355,7 +421,10 @@ export default function RelationshipTestPage() {
<div className="space-y-1">
{relatives.children.slice(0, 3).map((child: any) => (
<div key={child.id} className="font-medium text-sm">
{child.fullName}
<MemberNameWithStatus
name={child.fullName}
isDead={!!child.deathDate}
/>
</div>
))}
{relatives.children.length > 3 && (
@@ -380,7 +449,10 @@ export default function RelationshipTestPage() {
<div className="flex flex-wrap gap-2">
{siblings.map((sibling: any) => (
<Badge key={sibling.id} variant="outline">
{sibling.fullName}
<MemberNameWithStatus
name={sibling.fullName}
isDead={!!sibling.deathDate}
/>
</Badge>
))}
</div>
+2 -2
View File
@@ -87,7 +87,7 @@ export default function SettingsPage() {
<div className="min-h-screen bg-background flex flex-col font-sans">
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1 max-w-6xl">
<h1 className="text-3xl font-serif font-bold mb-8"> (Settings)</h1>
<h1 className="text-3xl font-serif font-bold mb-8"></h1>
<div className="space-y-6">
{/* 用户信息和密码修改 - 两列布局 */}
@@ -178,7 +178,7 @@ export default function SettingsPage() {
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> (Data Management)
<Database className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
+50 -12
View File
@@ -8,6 +8,7 @@ import { Clock, Map as MapIcon, ArrowRight, Calendar } from "lucide-react"
import dynamic from 'next/dynamic'
import { useMemo, useEffect, useState } from 'react'
import * as echarts from 'echarts'
import { MemberNameWithStatus } from "@/components/member-name-with-status"
const ReactECharts = dynamic(() => import('echarts-for-react'), { ssr: false })
@@ -184,9 +185,10 @@ const geoCoordMap: Record<string, [number, number]> = {
}
export default function TimelinePage() {
const { treeData, getMember, isLoading } = useFamily()
const { treeData, currentTree, getMember, isLoading } = useFamily()
const members = Object.values(treeData.members)
const [mapLoaded, setMapLoaded] = useState(false)
const treeId = currentTree?.id
// 注册中国地图 - 从外部文件加载完整数据(必须在条件渲染之前)
useEffect(() => {
@@ -505,17 +507,17 @@ export default function TimelinePage() {
<SiteHeader />
<main className="container mx-auto py-8 px-4 md:px-6 flex-1">
<div className="mb-8">
<h1 className="text-3xl font-serif font-bold mb-2"> (History)</h1>
<h1 className="text-3xl font-serif font-bold mb-2"></h1>
<p className="text-muted-foreground"></p>
</div>
<Tabs defaultValue="timeline" className="w-full">
<TabsList className="mb-6 w-full md:w-auto">
<TabsTrigger value="timeline" className="flex items-center gap-2">
<Clock className="h-4 w-4" /> (Chronology)
<Clock className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="migration" className="flex items-center gap-2">
<MapIcon className="h-4 w-4" /> (Migration)
<MapIcon className="h-4 w-4" />
</TabsTrigger>
</TabsList>
@@ -545,7 +547,14 @@ export default function TimelinePage() {
>
{event.type === "birth" ? "诞生" : "逝世"}
</span>
<span className="font-serif font-bold">{event.member.fullName}</span>
<span className="font-serif font-bold">
<MemberNameWithStatus
name={event.member.fullName}
isDead={!!event.member.deathDate}
memberId={event.member.id}
treeId={treeId}
/>
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${event.member.gender === "MALE" ? "bg-blue-100 text-blue-700" : "bg-pink-100 text-pink-700"}`}>
{event.member.gender === "MALE" ? "男" : "女"}
</span>
@@ -554,19 +563,34 @@ export default function TimelinePage() {
{/* 父母信息 */}
{event.member.fatherId && (
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
:{getMember(event.member.fatherId)?.fullName || "未知"}
:<MemberNameWithStatus
name={getMember(event.member.fatherId)?.fullName || "未知"}
isDead={!!getMember(event.member.fatherId)?.deathDate}
memberId={event.member.fatherId}
treeId={treeId}
/>
</span>
)}
{event.member.motherId && (
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-50 text-orange-700 border border-orange-200">
:{getMember(event.member.motherId)?.fullName || "未知"}
:<MemberNameWithStatus
name={getMember(event.member.motherId)?.fullName || "未知"}
isDead={!!getMember(event.member.motherId)?.deathDate}
memberId={event.member.motherId}
treeId={treeId}
/>
</span>
)}
{/* 配偶信息 */}
{event.member.spouseIds && event.member.spouseIds.length > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-50 text-purple-700 border border-purple-200">
:{getMember(event.member.spouseIds[0])?.fullName || "未知"}
:<MemberNameWithStatus
name={getMember(event.member.spouseIds[0])?.fullName || "未知"}
isDead={!!getMember(event.member.spouseIds[0])?.deathDate}
memberId={event.member.spouseIds[0]}
treeId={treeId}
/>
</span>
)}
@@ -618,7 +642,7 @@ export default function TimelinePage() {
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<Card>
<CardHeader>
<CardTitle className="font-serif"> (Records)</CardTitle>
<CardTitle className="font-serif"></CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[600px] pr-4">
@@ -635,13 +659,27 @@ export default function TimelinePage() {
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-bold">{mig.from}</div>
<div className="text-xs text-muted-foreground mt-1">{mig.father.fullName}</div>
<div className="text-xs text-muted-foreground mt-1">
<MemberNameWithStatus
name={mig.father.fullName}
isDead={!!mig.father.deathDate}
memberId={mig.father.id}
treeId={treeId}
/>
</div>
</div>
<ArrowRight className="text-muted-foreground" />
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
<div className="text-xs text-muted-foreground mb-1"></div>
<div className="font-bold text-primary">{mig.to}</div>
<div className="text-xs text-muted-foreground mt-1">{mig.person.fullName}</div>
<div className="text-xs text-muted-foreground mt-1">
<MemberNameWithStatus
name={mig.person.fullName}
isDead={!!mig.person.deathDate}
memberId={mig.person.id}
treeId={treeId}
/>
</div>
</div>
</div>
</div>
@@ -658,7 +696,7 @@ export default function TimelinePage() {
<Card className="bg-gradient-to-br from-primary/5 via-background to-secondary/5 border-primary/20">
<CardHeader>
<CardTitle className="font-serif"> (Migration Flow)</CardTitle>
<CardTitle className="font-serif"></CardTitle>
</CardHeader>
<CardContent>
<div className="relative h-[600px] overflow-hidden rounded-lg bg-background/50 backdrop-blur border border-border/50">
+17 -7
View File
@@ -19,6 +19,8 @@ import { Badge } from "@/components/ui/badge"
import { ZoomIn, ZoomOut, Move, Download, LayoutGrid, ChevronDown, Users, Network } from "lucide-react"
import { useState, useRef, useEffect, useCallback } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
import { RelationshipPathDisplay } from "@/components/relationship-path-display"
type ViewMode = "traditional" | "d3-org-chart"
@@ -225,7 +227,8 @@ export default function TreePage() {
}
} else {
console.log('Navigating to member detail')
router.push(`/members/${id}`)
const params = new URLSearchParams(window.location.search)
router.push(`/members/${id}?${params.toString()}`)
}
}, [router, calculateRelationship, treeData.members])
@@ -504,7 +507,10 @@ export default function TreePage() {
<div className="flex items-center justify-center gap-4 p-6 bg-muted rounded-lg">
<div className="text-center">
<div className="text-2xl font-bold font-serif mb-1">
{relationResult.from.fullName}
<MemberNameWithStatus
name={relationResult.from.fullName}
isDead={!!relationResult.from.deathDate}
/>
</div>
<div className="text-sm text-muted-foreground">
{relationResult.from.generation}
@@ -519,7 +525,10 @@ export default function TreePage() {
<div className="text-center">
<div className="text-2xl font-bold font-serif mb-1">
{relationResult.to.fullName}
<MemberNameWithStatus
name={relationResult.to.fullName}
isDead={!!relationResult.to.deathDate}
/>
</div>
<div className="text-sm text-muted-foreground">
{relationResult.to.generation}
@@ -531,7 +540,7 @@ export default function TreePage() {
<div id="relation-description" className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<div className="text-sm font-medium mb-1"></div>
<div className="text-muted-foreground">
<strong>{relationResult.from.fullName}</strong> <strong>{relationResult.to.fullName}</strong> <strong className="text-primary">{relationResult.relationship.term}</strong>
<strong><MemberNameWithStatus name={relationResult.from.fullName} isDead={!!relationResult.from.deathDate} /></strong> <strong><MemberNameWithStatus name={relationResult.to.fullName} isDead={!!relationResult.to.deathDate} /></strong> <strong className="text-primary">{relationResult.relationship.term}</strong>
</div>
</div>
@@ -539,9 +548,10 @@ export default function TreePage() {
{relationResult.relationship.path && (
<div className="p-4 bg-muted rounded-lg">
<div className="text-sm font-medium mb-2"></div>
<div className="text-xs text-muted-foreground break-all">
{relationResult.relationship.path}
</div>
<RelationshipPathDisplay
path={relationResult.relationship.path}
className="text-xs"
/>
</div>
)}
</div>