0.6.2.0
This commit is contained in:
+32
-7
@@ -20,15 +20,21 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: '没有文件' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 验证文件类型
|
||||
if (!file.type.startsWith('image/')) {
|
||||
return NextResponse.json({ error: '只能上传图片文件' }, { status: 400 })
|
||||
// 验证文件类型(支持图片和视频)
|
||||
const isImage = file.type.startsWith('image/')
|
||||
const isVideo = file.type.startsWith('video/')
|
||||
if (!isImage && !isVideo) {
|
||||
return NextResponse.json({ error: '只能上传图片或视频文件' }, { status: 400 })
|
||||
}
|
||||
|
||||
// 验证文件大小(限制为 5MB)
|
||||
const maxSize = 5 * 1024 * 1024 // 5MB
|
||||
// 验证文件大小(图片限制 5MB,视频限制 100MB)
|
||||
const maxImageSize = 5 * 1024 * 1024 // 5MB
|
||||
const maxVideoSize = 100 * 1024 * 1024 // 100MB
|
||||
const maxSize = isVideo ? maxVideoSize : maxImageSize
|
||||
if (file.size > maxSize) {
|
||||
return NextResponse.json({ error: '文件大小不能超过 5MB' }, { status: 400 })
|
||||
return NextResponse.json({
|
||||
error: isVideo ? '视频文件大小不能超过 100MB' : '图片文件大小不能超过 5MB'
|
||||
}, { status: 400 })
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
@@ -44,7 +50,26 @@ export async function POST(req: NextRequest) {
|
||||
// 生成唯一文件名
|
||||
const timestamp = Date.now()
|
||||
const randomStr = Math.random().toString(36).substring(2, 8)
|
||||
const ext = file.name.split('.').pop()
|
||||
|
||||
// 从文件名或 MIME 类型获取扩展名
|
||||
let ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!ext || ext === file.name) {
|
||||
// 如果没有扩展名,从 MIME 类型推断
|
||||
const mimeToExt: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
'image/svg+xml': 'svg',
|
||||
'video/mp4': 'mp4',
|
||||
'video/webm': 'webm',
|
||||
'video/ogg': 'ogg',
|
||||
'video/quicktime': 'mov',
|
||||
'video/x-msvideo': 'avi',
|
||||
'video/x-matroska': 'mkv',
|
||||
}
|
||||
ext = mimeToExt[file.type] || 'bin'
|
||||
}
|
||||
const fileName = `${timestamp}-${randomStr}.${ext}`
|
||||
|
||||
// 保存文件
|
||||
|
||||
+165
-103
@@ -11,7 +11,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen, BarChart3, Calculator, Loader2, Images, ChevronDown, ChevronUp, EyeOff } from "lucide-react"
|
||||
import { UserPlus, Network, Map, Calendar, Users, ArrowRight, Clock, Search, BookOpen, BarChart3, Calculator, Loader2, Images, ChevronDown, ChevronUp, EyeOff, Play, Video } from "lucide-react"
|
||||
import { useFamily } from "@/context/family-context"
|
||||
import { useMemo, useState, useEffect, useRef, useCallback } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
@@ -54,6 +54,12 @@ interface ActivityLog {
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否为视频文件
|
||||
const isVideoFile = (url: string) => {
|
||||
const videoExtensions = ['.mp4', '.webm', '.ogg', '.mov', '.avi', '.mkv']
|
||||
return videoExtensions.some(ext => url.toLowerCase().endsWith(ext))
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { treeData, isLoading, currentTree, updateMember } = useFamily()
|
||||
const { data: session } = useSession()
|
||||
@@ -580,111 +586,158 @@ export default function DashboardPage() {
|
||||
</ChineseCardHeader>
|
||||
<ChineseCardContent>
|
||||
{allPhotos.length > 0 ? (
|
||||
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
|
||||
{allPhotos.map((photo, index) => (
|
||||
<div
|
||||
key={`${photo.memberId}-${index}`}
|
||||
className="break-inside-avoid group"
|
||||
>
|
||||
<div className="rounded-lg overflow-hidden bg-card shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 border border-border/50">
|
||||
{photo.adminVisibleOverride === false ? (
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<span>来自</span>
|
||||
<Link
|
||||
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
<MemberNameWithStatus
|
||||
name={photo.memberName}
|
||||
isDead={photo.isDead}
|
||||
className="text-foreground font-medium"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-muted-foreground/70 text-[10px]">
|
||||
{format(new Date(photo.uploadedAt), 'yyyy-MM-dd')}
|
||||
</span>
|
||||
</div>
|
||||
{isOwner ? (
|
||||
<div className="flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>允许展示</span>
|
||||
<Switch
|
||||
checked={photo.adminVisibleOverride ?? true}
|
||||
onCheckedChange={(checked) => handleAdminPhotoToggle(photo.memberId, photo.url, checked)}
|
||||
disabled={adminToggleLoading === `${photo.memberId}|${photo.url}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">管理员已隐藏</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 照片区域 - 点击放大 */}
|
||||
<div
|
||||
className="relative cursor-zoom-in"
|
||||
onClick={() => setSelectedPhoto(photo.url)}
|
||||
<div className="space-y-8">
|
||||
{/* 按月份分组显示 */}
|
||||
{(() => {
|
||||
// 按月份分组
|
||||
const sortedPhotos = [...allPhotos].sort((a, b) =>
|
||||
new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime()
|
||||
)
|
||||
const groupedByMonth: Record<string, typeof allPhotos> = {}
|
||||
sortedPhotos.forEach(photo => {
|
||||
const monthKey = format(new Date(photo.uploadedAt), 'yyyy年MM月')
|
||||
if (!groupedByMonth[monthKey]) {
|
||||
groupedByMonth[monthKey] = []
|
||||
}
|
||||
groupedByMonth[monthKey].push(photo)
|
||||
})
|
||||
|
||||
return Object.entries(groupedByMonth).map(([month, monthPhotos]) => (
|
||||
<div key={month}>
|
||||
<h4 className="text-sm font-medium text-muted-foreground mb-4 flex items-center gap-2 sticky top-0 bg-card/95 backdrop-blur py-2 z-10">
|
||||
<span className="w-2 h-2 rounded-full bg-primary"></span>
|
||||
{month}
|
||||
<span className="text-xs text-muted-foreground/70">({monthPhotos.length})</span>
|
||||
</h4>
|
||||
<div className="columns-2 md:columns-3 lg:columns-4 xl:columns-5 gap-4 space-y-4">
|
||||
{monthPhotos.map((photo, index) => (
|
||||
<div
|
||||
key={`${photo.memberId}-${index}`}
|
||||
className="break-inside-avoid group"
|
||||
>
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || `${photo.memberName}的照片`}
|
||||
className="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{/* 底部显示信息 */}
|
||||
<div className="px-3 py-2 bg-card border-t border-border/30 space-y-1">
|
||||
{/* 照片说明 */}
|
||||
<div className="text-xs line-clamp-2">
|
||||
{photo.caption ? (
|
||||
<span className="text-foreground">{photo.caption}</span>
|
||||
<div className="rounded-lg overflow-hidden bg-card shadow-sm hover:shadow-lg transition-all duration-300 hover:-translate-y-1 border border-border/50">
|
||||
{photo.adminVisibleOverride === false ? (
|
||||
<div className="p-4 space-y-2">
|
||||
<div className="flex items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<span>来自</span>
|
||||
<Link
|
||||
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
<MemberNameWithStatus
|
||||
name={photo.memberName}
|
||||
isDead={photo.isDead}
|
||||
className="text-foreground font-medium"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-muted-foreground/70 text-[10px]">
|
||||
{format(new Date(photo.uploadedAt), 'MM-dd')}
|
||||
</span>
|
||||
</div>
|
||||
{isOwner ? (
|
||||
<div className="flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>允许展示</span>
|
||||
<Switch
|
||||
checked={photo.adminVisibleOverride ?? true}
|
||||
onCheckedChange={(checked) => handleAdminPhotoToggle(photo.memberId, photo.url, checked)}
|
||||
disabled={adminToggleLoading === `${photo.memberId}|${photo.url}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted-foreground">管理员已隐藏</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">暂无说明</span>
|
||||
<>
|
||||
{/* 媒体区域 - 点击放大/播放 */}
|
||||
<div
|
||||
className="relative cursor-zoom-in"
|
||||
onClick={() => setSelectedPhoto(photo.url)}
|
||||
>
|
||||
{isVideoFile(photo.url) ? (
|
||||
<div className="relative">
|
||||
<video
|
||||
src={photo.url}
|
||||
className="w-full h-auto object-cover"
|
||||
muted
|
||||
preload="metadata"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<div className="w-12 h-12 rounded-full bg-white/90 flex items-center justify-center">
|
||||
<Play className="h-6 w-6 text-black ml-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded flex items-center gap-1">
|
||||
<Video className="h-3 w-3" />
|
||||
视频
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={photo.url}
|
||||
alt={photo.caption || `${photo.memberName}的照片`}
|
||||
className="w-full h-auto object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 底部显示信息 */}
|
||||
<div className="px-3 py-2 bg-card border-t border-border/30 space-y-1">
|
||||
{/* 照片说明 */}
|
||||
<div className="text-xs line-clamp-2">
|
||||
{photo.caption ? (
|
||||
<span className="text-foreground">{photo.caption}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground/70">暂无说明</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 分享人和时间 */}
|
||||
<div className="flex items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">来自</span>
|
||||
<Link
|
||||
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
<MemberNameWithStatus
|
||||
name={photo.memberName}
|
||||
isDead={photo.isDead}
|
||||
className="text-foreground font-medium"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-muted-foreground/70 text-[10px]">
|
||||
{format(new Date(photo.uploadedAt), 'MM-dd')}
|
||||
</span>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>允许展示</span>
|
||||
<Switch
|
||||
checked={photo.adminVisibleOverride ?? true}
|
||||
onCheckedChange={(checked) => handleAdminPhotoToggle(photo.memberId, photo.url, checked)}
|
||||
disabled={adminToggleLoading === `${photo.memberId}|${photo.url}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 分享人和时间 */}
|
||||
<div className="flex items-center justify-between gap-1.5 text-xs">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted-foreground">来自</span>
|
||||
<Link
|
||||
href={`/members/${photo.memberId}${currentTree?.id ? `?treeId=${currentTree.id}` : ''}`}
|
||||
className="hover:text-primary hover:underline"
|
||||
>
|
||||
<MemberNameWithStatus
|
||||
name={photo.memberName}
|
||||
isDead={photo.isDead}
|
||||
className="text-foreground font-medium"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<span className="text-muted-foreground/70 text-[10px]">
|
||||
{format(new Date(photo.uploadedAt), 'yyyy-MM-dd')}
|
||||
</span>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="mt-2 flex items-center justify-between gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>允许展示</span>
|
||||
<Switch
|
||||
checked={photo.adminVisibleOverride ?? true}
|
||||
onCheckedChange={(checked) => handleAdminPhotoToggle(photo.memberId, photo.url, checked)}
|
||||
disabled={adminToggleLoading === `${photo.memberId}|${photo.url}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<Images className="h-12 w-12 mx-auto text-muted-foreground/30 mb-4" />
|
||||
<p className="text-muted-foreground">暂无照片</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1">在成员详情页可以添加照片</p>
|
||||
<p className="text-muted-foreground">暂无照片或视频</p>
|
||||
<p className="text-sm text-muted-foreground/70 mt-1">在成员详情页可以添加照片或视频</p>
|
||||
</div>
|
||||
)}
|
||||
</ChineseCardContent>
|
||||
@@ -1102,20 +1155,29 @@ export default function DashboardPage() {
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 照片放大预览 Dialog */}
|
||||
{/* 媒体放大预览 Dialog */}
|
||||
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
|
||||
<DialogContent
|
||||
className="max-w-4xl p-0 bg-black/95 border-none"
|
||||
overlayClassName="backdrop-blur-md bg-black/60"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogTitle className="sr-only">照片预览</DialogTitle>
|
||||
<DialogTitle className="sr-only">{selectedPhoto && isVideoFile(selectedPhoto) ? '视频播放' : '照片预览'}</DialogTitle>
|
||||
{selectedPhoto && (
|
||||
<img
|
||||
src={selectedPhoto}
|
||||
alt="照片预览"
|
||||
className="w-full h-auto max-h-[90vh] object-contain"
|
||||
/>
|
||||
isVideoFile(selectedPhoto) ? (
|
||||
<video
|
||||
src={selectedPhoto}
|
||||
controls
|
||||
autoPlay
|
||||
className="w-full h-auto max-h-[90vh] object-contain"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={selectedPhoto}
|
||||
alt="照片预览"
|
||||
className="w-full h-auto max-h-[90vh] object-contain"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
+565
-39
@@ -181,7 +181,343 @@ const geoCoordMap: Record<string, [number, number]> = {
|
||||
'抚顺': [123.92, 41.88],
|
||||
'秦皇岛': [119.57, 39.95],
|
||||
'株洲': [113.15, 27.83],
|
||||
'齐齐哈尔': [123.95, 47.33]
|
||||
'齐齐哈尔': [123.95, 47.33],
|
||||
|
||||
// 国际城市 - 澳大利亚
|
||||
'悉尼': [151.21, -33.87],
|
||||
'墨尔本': [144.96, -37.81],
|
||||
'布里斯班': [153.03, -27.47],
|
||||
'珀斯': [115.86, -31.95],
|
||||
'阿德莱德': [138.60, -34.93],
|
||||
'堪培拉': [149.13, -35.28],
|
||||
'黄金海岸': [153.43, -28.00],
|
||||
'Sydney': [151.21, -33.87],
|
||||
'Melbourne': [144.96, -37.81],
|
||||
'Brisbane': [153.03, -27.47],
|
||||
'Perth': [115.86, -31.95],
|
||||
'Adelaide': [138.60, -34.93],
|
||||
'Canberra': [149.13, -35.28],
|
||||
|
||||
// 国际城市 - 美国主要城市
|
||||
'纽约': [-74.01, 40.71],
|
||||
'洛杉矶': [-118.24, 34.05],
|
||||
'旧金山': [-122.42, 37.77],
|
||||
'西雅图': [-122.33, 47.61],
|
||||
'芝加哥': [-87.63, 41.88],
|
||||
'波士顿': [-71.06, 42.36],
|
||||
'华盛顿': [-77.04, 38.91],
|
||||
'迈阿密': [-80.19, 25.76],
|
||||
'休斯顿': [-95.37, 29.76],
|
||||
'达拉斯': [-96.80, 32.78],
|
||||
'费城': [-75.17, 39.95],
|
||||
'凤凰城': [-112.07, 33.45],
|
||||
'圣安东尼奥': [-98.49, 29.42],
|
||||
'圣地亚哥': [-117.16, 32.72],
|
||||
'丹佛': [-104.99, 39.74],
|
||||
'拉斯维加斯': [-115.14, 36.17],
|
||||
'波特兰': [-122.68, 45.52],
|
||||
'亚特兰大': [-84.39, 33.75],
|
||||
'底特律': [-83.05, 42.33],
|
||||
'明尼阿波利斯': [-93.27, 44.98],
|
||||
'奥斯汀': [-97.74, 30.27],
|
||||
'夏洛特': [-80.84, 35.23],
|
||||
'印第安纳波利斯': [-86.16, 39.77],
|
||||
'哥伦布': [-82.99, 39.96],
|
||||
'盐湖城': [-111.89, 40.76],
|
||||
'堪萨斯城': [-94.58, 39.10],
|
||||
'圣路易斯': [-90.20, 38.63],
|
||||
'匹兹堡': [-79.99, 40.44],
|
||||
'辛辛那提': [-84.51, 39.10],
|
||||
'克利夫兰': [-81.69, 41.50],
|
||||
'奥兰多': [-81.38, 28.54],
|
||||
'坦帕': [-82.46, 27.95],
|
||||
'新奥尔良': [-90.07, 29.95],
|
||||
'纳什维尔': [-86.78, 36.16],
|
||||
'孟菲斯': [-90.05, 35.15],
|
||||
'巴尔的摩': [-76.61, 39.29],
|
||||
'密尔沃基': [-87.91, 43.04],
|
||||
'阿尔伯克基': [-106.65, 35.08],
|
||||
'图森': [-110.93, 32.22],
|
||||
'檀香山': [-157.86, 21.31],
|
||||
'安克雷奇': [-149.90, 61.22],
|
||||
|
||||
// 美国各州首府
|
||||
'杰克逊': [-90.18, 32.30], // 密西西比州首府
|
||||
'蒙哥马利': [-86.30, 32.38], // 阿拉巴马州首府
|
||||
'小石城': [-92.29, 34.75], // 阿肯色州首府
|
||||
'萨克拉门托': [-121.49, 38.58], // 加州首府
|
||||
'哈特福德': [-72.68, 41.76], // 康涅狄格州首府
|
||||
'多佛': [-75.52, 39.16], // 特拉华州首府
|
||||
'塔拉哈西': [-84.28, 30.44], // 佛罗里达州首府
|
||||
'博伊西': [-116.20, 43.62], // 爱达荷州首府
|
||||
'斯普林菲尔德': [-89.64, 39.80], // 伊利诺伊州首府
|
||||
'得梅因': [-93.62, 41.59], // 艾奥瓦州首府
|
||||
'托皮卡': [-95.68, 39.05], // 堪萨斯州首府
|
||||
'法兰克福肯塔基': [-84.87, 38.20], // 肯塔基州首府
|
||||
'巴吞鲁日': [-91.15, 30.45], // 路易斯安那州首府
|
||||
'奥古斯塔': [-69.77, 44.31], // 缅因州首府
|
||||
'安纳波利斯': [-76.49, 38.98], // 马里兰州首府
|
||||
'兰辛': [-84.55, 42.73], // 密歇根州首府
|
||||
'圣保罗明尼苏达': [-93.09, 44.94], // 明尼苏达州首府
|
||||
'杰斐逊城': [-92.17, 38.58], // 密苏里州首府
|
||||
'海伦娜': [-112.03, 46.59], // 蒙大拿州首府
|
||||
'林肯': [-96.70, 40.81], // 内布拉斯加州首府
|
||||
'卡森城': [-119.77, 39.16], // 内华达州首府
|
||||
'康科德': [-71.54, 43.21], // 新罕布什尔州首府
|
||||
'特伦顿': [-74.76, 40.22], // 新泽西州首府
|
||||
'圣菲': [-105.94, 35.69], // 新墨西哥州首府
|
||||
'奥尔巴尼': [-73.76, 42.65], // 纽约州首府
|
||||
'罗利': [-78.64, 35.78], // 北卡罗来纳州首府
|
||||
'俾斯麦': [-100.79, 46.81], // 北达科他州首府
|
||||
'俄克拉荷马城': [-97.52, 35.47], // 俄克拉荷马州首府
|
||||
'塞勒姆': [-123.03, 44.94], // 俄勒冈州首府
|
||||
'哈里斯堡': [-76.88, 40.27], // 宾夕法尼亚州首府
|
||||
'普罗维登斯': [-71.41, 41.82], // 罗德岛州首府
|
||||
'哥伦比亚': [-81.03, 34.00], // 南卡罗来纳州首府
|
||||
'皮尔': [-100.35, 44.37], // 南达科他州首府
|
||||
'里士满': [-77.43, 37.54], // 弗吉尼亚州首府
|
||||
'奥林匹亚': [-122.90, 47.04], // 华盛顿州首府
|
||||
'查尔斯顿': [-81.63, 38.35], // 西弗吉尼亚州首府
|
||||
'麦迪逊': [-89.40, 43.07], // 威斯康星州首府
|
||||
'夏延': [-104.82, 41.14], // 怀俄明州首府
|
||||
|
||||
// 美国州名映射(使用州内最大城市)
|
||||
'密西西比': [-90.18, 32.30], // 杰克逊
|
||||
'密西西比州': [-90.18, 32.30],
|
||||
'Mississippi': [-90.18, 32.30],
|
||||
'加利福尼亚': [-118.24, 34.05], // 洛杉矶
|
||||
'加利福尼亚州': [-118.24, 34.05],
|
||||
'California': [-118.24, 34.05],
|
||||
'德克萨斯': [-95.37, 29.76], // 休斯顿
|
||||
'德克萨斯州': [-95.37, 29.76],
|
||||
'Texas': [-95.37, 29.76],
|
||||
'佛罗里达': [-80.19, 25.76], // 迈阿密
|
||||
'佛罗里达州': [-80.19, 25.76],
|
||||
'Florida': [-80.19, 25.76],
|
||||
'伊利诺伊': [-87.63, 41.88], // 芝加哥
|
||||
'伊利诺伊州': [-87.63, 41.88],
|
||||
'Illinois': [-87.63, 41.88],
|
||||
'宾夕法尼亚': [-75.17, 39.95], // 费城
|
||||
'宾夕法尼亚州': [-75.17, 39.95],
|
||||
'Pennsylvania': [-75.17, 39.95],
|
||||
'俄亥俄': [-82.99, 39.96], // 哥伦布
|
||||
'俄亥俄州': [-82.99, 39.96],
|
||||
'Ohio': [-82.99, 39.96],
|
||||
'乔治亚': [-84.39, 33.75], // 亚特兰大
|
||||
'乔治亚州': [-84.39, 33.75],
|
||||
'Georgia': [-84.39, 33.75],
|
||||
'北卡罗来纳': [-80.84, 35.23], // 夏洛特
|
||||
'北卡罗来纳州': [-80.84, 35.23],
|
||||
'North Carolina': [-80.84, 35.23],
|
||||
'密歇根': [-83.05, 42.33], // 底特律
|
||||
'密歇根州': [-83.05, 42.33],
|
||||
'Michigan': [-83.05, 42.33],
|
||||
'新泽西': [-74.01, 40.71], // 纽约附近
|
||||
'新泽西州': [-74.01, 40.71],
|
||||
'New Jersey': [-74.01, 40.71],
|
||||
'弗吉尼亚': [-77.43, 37.54], // 里士满
|
||||
'弗吉尼亚州': [-77.43, 37.54],
|
||||
'Virginia': [-77.43, 37.54],
|
||||
'华盛顿州': [-122.33, 47.61], // 西雅图
|
||||
'Washington': [-122.33, 47.61],
|
||||
'亚利桑那': [-112.07, 33.45], // 凤凰城
|
||||
'亚利桑那州': [-112.07, 33.45],
|
||||
'Arizona': [-112.07, 33.45],
|
||||
'马萨诸塞': [-71.06, 42.36], // 波士顿
|
||||
'马萨诸塞州': [-71.06, 42.36],
|
||||
'Massachusetts': [-71.06, 42.36],
|
||||
'田纳西': [-86.78, 36.16], // 纳什维尔
|
||||
'田纳西州': [-86.78, 36.16],
|
||||
'Tennessee': [-86.78, 36.16],
|
||||
'印第安纳': [-86.16, 39.77], // 印第安纳波利斯
|
||||
'印第安纳州': [-86.16, 39.77],
|
||||
'Indiana': [-86.16, 39.77],
|
||||
'马里兰': [-76.61, 39.29], // 巴尔的摩
|
||||
'马里兰州': [-76.61, 39.29],
|
||||
'Maryland': [-76.61, 39.29],
|
||||
'明尼苏达': [-93.27, 44.98], // 明尼阿波利斯
|
||||
'明尼苏达州': [-93.27, 44.98],
|
||||
'Minnesota': [-93.27, 44.98],
|
||||
'科罗拉多': [-104.99, 39.74], // 丹佛
|
||||
'科罗拉多州': [-104.99, 39.74],
|
||||
'Colorado': [-104.99, 39.74],
|
||||
'威斯康星': [-87.91, 43.04], // 密尔沃基
|
||||
'威斯康星州': [-87.91, 43.04],
|
||||
'Wisconsin': [-87.91, 43.04],
|
||||
'密苏里': [-90.20, 38.63], // 圣路易斯
|
||||
'密苏里州': [-90.20, 38.63],
|
||||
'Missouri': [-90.20, 38.63],
|
||||
'阿拉巴马': [-86.30, 32.38], // 蒙哥马利
|
||||
'阿拉巴马州': [-86.30, 32.38],
|
||||
'Alabama': [-86.30, 32.38],
|
||||
'路易斯安那': [-90.07, 29.95], // 新奥尔良
|
||||
'路易斯安那州': [-90.07, 29.95],
|
||||
'Louisiana': [-90.07, 29.95],
|
||||
'肯塔基': [-84.87, 38.20], // 法兰克福
|
||||
'肯塔基州': [-84.87, 38.20],
|
||||
'Kentucky': [-84.87, 38.20],
|
||||
'俄勒冈': [-122.68, 45.52], // 波特兰
|
||||
'俄勒冈州': [-122.68, 45.52],
|
||||
'Oregon': [-122.68, 45.52],
|
||||
'俄克拉荷马': [-97.52, 35.47], // 俄克拉荷马城
|
||||
'俄克拉荷马州': [-97.52, 35.47],
|
||||
'Oklahoma': [-97.52, 35.47],
|
||||
'康涅狄格': [-72.68, 41.76], // 哈特福德
|
||||
'康涅狄格州': [-72.68, 41.76],
|
||||
'Connecticut': [-72.68, 41.76],
|
||||
'犹他': [-111.89, 40.76], // 盐湖城
|
||||
'犹他州': [-111.89, 40.76],
|
||||
'Utah': [-111.89, 40.76],
|
||||
'内华达': [-115.14, 36.17], // 拉斯维加斯
|
||||
'内华达州': [-115.14, 36.17],
|
||||
'Nevada': [-115.14, 36.17],
|
||||
'阿肯色': [-92.29, 34.75], // 小石城
|
||||
'阿肯色州': [-92.29, 34.75],
|
||||
'Arkansas': [-92.29, 34.75],
|
||||
'爱荷华': [-93.62, 41.59], // 得梅因
|
||||
'爱荷华州': [-93.62, 41.59],
|
||||
'Iowa': [-93.62, 41.59],
|
||||
'堪萨斯': [-94.58, 39.10], // 堪萨斯城
|
||||
'堪萨斯州': [-94.58, 39.10],
|
||||
'Kansas': [-94.58, 39.10],
|
||||
'新墨西哥': [-106.65, 35.08], // 阿尔伯克基
|
||||
'新墨西哥州': [-106.65, 35.08],
|
||||
'New Mexico': [-106.65, 35.08],
|
||||
'内布拉斯加': [-96.70, 40.81], // 林肯
|
||||
'内布拉斯加州': [-96.70, 40.81],
|
||||
'Nebraska': [-96.70, 40.81],
|
||||
'夏威夷': [-157.86, 21.31], // 檀香山
|
||||
'夏威夷州': [-157.86, 21.31],
|
||||
'Hawaii': [-157.86, 21.31],
|
||||
'阿拉斯加': [-149.90, 61.22], // 安克雷奇
|
||||
'阿拉斯加州': [-149.90, 61.22],
|
||||
'Alaska': [-149.90, 61.22],
|
||||
|
||||
// 英文城市名
|
||||
'New York': [-74.01, 40.71],
|
||||
'Los Angeles': [-118.24, 34.05],
|
||||
'San Francisco': [-122.42, 37.77],
|
||||
'Seattle': [-122.33, 47.61],
|
||||
'Chicago': [-87.63, 41.88],
|
||||
'Boston': [-71.06, 42.36],
|
||||
'Washington DC': [-77.04, 38.91],
|
||||
'Miami': [-80.19, 25.76],
|
||||
'Houston': [-95.37, 29.76],
|
||||
'Dallas': [-96.80, 32.78],
|
||||
'Philadelphia': [-75.17, 39.95],
|
||||
'Phoenix': [-112.07, 33.45],
|
||||
'San Diego': [-117.16, 32.72],
|
||||
'Denver': [-104.99, 39.74],
|
||||
'Las Vegas': [-115.14, 36.17],
|
||||
'Portland': [-122.68, 45.52],
|
||||
'Atlanta': [-84.39, 33.75],
|
||||
'Detroit': [-83.05, 42.33],
|
||||
'Jackson': [-90.18, 32.30],
|
||||
|
||||
// 国际城市 - 加拿大
|
||||
'多伦多': [-79.38, 43.65],
|
||||
'温哥华': [-123.12, 49.28],
|
||||
'蒙特利尔': [-73.57, 45.50],
|
||||
'卡尔加里': [-114.07, 51.05],
|
||||
'渥太华': [-75.70, 45.42],
|
||||
'埃德蒙顿': [-113.49, 53.55],
|
||||
'Toronto': [-79.38, 43.65],
|
||||
'Vancouver': [-123.12, 49.28],
|
||||
'Montreal': [-73.57, 45.50],
|
||||
'Calgary': [-114.07, 51.05],
|
||||
'Ottawa': [-75.70, 45.42],
|
||||
|
||||
// 国际城市 - 欧洲
|
||||
'伦敦': [-0.13, 51.51],
|
||||
'巴黎': [2.35, 48.86],
|
||||
'柏林': [13.40, 52.52],
|
||||
'阿姆斯特丹': [4.90, 52.37],
|
||||
'罗马': [12.50, 41.90],
|
||||
'马德里': [-3.70, 40.42],
|
||||
'巴塞罗那': [2.17, 41.39],
|
||||
'维也纳': [16.37, 48.21],
|
||||
'布鲁塞尔': [4.35, 50.85],
|
||||
'苏黎世': [8.54, 47.38],
|
||||
'慕尼黑': [11.58, 48.14],
|
||||
'法兰克福德国': [8.68, 50.11],
|
||||
'米兰': [9.19, 45.46],
|
||||
'都柏林': [-6.26, 53.35],
|
||||
'斯德哥尔摩': [18.07, 59.33],
|
||||
'哥本哈根': [12.57, 55.68],
|
||||
'奥斯陆': [10.75, 59.91],
|
||||
'赫尔辛基': [24.94, 60.17],
|
||||
'华沙': [21.01, 52.23],
|
||||
'布拉格': [14.42, 50.08],
|
||||
'布达佩斯': [19.04, 47.50],
|
||||
'雅典': [23.73, 37.98],
|
||||
'里斯本': [-9.14, 38.72],
|
||||
'莫斯科': [37.62, 55.76],
|
||||
'圣彼得堡': [30.31, 59.94],
|
||||
'London': [-0.13, 51.51],
|
||||
'Paris': [2.35, 48.86],
|
||||
'Berlin': [13.40, 52.52],
|
||||
'Rome': [12.50, 41.90],
|
||||
'Madrid': [-3.70, 40.42],
|
||||
'Amsterdam': [4.90, 52.37],
|
||||
'Munich': [11.58, 48.14],
|
||||
'Frankfurt': [8.68, 50.11],
|
||||
|
||||
// 国际城市 - 亚洲其他
|
||||
'东京': [139.69, 35.69],
|
||||
'大阪': [135.50, 34.69],
|
||||
'京都': [135.77, 35.01],
|
||||
'新加坡': [103.82, 1.35],
|
||||
'首尔': [126.98, 37.57],
|
||||
'釜山': [129.04, 35.18],
|
||||
'曼谷': [100.50, 13.76],
|
||||
'吉隆坡': [101.69, 3.14],
|
||||
'雅加达': [106.85, -6.21],
|
||||
'马尼拉': [120.98, 14.60],
|
||||
'河内': [105.85, 21.03],
|
||||
'胡志明市': [106.63, 10.82],
|
||||
'新德里': [77.21, 28.61],
|
||||
'孟买': [72.88, 19.08],
|
||||
'班加罗尔': [77.59, 12.97],
|
||||
'迪拜': [55.27, 25.20],
|
||||
'特拉维夫': [34.78, 32.09],
|
||||
'香港': [114.17, 22.32],
|
||||
'澳门': [113.55, 22.20],
|
||||
'台北': [121.56, 25.04],
|
||||
'高雄': [120.31, 22.62],
|
||||
'Tokyo': [139.69, 35.69],
|
||||
'Osaka': [135.50, 34.69],
|
||||
'Singapore': [103.82, 1.35],
|
||||
'Seoul': [126.98, 37.57],
|
||||
'Bangkok': [100.50, 13.76],
|
||||
'Hong Kong': [114.17, 22.32],
|
||||
'Dubai': [55.27, 25.20],
|
||||
|
||||
// 国际城市 - 南美洲
|
||||
'圣保罗巴西': [-46.63, -23.55],
|
||||
'里约热内卢': [-43.17, -22.91],
|
||||
'布宜诺斯艾利斯': [-58.38, -34.60],
|
||||
'圣地亚哥智利': [-70.65, -33.45],
|
||||
'利马': [-77.03, -12.05],
|
||||
'波哥大': [-74.07, 4.71],
|
||||
'São Paulo': [-46.63, -23.55],
|
||||
'Rio de Janeiro': [-43.17, -22.91],
|
||||
'Buenos Aires': [-58.38, -34.60],
|
||||
|
||||
// 国际城市 - 非洲
|
||||
'开罗': [31.24, 30.04],
|
||||
'约翰内斯堡': [28.05, -26.20],
|
||||
'开普敦': [18.42, -33.93],
|
||||
'拉各斯': [3.39, 6.45],
|
||||
'内罗毕': [36.82, -1.29],
|
||||
'Cairo': [31.24, 30.04],
|
||||
'Johannesburg': [28.05, -26.20],
|
||||
'Cape Town': [18.42, -33.93],
|
||||
|
||||
// 国际城市 - 大洋洲
|
||||
'奥克兰': [174.76, -36.85],
|
||||
'惠灵顿': [174.78, -41.29],
|
||||
'Auckland': [174.76, -36.85],
|
||||
'Wellington': [174.78, -41.29]
|
||||
}
|
||||
|
||||
export default function TimelinePage() {
|
||||
@@ -263,31 +599,59 @@ export default function TimelinePage() {
|
||||
|
||||
// -- Migration Logic --
|
||||
// Find moves between generations (Father -> Child location change)
|
||||
const migrations = useMemo(() => members
|
||||
.flatMap((m) => {
|
||||
if (!m.fatherId) return []
|
||||
const father = getMember(m.fatherId)
|
||||
if (!father) return []
|
||||
// 也考虑同一个人的出生地和现居地变化
|
||||
const migrations = useMemo(() => {
|
||||
const result: Array<{
|
||||
year: number | null
|
||||
from: string
|
||||
to: string
|
||||
person: typeof members[0]
|
||||
father: typeof members[0]
|
||||
}> = []
|
||||
|
||||
// 1. 父子之间的迁徙(出生地变化)
|
||||
members.forEach((m) => {
|
||||
if (m.fatherId) {
|
||||
const father = getMember(m.fatherId)
|
||||
if (father) {
|
||||
// 优先使用 birthPlace(出生地),其次 ancestralHome(祖籍)
|
||||
const loc1 = father.birthPlace || father.ancestralHome
|
||||
const loc2 = m.birthPlace || m.ancestralHome
|
||||
|
||||
// Check if location exists and is different
|
||||
// 优先使用 birthPlace(出生地)来判断迁徙,因为 ancestralHome(祖籍)通常不变
|
||||
const loc1 = father.birthPlace || father.ancestralHome
|
||||
const loc2 = m.birthPlace || m.ancestralHome
|
||||
|
||||
if (loc1 && loc2 && loc1 !== loc2) {
|
||||
return [
|
||||
{
|
||||
year: m.birthDate ? Number.parseInt(m.birthDate.substring(0, 4)) : null,
|
||||
from: loc1,
|
||||
to: loc2,
|
||||
person: m,
|
||||
father: father,
|
||||
},
|
||||
]
|
||||
if (loc1 && loc2 && loc1 !== loc2) {
|
||||
result.push({
|
||||
year: m.birthDate ? Number.parseInt(m.birthDate.substring(0, 4)) : null,
|
||||
from: loc1,
|
||||
to: loc2,
|
||||
person: m,
|
||||
father: father,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 同一个人的出生地到现居地的迁徙
|
||||
const birthLoc = m.birthPlace || m.ancestralHome
|
||||
const currentLoc = m.address
|
||||
if (birthLoc && currentLoc && birthLoc !== currentLoc) {
|
||||
// 避免重复:检查是否已经有相同的迁徙记录
|
||||
const exists = result.some(r =>
|
||||
r.person.id === m.id && r.from === birthLoc && r.to === currentLoc
|
||||
)
|
||||
if (!exists) {
|
||||
result.push({
|
||||
year: m.birthDate ? Number.parseInt(m.birthDate.substring(0, 4)) + 20 : null, // 假设20岁左右迁徙
|
||||
from: birthLoc,
|
||||
to: currentLoc,
|
||||
person: m,
|
||||
father: m, // 自己迁徙,father 指向自己
|
||||
})
|
||||
}
|
||||
}
|
||||
return []
|
||||
})
|
||||
.sort((a, b) => (a.year || 0) - (b.year || 0)), [members, getMember])
|
||||
|
||||
return result.sort((a, b) => (a.year || 0) - (b.year || 0))
|
||||
}, [members, getMember])
|
||||
|
||||
// ECharts 地图配置
|
||||
const mapOption = useMemo(() => {
|
||||
@@ -343,15 +707,52 @@ export default function TimelinePage() {
|
||||
})
|
||||
})
|
||||
|
||||
// 国家/地区到默认城市的映射
|
||||
const countryToCityMap: Record<string, string> = {
|
||||
'澳大利亚': '悉尼',
|
||||
'澳洲': '悉尼',
|
||||
'Australia': '悉尼',
|
||||
'美国': '纽约',
|
||||
'USA': '纽约',
|
||||
'加拿大': '多伦多',
|
||||
'Canada': '多伦多',
|
||||
'英国': '伦敦',
|
||||
'UK': '伦敦',
|
||||
'法国': '巴黎',
|
||||
'France': '巴黎',
|
||||
'德国': '柏林',
|
||||
'Germany': '柏林',
|
||||
'日本': '东京',
|
||||
'Japan': '东京',
|
||||
'韩国': '首尔',
|
||||
'Korea': '首尔',
|
||||
'新加坡': '新加坡',
|
||||
'Singapore': '新加坡',
|
||||
'马来西亚': '吉隆坡',
|
||||
'Malaysia': '吉隆坡',
|
||||
'泰国': '曼谷',
|
||||
'Thailand': '曼谷',
|
||||
}
|
||||
|
||||
// 提取城市名称的辅助函数(去掉区的部分)
|
||||
const extractCity = (location: string): string => {
|
||||
// 尝试从 cityMap 中查找
|
||||
if (!location) return location
|
||||
|
||||
// 1. 直接匹配 geoCoordMap
|
||||
if (geoCoordMap[location]) return location
|
||||
|
||||
// 2. 尝试从 cityMap 中查找
|
||||
let city = cityMap[location]
|
||||
if (city && geoCoordMap[city]) return city
|
||||
|
||||
// 如果没找到,尝试提取省市部分(去掉区)
|
||||
// 3. 国家/地区映射到默认城市
|
||||
const countryCity = countryToCityMap[location]
|
||||
if (countryCity && geoCoordMap[countryCity]) return countryCity
|
||||
|
||||
// 4. 尝试提取省市部分(去掉区)
|
||||
// 例如:"山东省济南市历下区" -> "济南"
|
||||
const match = location.match(/(.+?省)?(.+?市)/)
|
||||
// 例如:"陕西西安" -> "西安"
|
||||
const match = location.match(/(.+?省)?(.+?市)?/)
|
||||
if (match) {
|
||||
const province = match[1] || ''
|
||||
const cityName = match[2] || ''
|
||||
@@ -370,6 +771,13 @@ export default function TimelinePage() {
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 尝试直接在地址中查找已知城市名
|
||||
for (const knownCity of Object.keys(geoCoordMap)) {
|
||||
if (location.includes(knownCity)) {
|
||||
return knownCity
|
||||
}
|
||||
}
|
||||
|
||||
return location
|
||||
}
|
||||
|
||||
@@ -410,6 +818,14 @@ export default function TimelinePage() {
|
||||
cityData[to] = (cityData[to] || 0) + 1
|
||||
})
|
||||
|
||||
// 检测是否有国际城市(经度超出中国范围或纬度为负)
|
||||
const hasInternational = lines.some((line: any) => {
|
||||
if (!line) return false
|
||||
const [from, to] = line.coords
|
||||
return from[0] < 70 || from[0] > 140 || from[1] < 0 ||
|
||||
to[0] < 70 || to[0] > 140 || to[1] < 0
|
||||
})
|
||||
|
||||
const scatterData = Object.entries(cityData).map(([name, value]) => {
|
||||
const coord = geoCoordMap[name]
|
||||
if (!coord) return null
|
||||
@@ -419,11 +835,114 @@ export default function TimelinePage() {
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
// 如果有国际城市,使用世界地图模式(不依赖 geo map)
|
||||
if (hasInternational) {
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '家族迁徙路径',
|
||||
subtext: '包含国际迁徙 (滚轮缩放,拖拽移动)',
|
||||
left: 'center',
|
||||
textStyle: { color: '#333', fontSize: 16 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (params: any) => {
|
||||
if (params.seriesType === 'lines') {
|
||||
return `${params.data.fromName} → ${params.data.toName}`
|
||||
}
|
||||
return params.name
|
||||
}
|
||||
},
|
||||
// 添加缩放和拖拽功能
|
||||
dataZoom: [
|
||||
{
|
||||
type: 'inside',
|
||||
xAxisIndex: 0,
|
||||
filterMode: 'none'
|
||||
},
|
||||
{
|
||||
type: 'inside',
|
||||
yAxisIndex: 0,
|
||||
filterMode: 'none'
|
||||
}
|
||||
],
|
||||
xAxis: {
|
||||
type: 'value',
|
||||
min: -180,
|
||||
max: 180,
|
||||
show: false
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: -60,
|
||||
max: 70,
|
||||
show: false
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'lines',
|
||||
coordinateSystem: 'cartesian2d',
|
||||
data: lines.map((line: any) => ({
|
||||
...line,
|
||||
coords: line.coords
|
||||
})),
|
||||
lineStyle: {
|
||||
color: '#3b82f6',
|
||||
width: 2,
|
||||
opacity: 0.6,
|
||||
curveness: 0.3
|
||||
},
|
||||
effect: {
|
||||
show: true,
|
||||
period: 4,
|
||||
trailLength: 0.3,
|
||||
symbol: 'arrow',
|
||||
symbolSize: 10,
|
||||
color: '#3b82f6'
|
||||
},
|
||||
zlevel: 1
|
||||
},
|
||||
{
|
||||
type: 'scatter',
|
||||
coordinateSystem: 'cartesian2d',
|
||||
data: scatterData.map((item: any) => ({
|
||||
name: item.name,
|
||||
value: [item.value[0], item.value[1]]
|
||||
})),
|
||||
symbolSize: 12,
|
||||
label: {
|
||||
show: true,
|
||||
formatter: '{b}',
|
||||
position: 'right',
|
||||
fontSize: 11,
|
||||
color: '#333'
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#ef4444',
|
||||
shadowBlur: 5,
|
||||
shadowColor: 'rgba(0,0,0,0.3)'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// 仅中国城市,使用中国地图
|
||||
return {
|
||||
backgroundColor: 'transparent',
|
||||
title: {
|
||||
text: '家族迁徙路径',
|
||||
subtext: '滚轮缩放,拖拽移动',
|
||||
left: 'center',
|
||||
textStyle: { color: '#333', fontSize: 16 }
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
geo: {
|
||||
map: 'china',
|
||||
roam: true,
|
||||
roam: true, // 支持缩放和拖拽
|
||||
label: {
|
||||
show: true,
|
||||
color: '#666',
|
||||
@@ -726,30 +1245,37 @@ export default function TimelinePage() {
|
||||
<ChineseCardContent>
|
||||
<ScrollArea className="h-[600px] pr-4">
|
||||
<div className="space-y-6">
|
||||
{migrations.map((mig, idx) => (
|
||||
{migrations.map((mig, idx) => {
|
||||
const isSelfMigration = mig.father.id === mig.person.id
|
||||
return (
|
||||
<div key={idx} className="flex flex-col gap-2 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
|
||||
<span className="font-mono bg-background px-1 rounded">
|
||||
{mig.year ? `${mig.year}年` : "未知年代"}
|
||||
</span>
|
||||
<span>第 {mig.person.generation} 世</span>
|
||||
{isSelfMigration && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-1.5 py-0.5 rounded">个人迁徙</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
|
||||
<div className="text-xs text-muted-foreground mb-1">迁出地</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{isSelfMigration ? '出生地' : '迁出地'}</div>
|
||||
<div className="font-bold">{mig.from}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<MemberNameWithStatus
|
||||
name={mig.father.fullName}
|
||||
isDead={!!mig.father.deathDate}
|
||||
memberId={mig.father.id}
|
||||
treeId={treeId}
|
||||
/>
|
||||
</div>
|
||||
{!isSelfMigration && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<MemberNameWithStatus
|
||||
name={mig.father.fullName}
|
||||
isDead={!!mig.father.deathDate}
|
||||
memberId={mig.father.id}
|
||||
treeId={treeId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ArrowRight className="text-muted-foreground" />
|
||||
<div className="flex-1 text-center p-2 bg-background rounded border border-border/50">
|
||||
<div className="text-xs text-muted-foreground mb-1">迁入地</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{isSelfMigration ? '现居地' : '迁入地'}</div>
|
||||
<div className="font-bold text-primary">{mig.to}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
<MemberNameWithStatus
|
||||
@@ -762,7 +1288,7 @@ export default function TimelinePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)})}
|
||||
{migrations.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-sm text-foreground font-light tracking-wide">足迹未显</p>
|
||||
|
||||
@@ -24,12 +24,20 @@ export async function GET(
|
||||
// 根据文件扩展名设置 Content-Type
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
// 图片
|
||||
'jpg': 'image/jpeg',
|
||||
'jpeg': 'image/jpeg',
|
||||
'png': 'image/png',
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'svg': 'image/svg+xml',
|
||||
// 视频
|
||||
'mp4': 'video/mp4',
|
||||
'webm': 'video/webm',
|
||||
'ogg': 'video/ogg',
|
||||
'mov': 'video/quicktime',
|
||||
'avi': 'video/x-msvideo',
|
||||
'mkv': 'video/x-matroska',
|
||||
}
|
||||
|
||||
const contentType = contentTypeMap[ext || ''] || 'application/octet-stream'
|
||||
|
||||
Reference in New Issue
Block a user