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
+62
View File
@@ -0,0 +1,62 @@
.antd-lunar-calendar-wrapper {
background: white;
border-radius: 12px;
padding: 16px;
}
.lunar-calendar-cell {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 4px;
}
.solar-date {
font-size: 16px;
font-weight: 600;
color: #1f2937;
margin-bottom: 2px;
}
.lunar-info {
display: flex;
align-items: center;
gap: 4px;
}
.lunar-date {
font-size: 11px;
color: #dc2626;
font-family: serif;
}
.lunar-leap-badge {
font-size: 10px;
padding: 0 4px;
background: #fef3c7;
color: #d97706;
border-radius: 4px;
border: 1px solid #fbbf24;
}
/* 今天的样式 */
.ant-picker-calendar-date-today .solar-date {
color: #1677ff;
}
/* 选中的样式 */
.ant-picker-cell-selected .lunar-calendar-cell {
background: #e6f4ff;
border-radius: 8px;
}
/* 其他月份的日期 */
.ant-picker-cell-disabled .solar-date {
color: #d1d5db;
}
.ant-picker-cell-disabled .lunar-date {
color: #d1d5db;
}
+84
View File
@@ -0,0 +1,84 @@
"use client"
import { Calendar } from 'antd'
import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import { solar2lunar } from '@/lib/lunar-calendar'
import './antd-lunar-calendar.css'
dayjs.locale('zh-cn')
interface AntdLunarCalendarProps {
onSelect?: (date: Date) => void
}
export function AntdLunarCalendar({ onSelect }: AntdLunarCalendarProps) {
const cellRender = (current: Dayjs) => {
const date = current.toDate()
const lunarInfo = solar2lunar(date)
return (
<div className="lunar-calendar-cell">
<div className="solar-date">{current.date()}</div>
{lunarInfo && (
<div className="lunar-info">
<div className="lunar-date">{lunarInfo.dayName}</div>
{lunarInfo.isLeap && (
<div className="lunar-leap-badge"></div>
)}
</div>
)}
</div>
)
}
const handleSelect = (date: Dayjs) => {
if (onSelect) {
onSelect(date.toDate())
}
}
return (
<div className="antd-lunar-calendar-wrapper">
<Calendar
fullCellRender={cellRender}
onSelect={handleSelect}
validRange={[dayjs('1900-01-01'), dayjs('2100-12-31')]}
locale={{
lang: {
locale: 'zh-cn',
monthFormat: 'M月',
yearFormat: 'YYYY年',
today: '今天',
now: '此刻',
backToToday: '返回今天',
ok: '确定',
timeSelect: '选择时间',
dateSelect: '选择日期',
weekSelect: '选择周',
clear: '清除',
month: '月',
year: '年',
previousMonth: '上个月',
nextMonth: '下个月',
monthSelect: '选择月份',
yearSelect: '选择年份',
decadeSelect: '选择年代',
dayFormat: 'D日',
dateFormat: 'YYYY-MM-DD',
dateTimeFormat: 'YYYY-MM-DD HH:mm:ss',
previousYear: '上一年',
nextYear: '下一年',
previousDecade: '上一年代',
nextDecade: '下一年代',
previousCentury: '上一世纪',
nextCentury: '下一世纪',
shortWeekDays: ['日', '一', '二', '三', '四', '五', '六'],
shortMonths: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
}
}}
/>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
"use client"
import { useState, useEffect } from "react"
import { Calendar as CalendarIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { solar2lunar, formatDate } from "@/lib/lunar-calendar"
import dynamic from 'next/dynamic'
// 动态导入 Ant Design 日历组件(避免 SSR 问题)
const AntdLunarCalendar = dynamic(
() => import('@/components/antd-lunar-calendar').then(mod => ({ default: mod.AntdLunarCalendar })),
{ ssr: false, loading: () => <div className="h-[400px] flex items-center justify-center">...</div> }
)
interface CalendarDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CalendarDialog({ open, onOpenChange }: CalendarDialogProps) {
const [selectedDate, setSelectedDate] = useState<Date>(new Date())
// 获取农历信息
const lunarInfo = solar2lunar(selectedDate)
// 格式化显示
const solarStr = formatDate(selectedDate)
const [year, monthNum, day] = solarStr.split('-')
const weekDay = ['日', '一', '二', '三', '四', '五', '六'][selectedDate.getDay()]
// 当对话框打开时,重置为当前日期
useEffect(() => {
if (open) {
const now = new Date()
setSelectedDate(now)
}
}, [open])
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl w-[90vw] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl font-serif">
<CalendarIcon className="h-6 w-6 text-red-600" />
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Ant Design 日历 */}
<div className="space-y-4">
<AntdLunarCalendar onSelect={(date) => setSelectedDate(date)} />
</div>
{/* 日期信息卡片 - 美化版 */}
<div className="grid md:grid-cols-2 gap-6">
{/* 公历信息 */}
<div className="group relative rounded-2xl border-2 border-blue-300/50 bg-gradient-to-br from-blue-50 via-sky-50 to-blue-100 p-6 shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden">
{/* 装饰性背景 */}
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-200/20 rounded-full -mr-16 -mt-16 group-hover:scale-110 transition-transform duration-500"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-sky-200/20 rounded-full -ml-12 -mb-12 group-hover:scale-110 transition-transform duration-500"></div>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<div className="h-2 w-2 rounded-full bg-blue-600 animate-pulse"></div>
<h3 className="text-sm font-semibold text-blue-900 tracking-wide"> SOLAR</h3>
</div>
<div className="space-y-2">
<div className="text-2xl font-bold text-blue-900 font-serif leading-tight">
{year}
</div>
<div className="text-3xl font-bold text-blue-900 font-serif leading-tight tracking-tight">
{parseInt(monthNum)}{parseInt(day)}
</div>
<div className="flex items-center gap-3">
<div className="px-3 py-1 rounded-lg bg-blue-600/10 border border-blue-300/50">
<span className="text-sm text-blue-800 font-medium">{weekDay}</span>
</div>
</div>
</div>
</div>
</div>
{/* 农历信息 */}
{lunarInfo ? (
<div className="group relative rounded-2xl border-2 border-red-300/50 bg-gradient-to-br from-red-50 via-orange-50 to-amber-100 p-6 shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden">
{/* 装饰性背景 */}
<div className="absolute top-0 right-0 w-32 h-32 bg-red-200/20 rounded-full -mr-16 -mt-16 group-hover:scale-110 transition-transform duration-500"></div>
<div className="absolute bottom-0 left-0 w-24 h-24 bg-amber-200/20 rounded-full -ml-12 -mb-12 group-hover:scale-110 transition-transform duration-500"></div>
<div className="relative z-10">
<div className="flex items-center gap-2 mb-4">
<div className="h-2 w-2 rounded-full bg-red-600 animate-pulse"></div>
<h3 className="text-sm font-semibold text-red-900 tracking-wide"> LUNAR</h3>
</div>
<div className="space-y-3">
<div className="text-2xl font-bold text-red-900 font-serif leading-tight">
{lunarInfo.ganZhiYear}
</div>
<div className="text-xl font-semibold text-red-800 font-serif">
{lunarInfo.monthName}{lunarInfo.dayName}
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="px-3 py-1 rounded-lg bg-red-600/10 border border-red-300/50 text-sm text-red-800 font-medium">
{lunarInfo.animal}
</span>
{lunarInfo.isLeap && (
<span className="px-3 py-1 rounded-lg bg-amber-500/20 border border-amber-400/50 text-sm text-amber-800 font-medium flex items-center gap-1">
<span className="text-xs"></span>
</span>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-2xl border-2 border-gray-300/50 bg-gradient-to-br from-gray-50 to-gray-100 p-6 shadow-lg flex items-center justify-center">
<div className="text-center">
<div className="text-4xl mb-2">📅</div>
<div className="text-gray-600 font-medium"></div>
<div className="text-xs text-gray-500 mt-1"> (1900-2100)</div>
</div>
</div>
)}
</div>
{/* 今天按钮 */}
<div className="flex justify-center">
<Button
variant="outline"
onClick={() => setSelectedDate(new Date())}
>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
+139
View File
@@ -0,0 +1,139 @@
"use client"
import { solar2lunar, formatDate } from "@/lib/lunar-calendar"
interface DualCalendarDisplayProps {
date: Date
className?: string
size?: "sm" | "md" | "lg"
showWeekday?: boolean
compact?: boolean
}
export function DualCalendarDisplay({
date,
className = "",
size = "md",
showWeekday = true,
compact = false
}: DualCalendarDisplayProps) {
// 获取农历信息
const lunarInfo = solar2lunar(date)
// 格式化显示
const solarStr = formatDate(date)
const [year, monthNum, day] = solarStr.split('-')
const weekDay = ['日', '一', '二', '三', '四', '五', '六'][date.getDay()]
// 尺寸配置
const sizeConfig = {
sm: {
container: "h-[80px]",
padding: "p-3",
titleGap: "mb-2",
contentGap: "space-y-1",
solarText: "text-lg",
weekText: "text-xs",
lunarText: "text-sm",
tagText: "text-[10px]",
tagPadding: "px-1.5 py-0.5"
},
md: {
container: "h-[110px]",
padding: "p-5",
titleGap: "mb-3",
contentGap: "space-y-2",
solarText: "text-2xl",
weekText: "text-sm",
lunarText: "text-lg",
tagText: "text-xs",
tagPadding: "px-2 py-0.5"
},
lg: {
container: "h-[140px]",
padding: "p-6",
titleGap: "mb-4",
contentGap: "space-y-3",
solarText: "text-3xl",
weekText: "text-base",
lunarText: "text-xl",
tagText: "text-sm",
tagPadding: "px-3 py-1"
}
}
const config = sizeConfig[size]
if (compact) {
// 紧凑模式:单行显示
return (
<div className={`flex items-center gap-2 ${className}`}>
<span className="text-sm font-medium">
{year}{parseInt(monthNum)}{parseInt(day)}
</span>
{lunarInfo && (
<>
<span className="text-xs text-muted-foreground">|</span>
<span className="text-sm text-muted-foreground font-serif">
{lunarInfo.monthName}{lunarInfo.dayName}
</span>
</>
)}
</div>
)
}
return (
<div className={`grid md:grid-cols-2 gap-4 ${className}`}>
{/* 公历信息 */}
<div className={`rounded-xl border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-sky-50 ${config.padding} ${config.container} flex flex-col`}>
<div className={`flex items-center gap-2 ${config.titleGap}`}>
<div className="h-1 w-1 rounded-full bg-blue-600"></div>
<h3 className="text-xs font-medium text-blue-900"></h3>
</div>
<div className={`${config.contentGap} flex-1 flex flex-col justify-center`}>
<div className={`${config.solarText} font-bold text-blue-900 font-serif leading-tight`}>
{year}{parseInt(monthNum)}{parseInt(day)}
</div>
{showWeekday && (
<div className={`${config.weekText} text-blue-700 font-medium`}>
{weekDay}
</div>
)}
</div>
</div>
{/* 农历信息 */}
{lunarInfo ? (
<div className={`rounded-xl border-2 border-red-200 bg-gradient-to-br from-red-50 to-amber-50 ${config.padding} ${config.container} flex flex-col overflow-hidden`}>
<div className={`flex items-center gap-2 ${config.titleGap}`}>
<div className="h-1 w-1 rounded-full bg-red-600"></div>
<h3 className="text-xs font-medium text-red-900"></h3>
</div>
<div className="flex-1 flex items-center min-h-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`${config.lunarText} font-bold text-red-900 font-serif leading-tight whitespace-nowrap`}>
{lunarInfo.ganZhiYear} {lunarInfo.monthName}{lunarInfo.dayName}
</span>
<span className={`${config.tagPadding} rounded-full bg-red-100 text-red-700 ${config.tagText} font-medium border border-red-200 whitespace-nowrap`}>
{lunarInfo.animal}
</span>
{lunarInfo.isLeap && (
<span className={`${config.tagPadding} rounded-full bg-amber-100 text-amber-700 ${config.tagText} font-medium border border-amber-200 whitespace-nowrap`}>
</span>
)}
</div>
</div>
</div>
) : (
<div className={`rounded-xl border-2 border-gray-200 bg-gray-50 ${config.padding} ${config.container} flex items-center justify-center`}>
<div className="text-center text-gray-500 text-sm">
<div className="text-xs mt-1"> (1900-2100)</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,98 @@
"use client"
import { MemberNameWithStatus } from "./member-name-with-status"
import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"
export function MemberNameWithStatusDemo() {
return (
<div className="space-y-6 p-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 方案1: 左上角圆点 */}
<div>
<h3 className="text-sm font-semibold mb-3">方案1: 左上角圆点</h3>
<div className="space-y-2">
<div className="flex items-center gap-4">
<MemberNameWithStatus name="虞雨欣" isDead={false} dotPosition="top-left" />
<span className="text-xs text-muted-foreground"> - 绿</span>
</div>
<div className="flex items-center gap-4">
<MemberNameWithStatus name="虞文昌" isDead={true} dotPosition="top-left" />
<span className="text-xs text-muted-foreground"> - + </span>
</div>
</div>
</div>
{/* 方案2: 左侧圆点 */}
<div>
<h3 className="text-sm font-semibold mb-3">方案2: 左侧圆点</h3>
<div className="space-y-2">
<div className="flex items-center gap-4">
<MemberNameWithStatus name="虞雨欣" isDead={false} dotPosition="left" />
<span className="text-xs text-muted-foreground"> - 绿</span>
</div>
<div className="flex items-center gap-4">
<MemberNameWithStatus name="虞文昌" isDead={true} dotPosition="left" />
<span className="text-xs text-muted-foreground"> - + </span>
</div>
</div>
</div>
{/* 方案3: 仅文字颜色 */}
<div>
<h3 className="text-sm font-semibold mb-3">方案3: 仅文字颜色</h3>
<div className="space-y-2">
<div className="flex items-center gap-4">
<MemberNameWithStatus name="虞雨欣" isDead={false} showDot={false} />
<span className="text-xs text-muted-foreground"> - </span>
</div>
<div className="flex items-center gap-4">
<MemberNameWithStatus name="虞文昌" isDead={true} showDot={false} />
<span className="text-xs text-muted-foreground"> - </span>
</div>
</div>
</div>
{/* 实际应用示例 */}
<div>
<h3 className="text-sm font-semibold mb-3"></h3>
<div className="space-y-3">
<Card className="p-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center">
<span className="text-xs">12</span>
<span className="font-bold">5</span>
</div>
<div>
<p className="font-medium">
<MemberNameWithStatus name="虞国英" isDead={false} />
</p>
<p className="text-xs text-muted-foreground"> 2 · 100 </p>
</div>
</div>
</Card>
<Card className="p-3">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-lg bg-blue-50 flex items-center justify-center">
<span className="text-xs">12</span>
<span className="font-bold">8</span>
</div>
<div>
<p className="font-medium">
<MemberNameWithStatus name="林雅芳" isDead={true} />
</p>
<p className="text-xs text-muted-foreground"> 2 · 33 · </p>
</div>
</div>
</Card>
</div>
</div>
</CardContent>
</Card>
</div>
)
}
+114
View File
@@ -0,0 +1,114 @@
import { cn } from "@/lib/utils"
import Link from "next/link"
interface MemberNameWithStatusProps {
name: string
isDead?: boolean
className?: string
showDot?: boolean
dotPosition?: "left" | "top-left"
memberId?: string
treeId?: string
clickable?: boolean
}
export function MemberNameWithStatus({
name,
isDead = false,
className = "",
showDot = true,
dotPosition = "top-left",
memberId,
treeId,
clickable = true
}: MemberNameWithStatusProps) {
const href = memberId
? `/members/${memberId}${treeId ? `?treeId=${treeId}` : ''}`
: '#'
const content = (
<>
{showDot && dotPosition === "left" && (
<span className={cn(
"h-1.5 w-1.5 rounded-full flex-shrink-0",
isDead ? "bg-gray-400" : "bg-green-500"
)}></span>
)}
<span className={isDead ? "text-muted-foreground" : "text-foreground"}>
{name}
</span>
{showDot && dotPosition === "top-left" && (
<span className={cn(
"absolute right-0.5 top-1 h-2 w-2 rounded-full",
isDead ? "bg-gray-400" : "bg-green-500"
)}></span>
)}
</>
)
if (!showDot) {
if (clickable && memberId) {
return (
<Link
href={href}
className={cn(
isDead ? "text-muted-foreground" : "text-foreground",
"hover:underline hover:text-primary transition-colors cursor-pointer",
className
)}
>
{name}
</Link>
)
}
return (
<span className={cn(
isDead ? "text-muted-foreground" : "text-foreground",
className
)}>
{name}
</span>
)
}
if (clickable && memberId) {
if (dotPosition === "left") {
return (
<Link
href={href}
className={cn(
"flex items-center gap-1.5 hover:underline hover:text-primary transition-colors cursor-pointer",
className
)}
>
{content}
</Link>
)
}
return (
<Link
href={href}
className={cn(
"relative inline-flex items-center pr-3 hover:underline hover:text-primary transition-colors cursor-pointer",
className
)}
>
{content}
</Link>
)
}
if (dotPosition === "left") {
return (
<span className={cn("flex items-center gap-1.5", className)}>
{content}
</span>
)
}
return (
<span className={cn("relative inline-flex items-center pr-3", className)}>
{content}
</span>
)
}
+211 -128
View File
@@ -9,20 +9,25 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { CalendarIcon, User, Scroll, Users, Images, BookOpen } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Switch } from "@/components/ui/switch"
import { ImageUpload } from "@/components/ui/image-upload"
import { PhotoGallery } from "./photo-gallery"
import { StoryManager } from "./story-manager"
import { DateInputWithLunar } from "@/components/ui/date-input-with-lunar"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
interface MemberFormProps {
initialData?: Partial<FamilyMember>
existingMembers?: FamilyMember[]
onSubmit: (data: FamilyMember) => void
onCancel: () => void
isFounder?: boolean
}
export function MemberForm({ initialData, existingMembers = [], onSubmit, onCancel }: MemberFormProps) {
export function MemberForm({ initialData, existingMembers = [], onSubmit, onCancel, isFounder = false }: MemberFormProps) {
// 计算最大世系
const maxGeneration = existingMembers.length > 0
? Math.max(...existingMembers.map(m => m.generation || 1))
@@ -31,10 +36,26 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
// 获取始祖的姓氏(第1世成员)
const ancestorSurname = existingMembers.find(m => m.generation === 1)?.surname || ""
// 始祖专用样式
const founderCardClass = isFounder ? "border-2 border-amber-400 shadow-md" : ""
// 生成兼容的UUID
const generateUUID = () => {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID()
}
// 降级方案:生成简单的UUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0
const v = c === 'x' ? r : (r & 0x3 | 0x8)
return v.toString(16)
})
}
// Initialize state with default values or initialData
const [formData, setFormData] = useState<Partial<FamilyMember>>(() => {
const defaults = {
id: crypto.randomUUID(),
id: generateUUID(),
surname: ancestorSurname,
givenName: "",
fullName: "",
@@ -255,18 +276,18 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
{/* 基本信息和家庭关系 - 两列布局 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 左列:基本信息 */}
<Card>
<Card className={founderCardClass}>
<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)}
value={formData.avatarUrl}
onChange={(imageUrl) => handleChange("avatarUrl", imageUrl)}
/>
</div>
@@ -291,19 +312,19 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="gender"> (Gender)</Label>
<Label htmlFor="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>
<SelectItem value="MALE"></SelectItem>
<SelectItem value="FEMALE"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="generation"> (Generation)</Label>
<Label htmlFor="generation"></Label>
<Input
id="generation"
type="number"
@@ -316,80 +337,102 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</Card>
{/* 右列:家庭关系 */}
<Card>
<Card className={founderCardClass}>
<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="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>
<Label htmlFor="fatherId"></Label>
{isFounder ? (
<Input
placeholder="填写父亲姓名"
value={formData.spouseFatherName || ""}
onChange={(e) => handleChange("spouseFatherName", e.target.value)}
/>
) : (
<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"></SelectItem>
{potentialRelatives
.filter((m) => m.gender === "MALE" && m.generation === (formData.generation || 1) - 1)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
<MemberNameWithStatus
name={m.fullName}
isDead={!!m.deathDate}
/> ({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>
<Label htmlFor="motherId"></Label>
{isFounder ? (
<Input
placeholder="填写母亲姓名"
value={formData.spouseMotherName || ""}
onChange={(e) => handleChange("spouseMotherName", e.target.value)}
/>
) : (
<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"></SelectItem>
{potentialRelatives
.filter((m) => m.gender === "FEMALE" && m.generation === (formData.generation || 1) - 1)
.map((m) => (
<SelectItem key={m.id} value={m.id}>
<MemberNameWithStatus
name={m.fullName}
isDead={!!m.deathDate}
/> ({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>
<Label htmlFor="spouseId"></Label>
<Select value="none" onValueChange={handleSpouseAdd}>
<SelectTrigger>
<SelectValue placeholder="添加配偶..." />
@@ -429,7 +472,10 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
})
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.fullName} ({m.generation})
<MemberNameWithStatus
name={m.fullName}
isDead={!!m.deathDate}
/> ({m.generation})
</SelectItem>
))}
</SelectContent>
@@ -445,7 +491,10 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
return (
<div key={spouseId} className="flex items-center justify-between p-2 bg-muted rounded">
<span className="text-sm">
{index + 1}. {spouse.fullName} ({spouse.generation})
{index + 1}. <MemberNameWithStatus
name={spouse.fullName}
isDead={!!spouse.deathDate}
/> ({spouse.generation})
</span>
<Button
type="button"
@@ -464,7 +513,7 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</div>
<div className="space-y-2">
<Label htmlFor="childId"> (Children)</Label>
<Label htmlFor="childId"></Label>
<Select value="none" onValueChange={handleChildAdd}>
<SelectTrigger>
<SelectValue placeholder="添加子女..." />
@@ -499,7 +548,10 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
})
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.fullName} ({m.generation})
<MemberNameWithStatus
name={m.fullName}
isDead={!!m.deathDate}
/> ({m.generation})
</SelectItem>
))}
</SelectContent>
@@ -515,7 +567,10 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
return (
<div key={childId} className="flex items-center justify-between p-2 bg-muted rounded">
<span className="text-sm">
{index + 1}. {child.fullName} ({child.generation})
{index + 1}. <MemberNameWithStatus
name={child.fullName}
isDead={!!child.deathDate}
/> ({child.generation})
</span>
<Button
type="button"
@@ -537,37 +592,37 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</div>
{/* Traditional Names */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<Scroll className="h-5 w-5" />
(Traditional Names)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="courtesyName"> (Courtesy Name)</Label>
<Label htmlFor="courtesyName"></Label>
<Input
id="courtesyName"
value={formData.courtesyName || ""}
onChange={(e) => handleChange("courtesyName", e.target.value)}
placeholder="e.g. 伯虎"
placeholder="例如:伯虎"
/>
</div>
<div className="space-y-2">
<Label htmlFor="artName"> (Art Name)</Label>
<Label htmlFor="artName"></Label>
<Input
id="artName"
value={formData.artName || ""}
onChange={(e) => handleChange("artName", e.target.value)}
placeholder="e.g. 六如居士"
placeholder="例如:六如居士"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="generationName"> (Gen. Name)</Label>
<Label htmlFor="generationName"></Label>
<Input
id="generationName"
value={formData.generationName || ""}
@@ -575,7 +630,7 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
/>
</div>
<div className="space-y-2">
<Label htmlFor="posthumousName"> (Posthumous)</Label>
<Label htmlFor="posthumousName"></Label>
<Input
id="posthumousName"
value={formData.posthumousName || ""}
@@ -587,45 +642,73 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</Card>
{/* Dates & Places */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<CalendarIcon className="h-5 w-5" />
(Life & Places)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="birthDate"> (Birth)</Label>
<Input
id="birthDate"
type="date"
value={formData.birthDate || ""}
onChange={(e) => handleChange("birthDate", e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="deathDate"> (Death)</Label>
<Input
id="deathDate"
type="date"
value={formData.deathDate || ""}
onChange={(e) => handleChange("deathDate", e.target.value)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<DateInputWithLunar
id="birthDate"
label="出生日期"
value={formData.birthDate || ""}
isLunar={formData.isLunarDate || false}
onChange={(value) => handleChange("birthDate", value)}
showLunarToggle={false}
/>
<DateInputWithLunar
id="deathDate"
label="逝世日期"
value={formData.deathDate || ""}
isLunar={formData.isLunarDate || false}
onChange={(value) => handleChange("deathDate", value)}
showLunarToggle={false}
/>
</div>
{/* 统一的公历/农历选择 */}
{(formData.birthDate || formData.deathDate) && (
<div className="space-y-2 pt-2">
<Label className="text-sm font-medium"></Label>
<RadioGroup
value={formData.isLunarDate ? "lunar" : "solar"}
onValueChange={(val) => handleChange("isLunarDate", val === "lunar")}
className="flex flex-row gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="solar" id="date-type-solar" />
<Label htmlFor="date-type-solar" className="text-sm font-normal cursor-pointer">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="lunar" id="date-type-lunar" />
<Label htmlFor="date-type-lunar" className="text-sm font-normal cursor-pointer">
()
</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground">
{formData.isLunarDate
? "💡 将按农历日期计算每年的纪念日,如生日、祭日等"
: "💡 将按公历日期计算每年的纪念日"}
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="ancestralHome"> (Ancestral Home)</Label>
<Label htmlFor="ancestralHome"></Label>
<Input
id="ancestralHome"
value={formData.ancestralHome || ""}
onChange={(e) => handleChange("ancestralHome", e.target.value)}
placeholder="e.g. 福建省泉州市"
placeholder="例如:福建省泉州市"
/>
</div>
<div className="space-y-2">
<Label htmlFor="burialPlace"> (Burial Place)</Label>
<Label htmlFor="burialPlace"></Label>
<Input
id="burialPlace"
value={formData.burialPlace || ""}
@@ -636,63 +719,63 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</Card>
{/* Contact Information */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<User className="h-5 w-5" />
(Contact Information)
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone"> (Mobile Phone)</Label>
<Label htmlFor="phone"></Label>
<Input
id="phone"
type="tel"
value={formData.phone || ""}
onChange={(e) => handleChange("phone", e.target.value)}
placeholder="e.g. 13800138000"
placeholder="例如:13800138000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="telephone"> (Telephone)</Label>
<Label htmlFor="telephone"></Label>
<Input
id="telephone"
type="tel"
value={formData.telephone || ""}
onChange={(e) => handleChange("telephone", e.target.value)}
placeholder="e.g. 0592-1234567"
placeholder="例如:0592-1234567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"> (Email)</Label>
<Label htmlFor="email"></Label>
<Input
id="email"
type="email"
value={formData.email || ""}
onChange={(e) => handleChange("email", e.target.value)}
placeholder="e.g. example@email.com"
placeholder="例如:example@email.com"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address"> (Address)</Label>
<Label htmlFor="address"></Label>
<Input
id="address"
value={formData.address || ""}
onChange={(e) => handleChange("address", e.target.value)}
placeholder="e.g. 福建省厦门市思明区XX路XX号"
placeholder="例如:福建省厦门市思明区XX路XX号"
/>
</div>
</CardContent>
</Card>
{/* Biography */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<Scroll className="h-5 w-5" />
(Biography)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
@@ -706,11 +789,11 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</Card>
{/* Tags */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<Scroll className="h-5 w-5" />
(Tags)
</CardTitle>
<CardDescription>便</CardDescription>
</CardHeader>
@@ -759,11 +842,11 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</Card>
{/* Photo Gallery */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<Images className="h-5 w-5" />
(Photo Gallery)
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
@@ -776,11 +859,11 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</Card>
{/* Family Stories */}
<Card>
<Card className={founderCardClass}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<BookOpen className="h-5 w-5" />
(Family Stories)
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
@@ -796,10 +879,10 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
<div className="flex justify-end gap-4 sticky bottom-4 bg-background/90 p-4 border-t border-border backdrop-blur rounded-lg">
<Button type="button" variant="outline" onClick={onCancel}>
(Cancel)
</Button>
<Button type="submit" className="bg-primary text-primary-foreground hover:bg-primary/90">
(Save Member)
</Button>
</div>
</form>
@@ -0,0 +1,222 @@
"use client"
import { useState, useEffect } from "react"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Badge } from "@/components/ui/badge"
import { User } from "lucide-react"
import { format } from "date-fns"
import { zhCN } from "date-fns/locale"
interface MemberHistory {
id: string
memberId: string
version: number
snapshot: any
changedBy: string
changedAt: string
changeType: "CREATE" | "UPDATE" | "DELETE"
changes: Record<string, { old: any, new: any }> | null
user?: {
name?: string | null
email?: string | null
}
}
interface MemberVersionHistoryProps {
memberId: string
treeId: string
onVersionSelect?: (version: MemberHistory) => void
}
export function MemberVersionHistory({ memberId, treeId, onVersionSelect }: MemberVersionHistoryProps) {
const [history, setHistory] = useState<MemberHistory[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadHistory()
}, [memberId, treeId])
const loadHistory = async () => {
try {
setLoading(true)
const res = await fetch(`/api/trees/${treeId}/members/${memberId}/history`)
if (!res.ok) throw new Error("获取历史失败")
const data = await res.json()
setHistory(data.history || [])
} catch (error) {
console.error("加载历史失败:", error)
} finally {
setLoading(false)
}
}
const getChangeTypeLabel = (type: string) => {
const labels: Record<string, string> = {
CREATE: "创建",
UPDATE: "更新",
DELETE: "删除",
}
return labels[type] || type
}
const getChangeTypeColor = (type: string) => {
const colors: Record<string, string> = {
CREATE: "bg-green-100 text-green-700 border-green-200",
UPDATE: "bg-blue-100 text-blue-700 border-blue-200",
DELETE: "bg-red-100 text-red-700 border-red-200",
}
return colors[type] || "bg-gray-100 text-gray-700 border-gray-200"
}
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: '标签',
spouseFatherName: '岳父/公公', spouseMotherName: '岳母/婆婆',
}
const formatValue = (value: any) => {
if (value === null || value === undefined) return '-'
if (typeof value === 'boolean') return value ? '是' : '否'
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '-'
return String(value)
}
const renderVersionChanges = (changes: Record<string, { old: any, new: any }> | null, changeType: string) => {
// 如果是创建操作,不显示变更详情
if (changeType === 'CREATE') {
return (
<div className="mt-3 text-xs text-muted-foreground">
</div>
)
}
// 如果没有变更信息
if (!changes || Object.keys(changes).length === 0) {
return (
<div className="mt-3 text-xs text-muted-foreground">
</div>
)
}
// 分组显示变更
const relationFields = ['fatherId', 'motherId', 'spouseIds', 'childrenIds', 'spouseFatherName', 'spouseMotherName']
const changedRelations = Object.entries(changes).filter(([key]) => relationFields.includes(key))
const changedOthers = Object.entries(changes).filter(([key]) => !relationFields.includes(key))
return (
<div className="mt-3 space-y-4">
{/* 家族关系变更 */}
{changedRelations.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-foreground mb-2"></h4>
<div className="space-y-2 text-xs">
{changedRelations.map(([field, change]) => (
<div key={field} className="flex items-start gap-2">
<span className="text-muted-foreground min-w-[60px]">{fieldLabels[field]}:</span>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="text-red-600 line-through">{formatValue(change.old)}</span>
<span className="text-muted-foreground"></span>
<span className="text-green-600 font-medium">{formatValue(change.new)}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 其他信息变更 */}
{changedOthers.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-foreground mb-2"></h4>
<div className="space-y-2 text-xs">
{changedOthers.map(([field, change]) => (
<div key={field} className="flex items-start gap-2">
<span className="text-muted-foreground min-w-[60px]">{fieldLabels[field]}:</span>
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-red-600 line-through">{formatValue(change.old)}</span>
<span className="text-muted-foreground"></span>
<span className="text-green-600 font-medium">{formatValue(change.new)}</span>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
if (loading) {
return (
<div className="text-center py-8 text-muted-foreground">
...
</div>
)
}
if (history.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
</div>
)
}
return (
<div>
<p className="text-sm text-muted-foreground mb-4">
{history.length}
</p>
<ScrollArea className="h-[calc(100vh-300px)] pr-4">
<div className="space-y-6">
{history.map((record) => (
<div
key={record.id}
className="p-4 rounded-lg border border-border bg-card shadow-sm"
>
{/* 版本头部 */}
<div className="flex items-center justify-between mb-3 pb-3 border-b border-border">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getChangeTypeColor(record.changeType)}>
{getChangeTypeLabel(record.changeType)}
</Badge>
<span className="text-sm font-semibold">
{record.version}
</span>
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(record.changedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
</div>
</div>
{/* 操作人 */}
{record.user && (
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-3">
<User className="h-3 w-3" />
<span>{record.user.name || record.user.email || '未知用户'}</span>
</div>
)}
{/* 变更详细内容 */}
{renderVersionChanges(record.changes, record.changeType)}
</div>
))}
</div>
</ScrollArea>
</div>
)
}
+16 -34
View File
@@ -24,32 +24,9 @@ export function PhotoGallery({ photoIds = [], onChange, readonly = false }: Phot
// 加载照片
useEffect(() => {
const loadPhotos = async () => {
const loadedPhotos = await Promise.all(
photoIds.map(async (id) => {
try {
const image = await db.images.get(id)
if (image) {
const url = URL.createObjectURL(image.blob)
return { id, url }
}
} catch (error) {
console.error('加载照片失败:', error)
}
return null
})
)
setPhotos(loadedPhotos.filter(Boolean) as { id: string; url: string }[])
}
if (photoIds.length > 0) {
loadPhotos()
}
// 清理 URL
return () => {
photos.forEach(photo => URL.revokeObjectURL(photo.url))
}
// photoIds 现在直接是 URL 数组
const loadedPhotos = photoIds.map((url) => ({ id: url, url }))
setPhotos(loadedPhotos)
}, [photoIds])
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -72,16 +49,21 @@ export function PhotoGallery({ photoIds = [], onChange, readonly = false }: Phot
const compressedFile = await imageCompression(file, options)
// 保存到数据库
const imageId = uuidv4()
await db.images.add({
id: imageId,
blob: compressedFile,
mimeType: compressedFile.type,
createdAt: new Date().toISOString(),
// 上传到服务器
const formData = new FormData()
formData.append('file', compressedFile)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
newPhotoIds.push(imageId)
if (!response.ok) {
throw new Error('上传失败')
}
const { url } = await response.json()
newPhotoIds.push(url)
}
// 更新照片列表
+63
View File
@@ -0,0 +1,63 @@
import { MemberNameWithStatus } from "./member-name-with-status"
import { useFamily } from "@/context/family-context"
interface RelationshipPathDisplayProps {
path: string
className?: string
}
/**
* 显示关系路径,并为路径中的每个成员姓名添加状态标识
* 路径格式: "虞国栋 → 儿子(虞晓东) → 儿子(虞雨轩) → 女儿(虞诗语)"
*/
export function RelationshipPathDisplay({ path, className = "" }: RelationshipPathDisplayProps) {
const { treeData } = useFamily()
if (!path) return null
// 解析路径字符串
// 格式: "起点名 → 关系(名字) → 关系(名字) → ..."
const parts = path.split(' → ')
return (
<div className={`flex flex-wrap items-center gap-2 ${className}`}>
{parts.map((part, index) => {
// 第一个部分是起点名字(没有关系前缀)
if (index === 0) {
const member = Object.values(treeData.members).find(m => m.fullName === part)
return (
<span key={index} className="inline-flex items-center">
<MemberNameWithStatus
name={part}
isDead={!!member?.deathDate}
/>
</span>
)
}
// 其他部分格式: "关系(名字)"
const match = part.match(/^(.+?)\((.+?)\)$/)
if (match) {
const [, relation, name] = match
const member = Object.values(treeData.members).find(m => m.fullName === name)
return (
<span key={index} className="inline-flex items-center gap-2">
<span className="text-muted-foreground"></span>
<span className="text-sm text-muted-foreground">{relation}</span>
<span className="text-muted-foreground">(</span>
<MemberNameWithStatus
name={name}
isDead={!!member?.deathDate}
/>
<span className="text-muted-foreground">)</span>
</span>
)
}
// 如果格式不匹配,直接显示原文
return <span key={index}>{part}</span>
})}
</div>
)
}
+176 -27
View File
@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
@@ -11,6 +11,8 @@ import { format } from "date-fns"
import { useFamily } from "@/context/family-context"
import { useSession } from "next-auth/react"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
// 定义日志类型
interface ActivityLog {
@@ -21,12 +23,14 @@ interface ActivityLog {
entityName?: string | null
changes?: any
timestamp: string
treeId: string
userId: string
user?: {
name?: string | null
email?: string | null
}
tree?: {
id: string
name: string
}
}
@@ -72,6 +76,7 @@ export function ActivityLogViewer() {
const { currentTree } = useFamily()
const { data: session } = useSession()
const { showAlert, showConfirm, showPrompt } = useDialog()
const searchParams = useSearchParams()
const [logs, setLogs] = useState<ActivityLog[]>([])
const [trees, setTrees] = useState<FamilyTree[]>([])
const [loading, setLoading] = useState(false)
@@ -81,24 +86,47 @@ export function ActivityLogViewer() {
// 加载家族树列表
useEffect(() => {
fetch('/api/trees')
const controller = new AbortController()
fetch('/api/trees', { signal: controller.signal })
.then(res => res.json())
.then(data => {
if (data.trees) {
setTrees(data.trees)
}
})
.catch(err => console.error("加载家族树列表失败:", err))
.catch(err => {
if (err.name !== 'AbortError') {
console.error("加载家族树列表失败:", err)
}
})
return () => controller.abort()
}, [])
// currentTree 改变时,更新筛选
// 从 URL 参数或 currentTree 获取初始 treeId
// 只有在 trees 加载完成后才设置 filterTreeId
useEffect(() => {
if (currentTree?.id) {
setFilterTreeId(currentTree.id)
if (trees.length === 0) return // 等待 trees 加载完成
const urlTreeId = searchParams.get('treeId')
if (urlTreeId) {
// 优先使用 URL 中的 treeId
// 确保这个 treeId 在 trees 列表中存在
const treeExists = trees.some(t => t.id === urlTreeId)
if (treeExists) {
setFilterTreeId(urlTreeId)
}
} else if (currentTree?.id) {
// 如果 URL 中没有,使用 currentTree
const treeExists = trees.some(t => t.id === currentTree.id)
if (treeExists) {
setFilterTreeId(currentTree.id)
}
}
}, [currentTree?.id])
}, [searchParams, currentTree?.id, trees])
const loadLogs = async () => {
const loadLogs = useCallback(async (signal?: AbortSignal) => {
setLoading(true)
setError(null)
try {
@@ -110,22 +138,26 @@ export function ActivityLogViewer() {
url = `/api/trees/${filterTreeId}/activity-logs?limit=100`
}
const res = await fetch(url)
const res = await fetch(url, { signal })
if (!res.ok) throw new Error("获取日志失败")
const data = await res.json()
setLogs(data.logs || [])
} catch (err) {
console.error("加载日志失败:", err)
setError(err instanceof Error ? err.message : "加载失败")
if (err instanceof Error && err.name !== 'AbortError') {
console.error("加载日志失败:", err)
setError(err.message)
}
} finally {
setLoading(false)
}
}
}, [filterTreeId])
useEffect(() => {
loadLogs()
}, [filterTreeId])
const controller = new AbortController()
loadLogs(controller.signal)
return () => controller.abort()
}, [loadLogs])
// 检查当前用户是否是选中家族树的所有者
const isOwner = () => {
@@ -137,6 +169,113 @@ export function ActivityLogViewer() {
return selectedTree.ownerId === session?.user?.id || selectedTree.currentUserRole === "OWNER"
}
// 格式化变更详情,使其更易读
const formatChanges = (changes: any, action: string) => {
if (!changes || typeof changes !== 'object') return null
const fieldLabels: Record<string, string> = {
fullName: '姓名',
surname: '姓氏',
givenName: '名字',
gender: '性别',
birthDate: '出生日期',
deathDate: '去世日期',
birthPlace: '出生地',
ancestralHome: '祖籍',
generation: '世代',
generationName: '字辈',
courtesyName: '字',
artName: '号',
posthumousName: '谥号',
rank: '排行',
bio: '简介',
phone: '手机',
telephone: '电话',
email: '邮箱',
address: '地址',
photoIds: '照片',
spouseIds: '配偶',
childrenIds: '子女',
motherId: '母亲',
fatherId: '父亲',
isFounder: '始祖',
isLunarDate: '农历日期',
burialPlace: '安葬地',
tags: '标签',
}
const formatValue = (key: string, value: any) => {
if (value === null || value === undefined) return '无'
if (key === 'gender') {
return value === 'MALE' ? '男' : value === 'FEMALE' ? '女' : '未知'
}
if (key === 'tags' && Array.isArray(value)) {
return value.length > 0 ? value.join(', ') : '无'
}
if (Array.isArray(value)) {
return `${value.length}`
}
if (typeof value === 'boolean') {
return value ? '是' : '否'
}
if (typeof value === 'string' && value.length > 50) {
return value.substring(0, 50) + '...'
}
return String(value)
}
const getChangeDescription = (key: string, value: any) => {
const label = fieldLabels[key] || key
// 如果是对象且包含 old 和 new,说明是修改
if (value && typeof value === 'object' && 'old' in value && 'new' in value) {
const oldVal = formatValue(key, value.old)
const newVal = formatValue(key, value.new)
// 特殊处理数组变化
if (key === 'photoIds') {
const oldCount = Array.isArray(value.old) ? value.old.length : 0
const newCount = Array.isArray(value.new) ? value.new.length : 0
if (newCount > oldCount) {
return `添加了${label}:新增 ${newCount - oldCount}`
} else if (newCount < oldCount) {
return `删除了${label}:减少 ${oldCount - newCount}`
}
}
if (key === 'childrenIds' || key === 'spouseIds') {
const oldCount = Array.isArray(value.old) ? value.old.length : 0
const newCount = Array.isArray(value.new) ? value.new.length : 0
if (newCount > oldCount) {
return `添加了${label}:新增 ${newCount - oldCount}`
} else if (newCount < oldCount) {
return `删除了${label}:减少 ${oldCount - newCount}`
}
}
return `修改了${label}:从 "${oldVal}" → "${newVal}"`
}
return null
}
const entries = Object.entries(changes)
.filter(([key]) => fieldLabels[key]) // 只显示有标签的字段
.map(([key, value]: [string, any]) => {
const description = getChangeDescription(key, value)
if (!description) return null
return (
<div key={key} className="text-xs py-1.5 text-muted-foreground">
{description}
</div>
)
})
.filter(Boolean) // 移除 null 值
return entries.length > 0 ? entries : null
}
const handleClearLogs = async () => {
if (filterTreeId === "all") {
await showAlert("请先选择一个具体的家族树再清空日志", "提示")
@@ -210,7 +349,7 @@ export function ActivityLogViewer() {
<Button
variant="outline"
size="icon"
onClick={loadLogs}
onClick={() => loadLogs()}
disabled={loading || clearing}
className="h-9 w-9"
title="刷新日志"
@@ -291,9 +430,18 @@ export function ActivityLogViewer() {
</Badge>
)}
{log.entityName && (
<span className="text-sm font-medium truncate">
{log.entityName}
</span>
log.entityId && log.entityType === 'MEMBER' ? (
<Link
href={`/members/${log.entityId}?treeId=${log.treeId}`}
className="text-sm font-medium truncate hover:text-primary hover:underline transition-colors"
>
{log.entityName}
</Link>
) : (
<span className="text-sm font-medium truncate">
{log.entityName}
</span>
)
)}
</div>
<div className="flex items-center justify-between">
@@ -304,15 +452,16 @@ export function ActivityLogViewer() {
{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>
{log.action === 'UPDATE' && (
<div className="mt-2 pl-2 border-l-2 border-muted">
{log.changes && formatChanges(log.changes, log.action) ? (
formatChanges(log.changes, log.action)
) : (
<div className="text-xs py-1.5 text-muted-foreground italic">
</div>
)}
</div>
)}
</div>
</div>
+13 -2
View File
@@ -10,6 +10,7 @@ import { useDialog } from "@/components/ui/alert-dialog-custom"
import { requestNotificationPermission, checkUpcomingAnniversaries } from "@/lib/notifications"
import { useFamily } from "@/context/family-context"
import { Alert, AlertDescription } from "@/components/ui/alert"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
export function NotificationSettings() {
const { showAlert } = useDialog()
@@ -162,7 +163,12 @@ export function NotificationSettings() {
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="font-medium">{member.fullName}</p>
<p className="font-medium">
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
/>
</p>
<p className="text-sm text-muted-foreground">
{new Date(member.birthDate).toLocaleDateString('zh-CN', {
month: 'long',
@@ -193,7 +199,12 @@ export function NotificationSettings() {
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="font-medium">{member.fullName}</p>
<p className="font-medium">
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
/>
</p>
<p className="text-sm text-muted-foreground">
{new Date(member.deathDate!).toLocaleDateString('zh-CN', {
month: 'long',
+118 -54
View File
@@ -1,7 +1,7 @@
"use client"
import Link from "next/link"
import { BookOpen, Search, Settings, LogOut, User, ChevronDown, Plus, Trash2, Network, LayoutGrid, Calculator, HelpCircle } from "lucide-react"
import { BookOpen, Search, Settings, LogOut, User, ChevronDown, Plus, Trash2, Network, LayoutGrid, Calculator, HelpCircle, Calendar } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
@@ -19,6 +19,9 @@ import { useRouter, usePathname, useSearchParams } from "next/navigation"
import { useSession, signOut } from "next-auth/react"
import { useDialog } from "@/components/ui/alert-dialog-custom"
import { permissions } from "@/lib/permissions"
import { solar2lunar } from "@/lib/lunar-calendar"
import { CalendarDialog } from "@/components/calendar-dialog"
import { MemberNameWithStatus } from "./member-name-with-status"
interface FamilyTree {
id: string
@@ -29,7 +32,7 @@ interface FamilyTree {
}
export function SiteHeader() {
const { searchMembers, searchResults } = useFamily()
const { searchMembers, searchResults, currentTree: familyCurrentTree } = useFamily()
const { data: session, status } = useSession()
const { showAlert, showConfirm, showPrompt } = useDialog()
const pathname = usePathname()
@@ -37,15 +40,33 @@ export function SiteHeader() {
const [searchQuery, setSearchQuery] = useState("")
const [showResults, setShowResults] = useState(false)
const [familyTrees, setFamilyTrees] = useState<FamilyTree[]>([])
const [currentTree, setCurrentTree] = useState<FamilyTree | null>(null)
const [deletingTreeId, setDeletingTreeId] = useState<string | null>(null)
const [treeDropdownOpen, setTreeDropdownOpen] = useState(false)
const [currentDate, setCurrentDate] = useState(new Date())
const [calendarDialogOpen, setCalendarDialogOpen] = useState(false)
const searchRef = useRef<HTMLDivElement>(null)
const router = useRouter()
// 使用 family context 的 currentTree
const currentTree = familyCurrentTree
// 获取当前视图模式
const currentViewMode = searchParams.get('view') || 'traditional'
const isTreePage = pathname === '/tree'
// 更新日期
useEffect(() => {
const timer = setInterval(() => {
setCurrentDate(new Date())
}, 60000) // 每分钟更新一次
return () => clearInterval(timer)
}, [])
// 获取农历信息
const lunarInfo = solar2lunar(currentDate)
const solarDateStr = `${currentDate.getFullYear()}${currentDate.getMonth() + 1}${currentDate.getDate()}`
const lunarDateStr = lunarInfo ? lunarInfo.toString() : '农历信息不可用'
// 生成带有 treeId 的 URL
const getUrlWithTreeId = (path: string) => {
if (currentTree?.id) {
@@ -159,19 +180,6 @@ export function SiteHeader() {
.then(data => {
if (data.trees) {
setFamilyTrees(data.trees)
// 如果 URL 中有 treeId,使用该树,否则使用第一个
const urlParams = new URLSearchParams(window.location.search)
const treeId = urlParams.get('treeId')
if (treeId) {
const tree = data.trees.find((t: FamilyTree) => t.id === treeId)
if (tree) {
setCurrentTree(tree)
} else if (data.trees.length > 0) {
setCurrentTree(data.trees[0])
}
} else if (data.trees.length > 0) {
setCurrentTree(data.trees[0])
}
}
})
.catch(err => {
@@ -216,42 +224,95 @@ export function SiteHeader() {
}
const handleSelectMember = (memberId: string) => {
router.push(`/members/${memberId}`)
const params = new URLSearchParams(window.location.search)
router.push(`/members/${memberId}?${params.toString()}`)
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">
<div className="mr-8 flex items-center gap-4">
<Link href="/" className="flex items-center gap-2">
<div className="mr-8 flex items-center gap-2">
<Link href={getUrlWithTreeId("/")} className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded bg-primary text-primary-foreground">
<BookOpen className="h-5 w-5" />
</div>
<span className="text-xl font-serif font-bold tracking-tight"></span>
</Link>
{/* 家族树选择器 */}
{session && familyTrees.length > 0 && (
<DropdownMenu>
</div>
<nav className="hidden md:flex items-center gap-3 font-serif" onClick={() => setTreeDropdownOpen(false)}>
<Link
href={getUrlWithTreeId("/")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(1% 0%, 99% 1%, 100% 98%, 2% 99%, 0% 2%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(0.5% 1%, 98.5% 0.5%, 99.5% 99%, 1.5% 98.5%, 1% 1.5%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
<Link
href={getUrlWithTreeId("/tree")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(0% 1%, 99% 0%, 100% 99%, 1% 100%, 1% 1%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(1% 0.5%, 98% 1%, 99% 98.5%, 2% 99.5%, 0.5% 2%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
<Link
href={getUrlWithTreeId("/members")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(1% 1%, 98% 0%, 99% 99%, 0% 98%, 2% 2%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(0.5% 0%, 99% 1.5%, 98.5% 99%, 1% 98%, 1.5% 1%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
<Link
href={getUrlWithTreeId("/timeline")}
className="relative w-20 py-2.5 flex items-center justify-center group"
style={{ filter: 'url(#rough-edge)' }}
>
<span className="relative z-10 text-amber-50 font-black text-base tracking-wider drop-shadow-sm"></span>
<div className="absolute inset-0 bg-gradient-to-br from-red-700 via-red-600 to-red-700 rounded-sm transition-all group-hover:from-red-800 group-hover:via-red-700 group-hover:to-red-800 shadow-md" style={{ clipPath: 'polygon(0% 0%, 100% 1%, 99% 100%, 1% 99%, 0.5% 1.5%)' }}></div>
<div className="absolute inset-0 border-2 border-red-900/40 rounded-sm" style={{ clipPath: 'polygon(1.5% 1%, 98.5% 0%, 99.5% 98%, 0.5% 99.5%, 1% 2%)' }}></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-red-500/20 via-transparent to-transparent rounded-sm"></div>
</Link>
</nav>
{/* 家族树选择器 */}
{session && familyTrees.length > 0 && (
<div className="ml-4 flex-shrink-0">
<DropdownMenu open={treeDropdownOpen} onOpenChange={setTreeDropdownOpen} modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="gap-2 min-w-[200px]">
<Button variant="outline" className="gap-2 w-[180px] font-serif">
<span className="flex-1 truncate text-left">{currentTree?.name || '选择家族树'}</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-[300px]">
<DropdownMenuContent
align="end"
className="w-[300px] font-serif"
sideOffset={5}
avoidCollisions={false}
sticky="always"
>
<DropdownMenuLabel className="font-light"></DropdownMenuLabel>
<DropdownMenuSeparator />
{familyTrees.map((tree) => (
<DropdownMenuItem
key={tree.id}
onClick={() => {
setCurrentTree(tree)
// 保持当前路径,只更新 treeId 参数
const currentPath = window.location.pathname
router.push(`${currentPath}?treeId=${tree.id}`)
onClick={(e) => {
e.preventDefault()
setTreeDropdownOpen(false)
// 切换家族后跳转到总览页面,family-context 会自动加载新的树
router.push(`/?treeId=${tree.id}`)
}}
className="cursor-pointer flex items-center justify-between group py-3"
>
@@ -287,26 +348,8 @@ export function SiteHeader() {
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<nav className="hidden md:flex items-center gap-6 text-sm font-medium text-muted-foreground">
<Link
href={getUrlWithTreeId("/")}
className="transition-colors hover:text-foreground data-[active=true]:text-foreground data-[active=true]:font-semibold"
>
(Overview)
</Link>
<Link href={getUrlWithTreeId("/tree")} className="transition-colors hover:text-foreground">
(Tree)
</Link>
<Link href={getUrlWithTreeId("/members")} className="transition-colors hover:text-foreground">
(Members)
</Link>
<Link href={getUrlWithTreeId("/timeline")} className="transition-colors hover:text-foreground">
(Timeline)
</Link>
</nav>
</div>
)}
<div className="ml-auto flex items-center gap-4">
<div className="relative hidden sm:block" ref={searchRef}>
@@ -331,7 +374,11 @@ export function SiteHeader() {
<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>
<MemberNameWithStatus
name={member.fullName}
isDead={!!member.deathDate}
className="font-medium"
/>
<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>
@@ -356,7 +403,20 @@ export function SiteHeader() {
)}
</div>
<Link href="/relationship">
{/* 日期显示 */}
<button
onClick={() => setCalendarDialogOpen(true)}
className="hidden lg:flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gradient-to-br from-red-50 to-amber-50 border border-red-200/50 shadow-sm hover:shadow-md hover:from-red-100 hover:to-amber-100 transition-all cursor-pointer"
title="点击查询公历农历"
>
<Calendar className="h-4 w-4 text-red-600" />
<div className="flex flex-col text-xs leading-tight">
<span className="font-medium text-gray-900">{solarDateStr}</span>
<span className="text-red-700 font-serif">{lunarDateStr}</span>
</div>
</button>
<Link href={getUrlWithTreeId("/relationship")}>
<Button variant="ghost" size="icon" className="text-muted-foreground">
<Calculator className="h-5 w-5" />
<span className="sr-only">Relationship Calculator</span>
@@ -391,7 +451,7 @@ export function SiteHeader() {
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuContent className="w-56 font-serif" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{session.user?.name || "用户"}</p>
@@ -402,7 +462,7 @@ export function SiteHeader() {
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/settings" className="cursor-pointer">
<Link href={getUrlWithTreeId("/settings")} className="cursor-pointer">
<Settings className="mr-2 h-4 w-4" />
<span></span>
</Link>
@@ -424,5 +484,9 @@ export function SiteHeader() {
</div>
</div>
</header>
{/* 日历查询对话框 - 放在 header 外部避免定位冲突 */}
<CalendarDialog open={calendarDialogOpen} onOpenChange={setCalendarDialogOpen} />
</>
)
}
+12 -41
View File
@@ -35,7 +35,7 @@ interface ChartNode {
deathYear: string
birthPlace: string
gender: string
avatarImageId: string | null | undefined
avatarUrl: string | null | undefined
spouseName: string
spouseId: string | null
hasChildren: boolean
@@ -133,7 +133,7 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
deathYear: member.deathDate ? new Date(member.deathDate).getFullYear().toString() : '',
birthPlace: member.birthPlace || '',
gender: member.gender === 'MALE' ? '男' : '女',
avatarImageId: member.avatarImageId,
avatarUrl: member.avatarUrl,
spouseName: spouses.length > 0 ? spouses.map(s => s.fullName).join(', ') : '',
spouseId: spouses.length > 0 ? spouses[0].id : null,
hasChildren: Object.values(members).some(m => m.fatherId === member.id || m.motherId === member.id),
@@ -151,34 +151,17 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
return chartNodes
}
// 加载所有成员的头像
// 构建头像缓存(URL 直接使用)
useEffect(() => {
const loadAvatars = async () => {
const cache = new Map<string, string>()
for (const member of Object.values(members)) {
if (member.avatarImageId) {
try {
const image = await db.images.get(member.avatarImageId)
if (image) {
const url = URL.createObjectURL(image.blob)
cache.set(member.avatarImageId, url)
}
} catch (error) {
console.error(`Failed to load avatar for ${member.fullName}:`, error)
}
}
const cache = new Map<string, string>()
for (const member of Object.values(members)) {
if (member.avatarUrl) {
cache.set(member.avatarUrl, member.avatarUrl)
}
setAvatarCache(cache)
}
loadAvatars()
// 清理函数
return () => {
avatarCache.forEach(url => URL.revokeObjectURL(url))
}
setAvatarCache(cache)
}, [members])
useEffect(() => {
@@ -217,7 +200,7 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
const bgColor = node.gender === '男' ? '#dbeafe' : '#fce7f3'
// 获取头像URL
const avatarUrl = node.avatarImageId ? avatarCache.get(node.avatarImageId) : null
const avatarUrl = node.avatarUrl ? avatarCache.get(node.avatarUrl) : null
// 检查是否被选中
const isSelected = selectedMembers.includes(node.id)
@@ -260,8 +243,9 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
}
</div>
<div style="flex: 1; min-width: 0;">
<div style="font-size: 13px; font-weight: 600; color: #1f2937; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px;">
<div style="font-size: 13px; font-weight: 600; color: ${node.deathYear ? '#9ca3af' : '#1f2937'}; margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: flex; align-items: center; gap: 4px; position: relative; padding-right: 12px;">
<span>${node.name}</span>
<span style="position: absolute; right: 2px; top: 4px; width: 8px; height: 8px; border-radius: 50%; background-color: ${node.deathYear ? '#9ca3af' : '#22c55e'};"></span>
${isSelected ? `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>` : ''}
</div>
<div style="font-size: 9px; color: #6b7280; padding: 1px 4px; background: #f3f4f6; border-radius: 3px; display: inline-block;">
@@ -304,19 +288,6 @@ const D3OrgChartFlowComponent = forwardRef<D3OrgChartRef, D3OrgChartFlowProps>(
配偶: ${node.spouseName}
</div>
` : ''}
${node.deathYear ? `
<div style="
position: absolute;
bottom: 4px;
left: 4px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #737373;
border: 2px solid white;
"></div>
` : ''}
</div>
`
})
+8 -6
View File
@@ -4,6 +4,7 @@ import type { FamilyMember } from "@/types/family"
import { cn } from "@/lib/utils"
import { useAvatarCache } from "@/hooks/use-avatar-cache"
import { CircleUser, CircleUserRound, Heart } from "lucide-react"
import { MemberNameWithStatus } from "@/components/member-name-with-status"
interface FamilyNodeProps {
member: FamilyMember
@@ -42,7 +43,7 @@ function calculateAge(birthDate?: string, deathDate?: string): number | null {
// 单个人员卡片组件
function PersonCard({ member, isRoot, isSpouse, onSelect, isHighlighted, relationMode, isSelected }: { member: FamilyMember; isRoot?: boolean; isSpouse?: boolean; onSelect?: (member: FamilyMember) => void; isHighlighted?: boolean; relationMode?: boolean; isSelected?: boolean }) {
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarImageId)
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarUrl)
const birthYear = formatYear(member.birthDate)
const deathYear = formatYear(member.deathDate)
@@ -98,7 +99,12 @@ function PersonCard({ member, isRoot, isSpouse, onSelect, isHighlighted, relatio
</div>
<div className="text-center w-full">
<div className="font-serif font-bold text-foreground leading-tight">{member.surname} {member.givenName}</div>
<div className="font-serif font-bold text-foreground leading-tight flex items-center justify-center gap-1">
<MemberNameWithStatus
name={`${member.surname}${member.givenName}`}
isDead={!!member.deathDate}
/>
</div>
{member.courtesyName && (
<div className="text-[10px] text-muted-foreground mt-0.5"> {member.courtesyName}</div>
)}
@@ -118,10 +124,6 @@ function PersonCard({ member, isRoot, isSpouse, onSelect, isHighlighted, relatio
</div>
)}
</div>
{member.deathDate && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-neutral-400 ring-2 ring-background" />
)}
</div>
)
}
+2 -1
View File
@@ -43,7 +43,8 @@ export function TreeLayout({
if (relationMode && onMemberClick) {
onMemberClick(member.id)
} else {
router.push(`/members/${member.id}`)
const params = new URLSearchParams(window.location.search)
router.push(`/members/${member.id}?${params.toString()}`)
}
}, [relationMode, onMemberClick, router])
+5 -24
View File
@@ -1,41 +1,22 @@
"use client"
import { useState, useEffect } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { db } from "@/lib/db"
import { CircleUser, CircleUserRound } from "lucide-react"
import type { Gender } from "@/types/family"
interface AvatarDisplayProps {
imageId?: string
fallbackUrl?: string
imageUrl?: string // 改为 URL
fallbackText?: string
gender?: Gender
className?: string
}
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, gender, className }: AvatarDisplayProps) {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined)
useEffect(() => {
if (imageId) {
db.images.get(imageId).then((image) => {
if (image) {
const url = URL.createObjectURL(image.blob)
setBlobUrl(url)
return () => URL.revokeObjectURL(url)
}
})
} else {
setBlobUrl(undefined)
}
}, [imageId])
// 如果有头像,显示头像
if (blobUrl) {
export function AvatarDisplay({ imageUrl, fallbackText, gender, className }: AvatarDisplayProps) {
// 如果有头像 URL,显示头像
if (imageUrl) {
return (
<Avatar className={className}>
<AvatarImage src={blobUrl} />
<AvatarImage src={imageUrl} />
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
</Avatar>
)
+1 -1
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-serif font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
+2 -2
View File
@@ -32,7 +32,7 @@ function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-title"
className={cn('leading-none font-semibold', className)}
className={cn('font-serif leading-none font-semibold', className)}
{...props}
/>
)
@@ -42,7 +42,7 @@ function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="card-description"
className={cn('text-muted-foreground text-sm', className)}
className={cn('font-serif text-muted-foreground text-sm', className)}
{...props}
/>
)
+124
View File
@@ -0,0 +1,124 @@
"use client"
import { useState, useEffect } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { solar2lunar, parseDate } from "@/lib/lunar-calendar"
import { Calendar } from "lucide-react"
interface DateInputWithLunarProps {
label: string
value?: string
isLunar?: boolean
onChange: (value: string) => void
onLunarChange?: (isLunar: boolean) => void
id?: string
showLunarToggle?: boolean // 是否显示公历/农历切换
}
export function DateInputWithLunar({
label,
value,
isLunar = false,
onChange,
onLunarChange,
id,
showLunarToggle = true,
}: DateInputWithLunarProps) {
const [lunarInfo, setLunarInfo] = useState<string>("")
// 当日期改变时,更新农历信息
useEffect(() => {
if (!value) {
setLunarInfo("")
return
}
const date = parseDate(value)
if (!date) {
setLunarInfo("")
return
}
try {
const lunar = solar2lunar(date)
if (lunar) {
setLunarInfo(lunar.toString())
} else {
const year = date.getFullYear()
setLunarInfo(`⚠️ 农历转换仅支持1900-2100年 (当前: ${year}年)`)
}
} catch (error) {
console.error("日期转换错误:", error)
setLunarInfo("")
}
}, [value])
return (
<div className="space-y-3">
<Label htmlFor={id} className="text-base font-medium">
{label}
</Label>
{/* 公历日期输入 */}
<div className="relative">
<Input
id={id}
type="date"
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="pr-10"
/>
<Calendar className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
</div>
{/* 农历对照显示 */}
{lunarInfo && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex items-start gap-2">
<Calendar className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 space-y-1">
<div className="text-sm font-medium text-amber-900">
</div>
<div className="text-sm text-amber-800">
{lunarInfo}
</div>
</div>
</div>
</div>
)}
{/* 纪念日类型选择 */}
{showLunarToggle && value && lunarInfo && !lunarInfo.includes('⚠️') && onLunarChange && (
<div className="space-y-2">
<Label className="text-sm font-medium"></Label>
<RadioGroup
value={isLunar ? "lunar" : "solar"}
onValueChange={(val) => onLunarChange(val === "lunar")}
className="flex flex-row gap-6"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="solar" id={`${id}-solar`} />
<Label htmlFor={`${id}-solar`} className="text-sm font-normal cursor-pointer">
()
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="lunar" id={`${id}-lunar`} />
<Label htmlFor={`${id}-lunar`} className="text-sm font-normal cursor-pointer">
()
</Label>
</div>
</RadioGroup>
<p className="text-xs text-muted-foreground">
{isLunar
? "💡 将按农历日期计算每年的纪念日,如生日、祭日等"
: "💡 将按公历日期计算每年的纪念日"}
</p>
</div>
)}
</div>
)
}
+22 -34
View File
@@ -12,8 +12,8 @@ import "react-image-crop/dist/ReactCrop.css"
import imageCompression from "browser-image-compression"
interface ImageUploadProps {
value?: string // The image ID
onChange: (imageId: string | undefined) => void
value?: string // The image URL
onChange: (imageUrl: string | undefined) => void
className?: string
}
@@ -27,31 +27,12 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
const imgRef = useRef<HTMLImageElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Load preview if value (imageId) exists
// Load preview if value (URL) exists
useEffect(() => {
let objectUrl: string | undefined
const loadPreview = async () => {
if (!value) {
setPreviewUrl(undefined)
return
}
try {
const image = await db.images.get(value)
if (image) {
objectUrl = URL.createObjectURL(image.blob)
setPreviewUrl(objectUrl)
}
} catch (error) {
console.error("Failed to load image:", error)
}
}
loadPreview()
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl)
if (value) {
setPreviewUrl(value)
} else {
setPreviewUrl(undefined)
}
}, [value])
@@ -147,20 +128,27 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
imageBlob = await response.blob()
}
const imageId = uuidv4()
await db.images.add({
id: imageId,
blob: imageBlob,
mimeType: imageBlob.type,
createdAt: new Date().toISOString(),
// 上传到服务器
const formData = new FormData()
formData.append('file', imageBlob, 'avatar.jpg')
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
onChange(imageId)
if (!response.ok) {
const error = await response.json()
throw new Error(error.error || '上传失败')
}
const { url } = await response.json()
onChange(url)
setShowCropDialog(false)
setImageToCrop(null)
} catch (error) {
console.error("Failed to save image:", error)
alert("图片保存失败")
alert("图片保存失败: " + (error instanceof Error ? error.message : '未知错误'))
} finally {
setIsLoading(false)
}
+1 -1
View File
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
type={type}
data-slot="input"
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'font-serif file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className,
+1 -1
View File
@@ -13,7 +13,7 @@ function Label({
<LabelPrimitive.Root
data-slot="label"
className={cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
'font-serif flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
className,
)}
{...props}