0.0.0.4
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
import * as echarts from "echarts"
|
||||
import type { FamilyMember } from "@/types/family"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
|
||||
interface StatisticsChartsProps {
|
||||
members: FamilyMember[]
|
||||
}
|
||||
|
||||
export function StatisticsCharts({ members }: StatisticsChartsProps) {
|
||||
const genderChartRef = useRef<HTMLDivElement>(null)
|
||||
const generationChartRef = useRef<HTMLDivElement>(null)
|
||||
const ageDistributionRef = useRef<HTMLDivElement>(null)
|
||||
const birthYearChartRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!genderChartRef.current || members.length === 0) return
|
||||
|
||||
// 性别分布饼图
|
||||
const genderChart = echarts.init(genderChartRef.current)
|
||||
const genderData = {
|
||||
male: members.filter(m => m.gender === 'male').length,
|
||||
female: members.filter(m => m.gender === 'female').length,
|
||||
other: members.filter(m => m.gender === 'other').length,
|
||||
}
|
||||
|
||||
genderChart.setOption({
|
||||
title: {
|
||||
text: '性别分布',
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} 人 ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
bottom: 10,
|
||||
left: 'center'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['40%', '70%'],
|
||||
center: ['50%', '50%'],
|
||||
avoidLabelOverlap: false,
|
||||
itemStyle: {
|
||||
borderRadius: 10,
|
||||
borderColor: '#fff',
|
||||
borderWidth: 2
|
||||
},
|
||||
label: {
|
||||
show: false
|
||||
},
|
||||
emphasis: {
|
||||
label: {
|
||||
show: true,
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold'
|
||||
}
|
||||
},
|
||||
data: [
|
||||
{ value: genderData.male, name: '男性', itemStyle: { color: '#3b82f6' } },
|
||||
{ value: genderData.female, name: '女性', itemStyle: { color: '#ec4899' } },
|
||||
...(genderData.other > 0 ? [{ value: genderData.other, name: '其他', itemStyle: { color: '#8b5cf6' } }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return () => {
|
||||
genderChart.dispose()
|
||||
}
|
||||
}, [members])
|
||||
|
||||
useEffect(() => {
|
||||
if (!generationChartRef.current || members.length === 0) return
|
||||
|
||||
// 世代分布柱状图
|
||||
const generationChart = echarts.init(generationChartRef.current)
|
||||
const generationData = new Map<number, number>()
|
||||
|
||||
members.forEach(m => {
|
||||
const gen = m.generation || 0
|
||||
generationData.set(gen, (generationData.get(gen) || 0) + 1)
|
||||
})
|
||||
|
||||
const sortedGenerations = Array.from(generationData.entries()).sort((a, b) => a[0] - b[0])
|
||||
|
||||
generationChart.setOption({
|
||||
title: {
|
||||
text: '世代分布',
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
},
|
||||
formatter: '{b}世: {c} 人'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: sortedGenerations.map(([gen]) => `第${gen}世`),
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '人数'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: sortedGenerations.map(([, count]) => count),
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#83bff6' },
|
||||
{ offset: 0.5, color: '#188df0' },
|
||||
{ offset: 1, color: '#188df0' }
|
||||
])
|
||||
},
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#2378f7' },
|
||||
{ offset: 0.7, color: '#2378f7' },
|
||||
{ offset: 1, color: '#83bff6' }
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return () => {
|
||||
generationChart.dispose()
|
||||
}
|
||||
}, [members])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ageDistributionRef.current || members.length === 0) return
|
||||
|
||||
// 年龄分布图
|
||||
const ageChart = echarts.init(ageDistributionRef.current)
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const ageGroups = {
|
||||
'0-18': 0,
|
||||
'19-35': 0,
|
||||
'36-50': 0,
|
||||
'51-65': 0,
|
||||
'66+': 0,
|
||||
'已故': 0
|
||||
}
|
||||
|
||||
members.forEach(m => {
|
||||
if (m.deathDate) {
|
||||
ageGroups['已故']++
|
||||
} else if (m.birthDate) {
|
||||
const age = currentYear - new Date(m.birthDate).getFullYear()
|
||||
if (age <= 18) ageGroups['0-18']++
|
||||
else if (age <= 35) ageGroups['19-35']++
|
||||
else if (age <= 50) ageGroups['36-50']++
|
||||
else if (age <= 65) ageGroups['51-65']++
|
||||
else ageGroups['66+']++
|
||||
}
|
||||
})
|
||||
|
||||
ageChart.setOption({
|
||||
title: {
|
||||
text: '年龄分布',
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: '{b}: {c} 人 ({d}%)'
|
||||
},
|
||||
legend: {
|
||||
bottom: 10,
|
||||
left: 'center'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: '60%',
|
||||
center: ['50%', '50%'],
|
||||
data: Object.entries(ageGroups)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([name, value]) => ({ name, value })),
|
||||
emphasis: {
|
||||
itemStyle: {
|
||||
shadowBlur: 10,
|
||||
shadowOffsetX: 0,
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return () => {
|
||||
ageChart.dispose()
|
||||
}
|
||||
}, [members])
|
||||
|
||||
useEffect(() => {
|
||||
if (!birthYearChartRef.current || members.length === 0) return
|
||||
|
||||
// 出生年份趋势图
|
||||
const birthYearChart = echarts.init(birthYearChartRef.current)
|
||||
const birthYearData = new Map<number, number>()
|
||||
|
||||
members.forEach(m => {
|
||||
if (m.birthDate) {
|
||||
const year = new Date(m.birthDate).getFullYear()
|
||||
if (year >= 1900) { // 只统计1900年以后的
|
||||
const decade = Math.floor(year / 10) * 10
|
||||
birthYearData.set(decade, (birthYearData.get(decade) || 0) + 1)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sortedYears = Array.from(birthYearData.entries()).sort((a, b) => a[0] - b[0])
|
||||
|
||||
birthYearChart.setOption({
|
||||
title: {
|
||||
text: '出生年代分布',
|
||||
left: 'center',
|
||||
top: 10,
|
||||
textStyle: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'normal'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: '{b}年代: {c} 人'
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '15%',
|
||||
containLabel: true
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: sortedYears.map(([year]) => `${year}s`),
|
||||
axisLabel: {
|
||||
rotate: 45
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '人数'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: sortedYears.map(([, count]) => count),
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(99, 102, 241, 0.5)' },
|
||||
{ offset: 1, color: 'rgba(99, 102, 241, 0.1)' }
|
||||
])
|
||||
},
|
||||
lineStyle: {
|
||||
color: '#6366f1',
|
||||
width: 2
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#6366f1'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
return () => {
|
||||
birthYearChart.dispose()
|
||||
}
|
||||
}, [members])
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>性别分布</CardTitle>
|
||||
<CardDescription>家族成员性别比例</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={genderChartRef} className="h-[300px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>世代分布</CardTitle>
|
||||
<CardDescription>各世代人数统计</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={generationChartRef} className="h-[300px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>年龄分布</CardTitle>
|
||||
<CardDescription>按年龄段划分的人数</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={ageDistributionRef} className="h-[300px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>出生年代分布</CardTitle>
|
||||
<CardDescription>各年代出生人数趋势</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div ref={birthYearChartRef} className="h-[300px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,9 +9,11 @@ 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 { CalendarIcon, User, Scroll, Users } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { CalendarIcon, User, Scroll, Users, Images, BookOpen } from "lucide-react"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { ImageUpload } from "@/components/ui/image-upload"
|
||||
import { PhotoGallery } from "./photo-gallery"
|
||||
import { StoryManager } from "./story-manager"
|
||||
|
||||
interface MemberFormProps {
|
||||
initialData?: Partial<FamilyMember>
|
||||
@@ -110,6 +112,42 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Circular Reference Detection (循环引用检测)
|
||||
if (formData.fatherId || formData.motherId) {
|
||||
const visited = new Set<string>()
|
||||
const checkCircular = (memberId: string): boolean => {
|
||||
if (memberId === formData.id) return true
|
||||
if (visited.has(memberId)) return false
|
||||
visited.add(memberId)
|
||||
|
||||
const member = existingMembers.find((m) => m.id === memberId)
|
||||
if (!member) return false
|
||||
|
||||
if (member.fatherId && checkCircular(member.fatherId)) return true
|
||||
if (member.motherId && checkCircular(member.motherId)) return true
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
if (formData.fatherId && checkCircular(formData.fatherId)) {
|
||||
newErrors.push("检测到循环引用:不能将自己的后代设置为父亲")
|
||||
}
|
||||
if (formData.motherId && checkCircular(formData.motherId)) {
|
||||
newErrors.push("检测到循环引用:不能将自己的后代设置为母亲")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Self-Reference Detection (自我引用检测)
|
||||
if (formData.fatherId === formData.id) {
|
||||
newErrors.push("不能将自己设置为自己的父亲")
|
||||
}
|
||||
if (formData.motherId === formData.id) {
|
||||
newErrors.push("不能将自己设置为自己的母亲")
|
||||
}
|
||||
if (formData.spouseIds?.includes(formData.id || "")) {
|
||||
newErrors.push("不能将自己设置为自己的配偶")
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return newErrors.length === 0
|
||||
}
|
||||
@@ -297,6 +335,58 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Card>
|
||||
<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>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone || ""}
|
||||
onChange={(e) => handleChange("phone", e.target.value)}
|
||||
placeholder="e.g. 13800138000"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="telephone">固定电话 (Telephone)</Label>
|
||||
<Input
|
||||
id="telephone"
|
||||
type="tel"
|
||||
value={formData.telephone || ""}
|
||||
onChange={(e) => handleChange("telephone", e.target.value)}
|
||||
placeholder="e.g. 0592-1234567"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">邮箱 (Email)</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email || ""}
|
||||
onChange={(e) => handleChange("email", e.target.value)}
|
||||
placeholder="e.g. example@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label htmlFor="address">现居地址 (Address)</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address || ""}
|
||||
onChange={(e) => handleChange("address", e.target.value)}
|
||||
placeholder="e.g. 福建省厦门市思明区XX路XX号"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Biography */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -315,6 +405,40 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Photo Gallery */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<Images className="h-5 w-5" />
|
||||
照片墙 (Photo Gallery)
|
||||
</CardTitle>
|
||||
<CardDescription>上传多张照片记录珍贵时刻</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PhotoGallery
|
||||
photoIds={formData.photoIds || []}
|
||||
onChange={(photoIds) => handleChange("photoIds", photoIds)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Family Stories */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-serif">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
家族故事 (Family Stories)
|
||||
</CardTitle>
|
||||
<CardDescription>记录与此成员相关的家族故事</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StoryManager
|
||||
stories={formData.stories || []}
|
||||
onChange={(stories) => handleChange("stories", stories)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Relationships */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { db } from "@/lib/db"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { X, Plus, ZoomIn } from "lucide-react"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import imageCompression from "browser-image-compression"
|
||||
|
||||
interface PhotoGalleryProps {
|
||||
photoIds?: string[]
|
||||
onChange: (photoIds: string[]) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export function PhotoGallery({ photoIds = [], onChange, readonly = false }: PhotoGalleryProps) {
|
||||
const [photos, setPhotos] = useState<{ id: string; url: string }[]>([])
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
// 加载照片
|
||||
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])
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const newPhotoIds: string[] = []
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
if (!file.type.startsWith('image/')) continue
|
||||
|
||||
// 压缩图片
|
||||
const options = {
|
||||
maxSizeMB: 2,
|
||||
maxWidthOrHeight: 1920,
|
||||
useWebWorker: true,
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, options)
|
||||
|
||||
// 保存到数据库
|
||||
const imageId = uuidv4()
|
||||
await db.images.add({
|
||||
id: imageId,
|
||||
blob: compressedFile,
|
||||
mimeType: compressedFile.type,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
newPhotoIds.push(imageId)
|
||||
}
|
||||
|
||||
// 更新照片列表
|
||||
onChange([...photoIds, ...newPhotoIds])
|
||||
} catch (error) {
|
||||
console.error('上传照片失败:', error)
|
||||
alert('上传照片失败')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = (photoId: string) => {
|
||||
if (confirm('确定要删除这张照片吗?')) {
|
||||
onChange(photoIds.filter(id => id !== photoId))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">照片墙 ({photos.length})</h3>
|
||||
{!readonly && (
|
||||
<label>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isUploading}
|
||||
asChild
|
||||
>
|
||||
<span>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isUploading ? '上传中...' : '添加照片'}
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{photos.length > 0 ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => (
|
||||
<div key={photo.id} className="relative group aspect-square">
|
||||
<img
|
||||
src={photo.url}
|
||||
alt="家族照片"
|
||||
className="w-full h-full object-cover rounded-lg border border-border cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => setSelectedPhoto(photo.url)}
|
||||
/>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleDelete(photo.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="absolute bottom-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => setSelectedPhoto(photo.url)}
|
||||
>
|
||||
<ZoomIn className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed rounded-lg">
|
||||
暂无照片
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 照片预览对话框 */}
|
||||
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>照片预览</DialogTitle>
|
||||
</DialogHeader>
|
||||
{selectedPhoto && (
|
||||
<img
|
||||
src={selectedPhoto}
|
||||
alt="照片预览"
|
||||
className="w-full h-auto max-h-[70vh] object-contain"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Plus, Edit, Trash, BookOpen, Calendar } from "lucide-react"
|
||||
import type { FamilyStory } from "@/types/family"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
|
||||
interface StoryManagerProps {
|
||||
stories?: FamilyStory[]
|
||||
onChange: (stories: FamilyStory[]) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export function StoryManager({ stories = [], onChange, readonly = false }: StoryManagerProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const [editingStory, setEditingStory] = useState<FamilyStory | null>(null)
|
||||
const [formData, setFormData] = useState<Partial<FamilyStory>>({
|
||||
title: "",
|
||||
content: "",
|
||||
date: "",
|
||||
})
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingStory(null)
|
||||
setFormData({ title: "", content: "", date: "" })
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleEdit = (story: FamilyStory) => {
|
||||
setEditingStory(story)
|
||||
setFormData(story)
|
||||
setIsDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = (storyId: string) => {
|
||||
if (confirm("确定要删除这个故事吗?")) {
|
||||
onChange(stories.filter(s => s.id !== storyId))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!formData.title || !formData.content) {
|
||||
alert("请填写标题和内容")
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
|
||||
if (editingStory) {
|
||||
// 更新现有故事
|
||||
onChange(
|
||||
stories.map(s =>
|
||||
s.id === editingStory.id
|
||||
? { ...s, ...formData, updatedAt: now }
|
||||
: s
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// 添加新故事
|
||||
const newStory: FamilyStory = {
|
||||
id: uuidv4(),
|
||||
title: formData.title!,
|
||||
content: formData.content!,
|
||||
date: formData.date,
|
||||
imageIds: formData.imageIds || [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
onChange([...stories, newStory])
|
||||
}
|
||||
|
||||
setIsDialogOpen(false)
|
||||
setFormData({ title: "", content: "", date: "" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
家族故事 ({stories.length})
|
||||
</h3>
|
||||
{!readonly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAdd}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
添加故事
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stories.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{stories
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||
.map((story) => (
|
||||
<Card key={story.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-lg font-serif">{story.title}</CardTitle>
|
||||
{story.date && (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{story.date}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!readonly && (
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleEdit(story)}
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(story.id)}
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-line leading-relaxed">
|
||||
{story.content}
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
创建于 {new Date(story.createdAt).toLocaleDateString('zh-CN')}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed rounded-lg">
|
||||
暂无家族故事
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 添加/编辑故事对话框 */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingStory ? "编辑故事" : "添加故事"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-title">故事标题 *</Label>
|
||||
<Input
|
||||
id="story-title"
|
||||
placeholder="例如:爷爷的创业故事"
|
||||
value={formData.title || ""}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-date">发生日期(可选)</Label>
|
||||
<Input
|
||||
id="story-date"
|
||||
type="date"
|
||||
value={formData.date || ""}
|
||||
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="story-content">故事内容 *</Label>
|
||||
<Textarea
|
||||
id="story-content"
|
||||
placeholder="记录家族的珍贵故事..."
|
||||
className="min-h-[200px]"
|
||||
value={formData.content || ""}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{editingStory ? "保存" : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Download, X, Smartphone } from "lucide-react"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
|
||||
export function PWAInstaller() {
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
|
||||
const [isInstallable, setIsInstallable] = useState(false)
|
||||
const [isInstalled, setIsInstalled] = useState(false)
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 检查是否已安装
|
||||
if (typeof window !== "undefined") {
|
||||
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
|
||||
const isIOSStandalone = (window.navigator as any).standalone === true
|
||||
|
||||
if (isStandalone || isIOSStandalone) {
|
||||
setIsInstalled(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 监听 beforeinstallprompt 事件
|
||||
const handleBeforeInstallPrompt = (e: Event) => {
|
||||
e.preventDefault()
|
||||
setDeferredPrompt(e)
|
||||
setIsInstallable(true)
|
||||
|
||||
// 延迟显示提示(给用户一些时间浏览应用)
|
||||
setTimeout(() => {
|
||||
const hasSeenPrompt = localStorage.getItem('pwa-install-prompt-seen')
|
||||
if (!hasSeenPrompt) {
|
||||
setShowPrompt(true)
|
||||
}
|
||||
}, 30000) // 30秒后显示
|
||||
}
|
||||
|
||||
// 监听安装完成事件
|
||||
const handleAppInstalled = () => {
|
||||
setIsInstalled(true)
|
||||
setShowPrompt(false)
|
||||
setIsInstallable(false)
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.addEventListener('appinstalled', handleAppInstalled)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
|
||||
window.removeEventListener('appinstalled', handleAppInstalled)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInstallClick = async () => {
|
||||
if (!deferredPrompt) return
|
||||
|
||||
// 显示安装提示
|
||||
deferredPrompt.prompt()
|
||||
|
||||
// 等待用户响应
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
console.log('用户接受了安装')
|
||||
} else {
|
||||
console.log('用户拒绝了安装')
|
||||
}
|
||||
|
||||
setDeferredPrompt(null)
|
||||
setShowPrompt(false)
|
||||
localStorage.setItem('pwa-install-prompt-seen', 'true')
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowPrompt(false)
|
||||
localStorage.setItem('pwa-install-prompt-seen', 'true')
|
||||
}
|
||||
|
||||
if (isInstalled) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isInstallable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 浮动安装提示 */}
|
||||
{showPrompt && (
|
||||
<div className="fixed bottom-4 right-4 z-50 max-w-sm animate-in slide-in-from-bottom-5">
|
||||
<Card className="shadow-lg border-2 border-primary">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="h-5 w-5 text-primary" />
|
||||
<CardTitle className="text-base">安装应用</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={handleDismiss}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription className="text-xs">
|
||||
将家谱应用添加到主屏幕,随时随地访问
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<Button
|
||||
onClick={handleInstallClick}
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
立即安装
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 设置页面的安装按钮 */}
|
||||
{!showPrompt && (
|
||||
<Alert className="cursor-pointer hover:bg-muted/50 transition-colors" onClick={handleInstallClick}>
|
||||
<Download className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>将应用安装到设备</span>
|
||||
<Button size="sm" variant="outline">
|
||||
安装
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { registerServiceWorker } from "@/lib/register-sw"
|
||||
import { PWAInstaller } from "./pwa-installer"
|
||||
|
||||
export function PWAProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
registerServiceWorker()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<PWAInstaller />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Bell, BellOff, Check, X } from "lucide-react"
|
||||
import { requestNotificationPermission, checkUpcomingAnniversaries } from "@/lib/notifications"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
|
||||
export function NotificationSettings() {
|
||||
const { treeData } = useFamily()
|
||||
const [notificationEnabled, setNotificationEnabled] = useState(false)
|
||||
const [permission, setPermission] = useState<NotificationPermission>("default")
|
||||
const [isClient, setIsClient] = useState(false)
|
||||
const [upcomingEvents, setUpcomingEvents] = useState<{
|
||||
birthdays: Array<{ member: any; daysUntil: number }>
|
||||
deathdays: Array<{ member: any; daysUntil: number }>
|
||||
}>({ birthdays: [], deathdays: [] })
|
||||
|
||||
useEffect(() => {
|
||||
// 标记为客户端
|
||||
setIsClient(true)
|
||||
|
||||
// 检查通知权限
|
||||
if (typeof window !== "undefined" && "Notification" in window) {
|
||||
setPermission(Notification.permission)
|
||||
setNotificationEnabled(Notification.permission === "granted")
|
||||
}
|
||||
|
||||
// 检查即将到来的纪念日
|
||||
const members = Object.values(treeData.members)
|
||||
const upcoming = checkUpcomingAnniversaries(members, 7)
|
||||
setUpcomingEvents(upcoming)
|
||||
}, [treeData])
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
const granted = await requestNotificationPermission()
|
||||
if (granted) {
|
||||
setNotificationEnabled(true)
|
||||
setPermission("granted")
|
||||
|
||||
// 发送测试通知
|
||||
new Notification("通知已启用", {
|
||||
body: "您将收到家族纪念日提醒",
|
||||
icon: "/icon.svg",
|
||||
})
|
||||
} else {
|
||||
setPermission(Notification.permission)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisableNotifications = () => {
|
||||
setNotificationEnabled(false)
|
||||
alert("请在浏览器设置中禁用此网站的通知权限")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bell className="h-5 w-5" />
|
||||
纪念日通知
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
接收家族成员生日和忌日的浏览器通知
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!isClient ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
) : !(typeof window !== "undefined" && "Notification" in window) ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
您的浏览器不支持通知功能
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="notifications">启用通知</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
每天自动检查并提醒当日纪念日
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="notifications"
|
||||
checked={notificationEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
handleEnableNotifications()
|
||||
} else {
|
||||
handleDisableNotifications()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{permission === "denied" && (
|
||||
<Alert variant="destructive">
|
||||
<X className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
通知权限已被拒绝。请在浏览器设置中允许此网站发送通知。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{permission === "granted" && (
|
||||
<Alert>
|
||||
<Check className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
通知已启用。您将在每天首次访问时收到当日纪念日提醒。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{notificationEnabled && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
new Notification("测试通知", {
|
||||
body: "这是一条测试通知",
|
||||
icon: "/icon.svg",
|
||||
})
|
||||
}}
|
||||
>
|
||||
发送测试通知
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 即将到来的纪念日 */}
|
||||
{(upcomingEvents.birthdays.length > 0 || upcomingEvents.deathdays.length > 0) && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>即将到来的纪念日(7天内)</CardTitle>
|
||||
<CardDescription>提前准备,不错过重要日子</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{upcomingEvents.birthdays.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||
🎂 生日
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{upcomingEvents.birthdays.map(({ member, daysUntil }) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{member.fullName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(member.birthDate).toLocaleDateString('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{daysUntil === 1 ? '明天' : `${daysUntil}天后`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{upcomingEvents.deathdays.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2 flex items-center gap-2">
|
||||
🕯️ 忌日
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{upcomingEvents.deathdays.map(({ member, daysUntil }) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">{member.fullName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{new Date(member.deathDate!).toLocaleDateString('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
{daysUntil === 1 ? '明天' : `${daysUntil}天后`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,21 +2,72 @@
|
||||
|
||||
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"
|
||||
|
||||
interface FamilyNodeProps {
|
||||
member: FamilyMember
|
||||
spouse?: FamilyMember
|
||||
isRoot?: boolean
|
||||
onSelect?: (member: FamilyMember) => void
|
||||
isHighlighted?: boolean
|
||||
}
|
||||
|
||||
export function FamilyNode({ member, spouse, isRoot, onSelect }: FamilyNodeProps) {
|
||||
// 格式化日期显示(只显示年份)
|
||||
function formatYear(dateString?: string): string {
|
||||
if (!dateString) return ''
|
||||
const year = dateString.split('-')[0]
|
||||
return year
|
||||
}
|
||||
|
||||
// 计算年龄
|
||||
function calculateAge(birthDate?: string, deathDate?: string): number | null {
|
||||
if (!birthDate) return null
|
||||
|
||||
const birth = new Date(birthDate)
|
||||
const end = deathDate ? new Date(deathDate) : new Date()
|
||||
|
||||
let age = end.getFullYear() - birth.getFullYear()
|
||||
const monthDiff = end.getMonth() - birth.getMonth()
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
|
||||
age--
|
||||
}
|
||||
|
||||
return age
|
||||
}
|
||||
|
||||
// 单个人员卡片组件
|
||||
function PersonCard({ member, isRoot, isSpouse, onSelect, isHighlighted }: { member: FamilyMember; isRoot?: boolean; isSpouse?: boolean; onSelect?: (member: FamilyMember) => void; isHighlighted?: boolean }) {
|
||||
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarImageId)
|
||||
|
||||
const birthYear = formatYear(member.birthDate)
|
||||
const deathYear = formatYear(member.deathDate)
|
||||
const age = calculateAge(member.birthDate, member.deathDate)
|
||||
|
||||
const lifeSpan = birthYear
|
||||
? deathYear
|
||||
? `${birthYear}-${deathYear}`
|
||||
: `${birthYear}-`
|
||||
: ''
|
||||
|
||||
const ageText = age !== null
|
||||
? member.deathDate
|
||||
? `享年${age}岁`
|
||||
: `${age}岁`
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer hover:scale-105 shadow-sm w-32",
|
||||
isRoot ? "border-primary bg-primary/5" : "border-border bg-card",
|
||||
member.gender === "female" ? "rounded-full w-28 h-28 justify-center" : "rounded-lg", // Traditional round sky/square earth or just variety
|
||||
"family-node-card relative flex flex-col items-center gap-2 p-3 rounded-lg transition-all cursor-pointer hover:scale-105 shadow-sm w-32",
|
||||
isHighlighted
|
||||
? "border-4 border-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 ring-4 ring-yellow-200 dark:ring-yellow-800 animate-pulse"
|
||||
: isRoot
|
||||
? "border-2 border-primary bg-primary/5"
|
||||
: isSpouse
|
||||
? "border-2 border-dashed border-border/60 bg-muted/30"
|
||||
: "border-2 border-border bg-card",
|
||||
)}
|
||||
onClick={() => onSelect?.(member)}
|
||||
>
|
||||
@@ -24,28 +75,62 @@ export function FamilyNode({ member, spouse, isRoot, onSelect }: FamilyNodeProps
|
||||
{member.generation}世
|
||||
</div>
|
||||
|
||||
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground overflow-hidden border border-border/50">
|
||||
{member.avatarUrl ? (
|
||||
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center text-muted-foreground overflow-hidden border border-border/50">
|
||||
{avatarBlobUrl ? (
|
||||
<img
|
||||
src={member.avatarUrl || "/placeholder.svg"}
|
||||
src={avatarBlobUrl}
|
||||
alt={member.fullName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="font-serif font-bold text-lg text-foreground/70">{member.surname}</span>
|
||||
member.gender === 'female' ? (
|
||||
<CircleUserRound className="h-8 w-8 text-pink-400" />
|
||||
) : (
|
||||
<CircleUser className="h-8 w-8 text-blue-400" />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-serif font-bold text-foreground leading-tight">{member.givenName}</div>
|
||||
<div className="text-center w-full">
|
||||
<div className="font-serif font-bold text-foreground leading-tight">{member.surname} {member.givenName}</div>
|
||||
{member.courtesyName && (
|
||||
<div className="text-[10px] text-muted-foreground mt-0.5">字 {member.courtesyName}</div>
|
||||
)}
|
||||
{lifeSpan && (
|
||||
<div className="text-[9px] text-muted-foreground/70 mt-1 font-mono">
|
||||
{lifeSpan}
|
||||
</div>
|
||||
)}
|
||||
{ageText && (
|
||||
<div className="text-[9px] text-muted-foreground/60 font-mono">
|
||||
{ageText}
|
||||
</div>
|
||||
)}
|
||||
{member.phone && (
|
||||
<div className="text-[9px] text-muted-foreground/60 font-mono truncate">
|
||||
📱 {member.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{member.lifeStatus === "deceased" && (
|
||||
{member.deathDate && (
|
||||
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-neutral-400 ring-2 ring-background" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FamilyNode({ member, spouse, isRoot, onSelect, isHighlighted }: FamilyNodeProps) {
|
||||
if (!spouse) {
|
||||
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} />
|
||||
}
|
||||
|
||||
// 有配偶时,并排显示
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} />
|
||||
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
|
||||
<PersonCard member={spouse} isSpouse={true} onSelect={onSelect} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+179
-46
@@ -2,71 +2,169 @@
|
||||
import type { FamilyMember } from "@/types/family"
|
||||
import { FamilyNode } from "./family-node"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
|
||||
interface TreeLayoutProps {
|
||||
rootId: string
|
||||
onSelectMember?: (member: FamilyMember) => void
|
||||
}
|
||||
|
||||
interface Connection {
|
||||
from: { x: number; y: number }
|
||||
to: { x: number; y: number }
|
||||
type: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
export function TreeLayout({ rootId, onSelectMember }: TreeLayoutProps) {
|
||||
const { getMember } = useFamily()
|
||||
const { getMember, treeData, highlightedMemberId } = useFamily()
|
||||
const root = getMember(rootId)
|
||||
const router = useRouter()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [connections, setConnections] = useState<Connection[]>([])
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
// 节点间距配置
|
||||
const HORIZONTAL_GAP = 32 // 2rem = 32px
|
||||
const VERTICAL_GAP = 48 // 3rem = 48px
|
||||
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
|
||||
|
||||
// 处理节点点击
|
||||
const handleNodeClick = useCallback((member: FamilyMember) => {
|
||||
router.push(`/members/${member.id}`)
|
||||
}, [router])
|
||||
|
||||
// 计算所有连线位置
|
||||
const calculateConnections = useCallback(() => {
|
||||
const newConnections: Connection[] = []
|
||||
const containerRect = containerRef.current?.getBoundingClientRect()
|
||||
if (!containerRect) return
|
||||
|
||||
// 检查是否有足够的节点已注册
|
||||
if (nodeRefs.current.size === 0) return
|
||||
|
||||
nodeRefs.current.forEach((nodeElement, nodeId) => {
|
||||
const member = getMember(nodeId)
|
||||
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
|
||||
|
||||
const parentRect = nodeElement.getBoundingClientRect()
|
||||
const parentCenterX = parentRect.left + parentRect.width / 2 - containerRect.left
|
||||
const parentBottomY = parentRect.bottom - containerRect.top
|
||||
|
||||
// 父节点到水平线的垂直连线
|
||||
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
|
||||
newConnections.push({
|
||||
from: { x: parentCenterX, y: parentBottomY },
|
||||
to: { x: parentCenterX, y: verticalLineY },
|
||||
type: 'vertical'
|
||||
})
|
||||
|
||||
// 获取所有子节点的位置
|
||||
const childPositions = member.childrenIds
|
||||
.map(childId => {
|
||||
const childElement = nodeRefs.current.get(childId)
|
||||
if (!childElement) return null
|
||||
const childRect = childElement.getBoundingClientRect()
|
||||
return {
|
||||
id: childId,
|
||||
centerX: childRect.left + childRect.width / 2 - containerRect.left,
|
||||
topY: childRect.top - containerRect.top
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
|
||||
|
||||
if (childPositions.length === 0) return
|
||||
|
||||
// 如果有多个子节点,绘制水平连线
|
||||
if (childPositions.length > 1) {
|
||||
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
|
||||
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
|
||||
|
||||
newConnections.push({
|
||||
from: { x: leftmostX, y: verticalLineY },
|
||||
to: { x: rightmostX, y: verticalLineY },
|
||||
type: 'horizontal'
|
||||
})
|
||||
}
|
||||
|
||||
// 每个子节点的垂直连线
|
||||
childPositions.forEach(child => {
|
||||
newConnections.push({
|
||||
from: { x: child.centerX, y: verticalLineY },
|
||||
to: { x: child.centerX, y: child.topY },
|
||||
type: 'vertical'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setConnections(newConnections)
|
||||
}, [getMember])
|
||||
|
||||
useEffect(() => {
|
||||
// 多次尝试计算,确保 DOM 完全渲染
|
||||
const timers: NodeJS.Timeout[] = []
|
||||
timers.push(setTimeout(calculateConnections, 50))
|
||||
timers.push(setTimeout(calculateConnections, 150))
|
||||
timers.push(setTimeout(calculateConnections, 300))
|
||||
timers.push(setTimeout(calculateConnections, 500))
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', calculateConnections)
|
||||
|
||||
return () => {
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
window.removeEventListener('resize', calculateConnections)
|
||||
}
|
||||
}, [calculateConnections, treeData])
|
||||
|
||||
if (!root) return null
|
||||
|
||||
// Recursive component to render the tree
|
||||
const TreeNode = ({ memberId }: { memberId: string }) => {
|
||||
// 递归渲染树节点
|
||||
const TreeNode = ({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) => {
|
||||
const member = getMember(memberId)
|
||||
if (!member) return null
|
||||
|
||||
const hasChildren = member.childrenIds && member.childrenIds.length > 0
|
||||
|
||||
// 获取配偶(取第一个配偶)
|
||||
const spouse = member.spouseIds && member.spouseIds.length > 0
|
||||
? getMember(member.spouseIds[0])
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative z-10">
|
||||
<FamilyNode member={member} onSelect={onSelectMember} />
|
||||
|
||||
{/* Connector to children */}
|
||||
{hasChildren && <div className="absolute top-full left-1/2 -translate-x-1/2 h-8 w-px bg-border" />}
|
||||
{/* 节点本身 */}
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
nodeRefs.current.set(memberId, el)
|
||||
} else {
|
||||
nodeRefs.current.delete(memberId)
|
||||
}
|
||||
}}
|
||||
className="relative z-10"
|
||||
>
|
||||
<FamilyNode
|
||||
member={member}
|
||||
spouse={spouse}
|
||||
onSelect={handleNodeClick}
|
||||
isRoot={isRoot}
|
||||
isHighlighted={member.id === highlightedMemberId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 子节点 */}
|
||||
{hasChildren && (
|
||||
<div className="flex items-start pt-8 relative">
|
||||
{/* Horizontal connecting line */}
|
||||
{member.childrenIds.length > 1 && (
|
||||
<div className="absolute top-0 left-[50%] -translate-x-1/2 h-px bg-border w-[calc(100%-8rem)]" /> // Rough approx for connector width
|
||||
)}
|
||||
|
||||
<div className="flex gap-8 relative">
|
||||
{/* Top connecting lines for children */}
|
||||
{member.childrenIds.length > 1 && (
|
||||
<div className="absolute top-0 left-0 right-0 h-px bg-transparent">
|
||||
{/* This needs precise calculation or just use CSS pseudo elements on children */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{member.childrenIds.map((childId, index) => (
|
||||
<div key={childId} className="flex flex-col items-center relative">
|
||||
{/* Vertical line from parent's horizontal line to child */}
|
||||
<div className="h-8 w-px bg-border absolute -top-8 left-1/2 -translate-x-1/2"></div>
|
||||
|
||||
{/* Horizontal connector fix for first/last child */}
|
||||
{member.childrenIds.length > 1 && (
|
||||
<>
|
||||
{index === 0 && <div className="absolute -top-8 right-0 w-1/2 h-px bg-border"></div>}
|
||||
{index === member.childrenIds.length - 1 && (
|
||||
<div className="absolute -top-8 left-0 w-1/2 h-px bg-border"></div>
|
||||
)}
|
||||
{index > 0 && index < member.childrenIds.length - 1 && (
|
||||
<div className="absolute -top-8 left-0 w-full h-px bg-border"></div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<TreeNode memberId={childId} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex items-start relative"
|
||||
style={{
|
||||
marginTop: `${VERTICAL_GAP}px`,
|
||||
gap: `${HORIZONTAL_GAP}px`
|
||||
}}
|
||||
>
|
||||
{member.childrenIds.map((childId) => (
|
||||
<TreeNode key={childId} memberId={childId} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -74,8 +172,43 @@ export function TreeLayout({ rootId, onSelectMember }: TreeLayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-12 min-w-fit">
|
||||
<TreeNode memberId={rootId} />
|
||||
<div className="relative">
|
||||
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
|
||||
{/* 连线层 - 使用 div 绘制 */}
|
||||
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
|
||||
{connections.map((conn, index) => {
|
||||
const isHorizontal = conn.type === 'horizontal'
|
||||
// 使用 1px 但通过 transform scale 缩小来实现细线
|
||||
const width = isHorizontal ? Math.abs(conn.to.x - conn.from.x) : 1
|
||||
const height = isHorizontal ? 1 : Math.abs(conn.to.y - conn.from.y)
|
||||
const left = Math.min(conn.from.x, conn.to.x)
|
||||
const top = Math.min(conn.from.y, conn.to.y)
|
||||
|
||||
// 跳过无效的连线(宽度或高度为0)
|
||||
if (width <= 0 || height <= 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute bg-foreground/25"
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${width}px`,
|
||||
height: `${height}px`,
|
||||
transform: isHorizontal ? 'scaleY(0.3)' : 'scaleX(0.3)',
|
||||
transformOrigin: 'top left',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 树节点层 */}
|
||||
<div className="relative" style={{ zIndex: 10 }}>
|
||||
<TreeNode memberId={rootId} isRoot={true} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
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
|
||||
fallbackText?: string
|
||||
gender?: Gender
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, className }: AvatarDisplayProps) {
|
||||
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, gender, className }: AvatarDisplayProps) {
|
||||
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -28,10 +31,26 @@ export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, className }:
|
||||
}
|
||||
}, [imageId])
|
||||
|
||||
// 如果有头像,显示头像
|
||||
if (blobUrl) {
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={blobUrl} />
|
||||
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
// 没有头像,显示性别图标
|
||||
return (
|
||||
<Avatar className={className}>
|
||||
<AvatarImage src={blobUrl || fallbackUrl || "/placeholder.svg"} />
|
||||
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
|
||||
<AvatarFallback className="bg-muted flex items-center justify-center">
|
||||
{gender === 'female' ? (
|
||||
<CircleUserRound className="h-8 w-8 text-pink-400" />
|
||||
) : (
|
||||
<CircleUser className="h-8 w-8 text-blue-400" />
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
+172
-42
@@ -3,9 +3,13 @@
|
||||
import { useState, useRef, useEffect } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Upload, X, Loader2 } from "lucide-react"
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
|
||||
import { Upload, X, Loader2, Crop } from "lucide-react"
|
||||
import { db } from "@/lib/db"
|
||||
import { v4 as uuidv4 } from "uuid"
|
||||
import ReactCrop, { type Crop as CropType } from "react-image-crop"
|
||||
import "react-image-crop/dist/ReactCrop.css"
|
||||
import imageCompression from "browser-image-compression"
|
||||
|
||||
interface ImageUploadProps {
|
||||
value?: string // The image ID
|
||||
@@ -16,6 +20,11 @@ interface ImageUploadProps {
|
||||
export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [showCropDialog, setShowCropDialog] = useState(false)
|
||||
const [imageToCrop, setImageToCrop] = useState<string | null>(null)
|
||||
const [crop, setCrop] = useState<CropType>()
|
||||
const [completedCrop, setCompletedCrop] = useState<CropType>()
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Load preview if value (imageId) exists
|
||||
@@ -50,7 +59,6 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Basic validation
|
||||
if (!file.type.startsWith("image/")) {
|
||||
@@ -58,28 +66,103 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("图片大小不能超过 5MB")
|
||||
return
|
||||
// 压缩图片
|
||||
const options = {
|
||||
maxSizeMB: 1,
|
||||
maxWidthOrHeight: 1024,
|
||||
useWebWorker: true,
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, options)
|
||||
|
||||
// 显示裁剪对话框
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
setImageToCrop(reader.result as string)
|
||||
setShowCropDialog(true)
|
||||
}
|
||||
reader.readAsDataURL(compressedFile)
|
||||
} catch (error) {
|
||||
console.error("Failed to process image:", error)
|
||||
alert("图片处理失败")
|
||||
} finally {
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleCropComplete = async () => {
|
||||
if (!completedCrop || !imgRef.current) {
|
||||
// 如果没有裁剪,直接保存原图
|
||||
await saveImage(imageToCrop!)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const canvas = document.createElement('canvas')
|
||||
const scaleX = imgRef.current.naturalWidth / imgRef.current.width
|
||||
const scaleY = imgRef.current.naturalHeight / imgRef.current.height
|
||||
|
||||
canvas.width = completedCrop.width
|
||||
canvas.height = completedCrop.height
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
if (ctx) {
|
||||
ctx.drawImage(
|
||||
imgRef.current,
|
||||
completedCrop.x * scaleX,
|
||||
completedCrop.y * scaleY,
|
||||
completedCrop.width * scaleX,
|
||||
completedCrop.height * scaleY,
|
||||
0,
|
||||
0,
|
||||
completedCrop.width,
|
||||
completedCrop.height
|
||||
)
|
||||
}
|
||||
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
await saveImage(URL.createObjectURL(blob), blob)
|
||||
}
|
||||
}, 'image/jpeg', 0.9)
|
||||
} catch (error) {
|
||||
console.error("Failed to crop image:", error)
|
||||
alert("图片裁剪失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
setShowCropDialog(false)
|
||||
setImageToCrop(null)
|
||||
}
|
||||
}
|
||||
|
||||
const saveImage = async (dataUrl: string, blob?: Blob) => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
|
||||
let imageBlob = blob
|
||||
if (!imageBlob) {
|
||||
const response = await fetch(dataUrl)
|
||||
imageBlob = await response.blob()
|
||||
}
|
||||
|
||||
// Save to DB
|
||||
const imageId = uuidv4()
|
||||
await db.images.add({
|
||||
id: imageId,
|
||||
blob: file,
|
||||
mimeType: file.type,
|
||||
blob: imageBlob,
|
||||
mimeType: imageBlob.type,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
|
||||
onChange(imageId)
|
||||
setShowCropDialog(false)
|
||||
setImageToCrop(null)
|
||||
} catch (error) {
|
||||
console.error("Failed to save image:", error)
|
||||
alert("图片上传失败")
|
||||
alert("图片保存失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
// Reset input
|
||||
if (fileInputRef.current) fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,43 +171,90 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<Avatar className="h-20 w-20 border-2 border-dashed border-muted-foreground/50">
|
||||
<AvatarImage src={previewUrl} className="object-cover" />
|
||||
<AvatarFallback className="bg-transparent">
|
||||
{isLoading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6 text-muted-foreground" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<>
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
<Avatar className="h-20 w-20 border-2 border-dashed border-muted-foreground/50">
|
||||
<AvatarImage src={previewUrl} className="object-cover" />
|
||||
<AvatarFallback className="bg-transparent">
|
||||
{isLoading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6 text-muted-foreground" />}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{value ? "更换头像" : "上传头像"}
|
||||
</Button>
|
||||
{value && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleRemove}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="mr-2 h-3 w-3" /> 移除
|
||||
{value ? "更换头像" : "上传头像"}
|
||||
</Button>
|
||||
)}
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<X className="mr-2 h-3 w-3" /> 移除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 裁剪对话框 */}
|
||||
<Dialog open={showCropDialog} onOpenChange={setShowCropDialog}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Crop className="h-5 w-5" />
|
||||
裁剪图片
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-auto">
|
||||
{imageToCrop && (
|
||||
<ReactCrop
|
||||
crop={crop}
|
||||
onChange={(c) => setCrop(c)}
|
||||
onComplete={(c) => setCompletedCrop(c)}
|
||||
aspect={1}
|
||||
circularCrop
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageToCrop}
|
||||
alt="待裁剪图片"
|
||||
className="max-w-full"
|
||||
/>
|
||||
</ReactCrop>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCropDialog(false)
|
||||
setImageToCrop(null)
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleCropComplete} disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
||||
{completedCrop ? "确认裁剪" : "跳过裁剪"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user