1079 lines
35 KiB
TypeScript
1079 lines
35 KiB
TypeScript
import { jsPDF } from 'jspdf'
|
||
import type { FamilyMember } from '@/types/family'
|
||
|
||
// 中文字体需要特殊处理,这里使用基本的方式
|
||
// jsPDF 默认不支持中文,需要添加中文字体
|
||
|
||
interface MemberInfo {
|
||
label: string
|
||
value: string
|
||
}
|
||
|
||
// 格式化日期
|
||
function formatDate(dateStr?: string): string {
|
||
if (!dateStr) return ''
|
||
try {
|
||
const date = new Date(dateStr)
|
||
if (isNaN(date.getTime())) return dateStr
|
||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||
} catch {
|
||
return dateStr
|
||
}
|
||
}
|
||
|
||
// 计算年龄
|
||
function calculateAge(birthDate?: string, deathDate?: string): string {
|
||
if (!birthDate) return ''
|
||
try {
|
||
const birth = new Date(birthDate)
|
||
const end = deathDate ? new Date(deathDate) : new Date()
|
||
if (isNaN(birth.getTime())) return ''
|
||
|
||
let age = end.getFullYear() - birth.getFullYear()
|
||
const monthDiff = end.getMonth() - birth.getMonth()
|
||
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
|
||
age--
|
||
}
|
||
|
||
if (deathDate) {
|
||
return `享年${age}岁`
|
||
}
|
||
return `${age}岁`
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// 获取成员主要信息
|
||
function getMemberInfo(member: FamilyMember, getMemberName: (id: string) => string): MemberInfo[] {
|
||
const info: MemberInfo[] = []
|
||
|
||
// 基本信息
|
||
info.push({ label: '姓名', value: member.fullName || '' })
|
||
info.push({ label: '性别', value: member.gender === 'MALE' ? '男' : member.gender === 'FEMALE' ? '女' : '' })
|
||
info.push({ label: '世代', value: member.generation ? `第${member.generation}世` : '' })
|
||
info.push({ label: '排行', value: member.rank ? `第${member.rank}` : '' })
|
||
|
||
// 字辈名号
|
||
info.push({ label: '字辈', value: member.generationName || '' })
|
||
info.push({ label: '字', value: member.courtesyName || '' })
|
||
info.push({ label: '号', value: member.artName || '' })
|
||
|
||
// 日期
|
||
info.push({ label: '出生日期', value: formatDate(member.birthDate) })
|
||
info.push({ label: '逝世日期', value: formatDate(member.deathDate) })
|
||
info.push({ label: '年龄', value: calculateAge(member.birthDate, member.deathDate) })
|
||
|
||
// 地点
|
||
info.push({ label: '籍贯', value: member.ancestralHome || '' })
|
||
info.push({ label: '出生地', value: member.birthPlace || '' })
|
||
info.push({ label: '现居地', value: member.address || '' })
|
||
info.push({ label: '墓地', value: member.burialPlace || '' })
|
||
|
||
// 联系方式
|
||
info.push({ label: '手机', value: member.phone || '' })
|
||
info.push({ label: '电话', value: member.telephone || '' })
|
||
info.push({ label: '邮箱', value: member.email || '' })
|
||
|
||
// 职业
|
||
info.push({ label: '职业', value: member.occupation || '' })
|
||
|
||
// 家庭关系
|
||
const fatherName = member.fatherId ? getMemberName(member.fatherId) : ''
|
||
const motherName = member.motherId ? getMemberName(member.motherId) : ''
|
||
const spouseNames = member.spouseIds?.map(id => getMemberName(id)).filter(Boolean).join('、') || ''
|
||
const childrenNames = member.childrenIds?.map(id => getMemberName(id)).filter(Boolean).join('、') || ''
|
||
|
||
info.push({ label: '父亲', value: fatherName })
|
||
info.push({ label: '母亲', value: motherName })
|
||
info.push({ label: '配偶', value: spouseNames })
|
||
info.push({ label: '子女', value: childrenNames })
|
||
|
||
return info
|
||
}
|
||
|
||
// 按世代分组成员
|
||
function groupMembersByGeneration(members: FamilyMember[]): Map<number, FamilyMember[]> {
|
||
const groups = new Map<number, FamilyMember[]>()
|
||
|
||
members.forEach(member => {
|
||
const gen = member.generation || 0
|
||
if (!groups.has(gen)) {
|
||
groups.set(gen, [])
|
||
}
|
||
groups.get(gen)!.push(member)
|
||
})
|
||
|
||
// 按世代排序
|
||
return new Map([...groups.entries()].sort((a, b) => a[0] - b[0]))
|
||
}
|
||
|
||
// 导出 PDF
|
||
export async function exportFamilyPDF(
|
||
members: FamilyMember[],
|
||
getMemberName: (id: string) => string,
|
||
treeName: string = '家族'
|
||
): Promise<void> {
|
||
// 创建 PDF,A4 纸张
|
||
const doc = new jsPDF({
|
||
orientation: 'portrait',
|
||
unit: 'mm',
|
||
format: 'a4'
|
||
})
|
||
|
||
// 页面尺寸
|
||
const pageWidth = 210
|
||
const pageHeight = 297
|
||
const margin = 15
|
||
const contentWidth = pageWidth - margin * 2
|
||
const halfWidth = contentWidth / 2 - 5
|
||
|
||
// 按世代分组
|
||
const groupedMembers = groupMembersByGeneration(members)
|
||
|
||
let isFirstPage = true
|
||
|
||
// 遍历每个世代
|
||
for (const [generation, genMembers] of groupedMembers) {
|
||
// 对同世代成员按排行排序
|
||
genMembers.sort((a, b) => (a.rank || 999) - (b.rank || 999))
|
||
|
||
// 每页两个成员
|
||
for (let i = 0; i < genMembers.length; i += 2) {
|
||
if (!isFirstPage) {
|
||
doc.addPage()
|
||
}
|
||
isFirstPage = false
|
||
|
||
// 页眉
|
||
doc.setFontSize(12)
|
||
doc.setTextColor(100)
|
||
doc.text(`${treeName}族谱 - 第${generation}世`, pageWidth / 2, 10, { align: 'center' })
|
||
|
||
// 绘制分隔线
|
||
doc.setDrawColor(200)
|
||
doc.line(margin, 15, pageWidth - margin, 15)
|
||
|
||
// 左侧成员
|
||
const leftMember = genMembers[i]
|
||
drawMemberCard(doc, leftMember, getMemberName, margin, 20, halfWidth)
|
||
|
||
// 右侧成员(如果有)
|
||
if (i + 1 < genMembers.length) {
|
||
const rightMember = genMembers[i + 1]
|
||
drawMemberCard(doc, rightMember, getMemberName, margin + halfWidth + 10, 20, halfWidth)
|
||
}
|
||
|
||
// 中间分隔线
|
||
doc.setDrawColor(220)
|
||
doc.setLineDashPattern([2, 2], 0)
|
||
doc.line(pageWidth / 2, 20, pageWidth / 2, pageHeight - 15)
|
||
doc.setLineDashPattern([], 0)
|
||
|
||
// 页脚
|
||
doc.setFontSize(10)
|
||
doc.setTextColor(150)
|
||
doc.text(`第 ${doc.getNumberOfPages()} 页`, pageWidth / 2, pageHeight - 8, { align: 'center' })
|
||
}
|
||
}
|
||
|
||
// 保存 PDF
|
||
const fileName = `${treeName}族谱_${new Date().toISOString().split('T')[0]}.pdf`
|
||
doc.save(fileName)
|
||
}
|
||
|
||
// 绘制成员卡片
|
||
function drawMemberCard(
|
||
doc: jsPDF,
|
||
member: FamilyMember,
|
||
getMemberName: (id: string) => string,
|
||
x: number,
|
||
y: number,
|
||
width: number
|
||
): void {
|
||
const info = getMemberInfo(member, getMemberName)
|
||
const lineHeight = 8
|
||
const labelWidth = 25
|
||
|
||
// 成员姓名标题
|
||
doc.setFontSize(14)
|
||
doc.setTextColor(0)
|
||
doc.setFont('helvetica', 'bold')
|
||
doc.text(member.fullName || '未知', x + width / 2, y + 5, { align: 'center' })
|
||
|
||
// 状态标签
|
||
const status = member.deathDate ? '(已故)' : '(在世)'
|
||
doc.setFontSize(10)
|
||
doc.setTextColor(100)
|
||
doc.text(status, x + width / 2, y + 12, { align: 'center' })
|
||
|
||
// 分隔线
|
||
doc.setDrawColor(200)
|
||
doc.line(x, y + 16, x + width, y + 16)
|
||
|
||
// 信息列表
|
||
doc.setFont('helvetica', 'normal')
|
||
doc.setFontSize(9)
|
||
|
||
let currentY = y + 24
|
||
|
||
info.forEach(item => {
|
||
// 跳过姓名(已在标题显示)
|
||
if (item.label === '姓名') return
|
||
|
||
// 标签
|
||
doc.setTextColor(80)
|
||
doc.text(`${item.label}:`, x + 2, currentY)
|
||
|
||
// 值
|
||
doc.setTextColor(0)
|
||
const value = item.value || '-'
|
||
// 处理长文本换行
|
||
const maxValueWidth = width - labelWidth - 5
|
||
const lines = doc.splitTextToSize(value, maxValueWidth)
|
||
doc.text(lines[0] || '-', x + labelWidth, currentY)
|
||
|
||
// 如果有多行,继续绘制
|
||
if (lines.length > 1) {
|
||
for (let i = 1; i < Math.min(lines.length, 2); i++) {
|
||
currentY += lineHeight - 2
|
||
doc.text(lines[i], x + labelWidth, currentY)
|
||
}
|
||
}
|
||
|
||
currentY += lineHeight
|
||
})
|
||
}
|
||
|
||
// 定义页面数据结构
|
||
interface PageData {
|
||
leftMember: FamilyMember
|
||
leftGeneration: number
|
||
rightMembers: FamilyMember[] // 配偶列表(可能多个,上下排列)
|
||
rightGeneration: number
|
||
}
|
||
|
||
// 获取完整的图片 URL
|
||
function getFullImageUrl(url: string | undefined, baseUrl: string): string {
|
||
if (!url) return ''
|
||
// 如果已经是完整 URL,直接返回
|
||
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) {
|
||
return url
|
||
}
|
||
// 相对路径,拼接基础 URL
|
||
return `${baseUrl}${url.startsWith('/') ? '' : '/'}${url}`
|
||
}
|
||
|
||
// 导出为 HTML 格式(支持中文,夫妻同页)
|
||
export async function exportFamilyHTML(
|
||
members: FamilyMember[],
|
||
getMemberName: (id: string) => string,
|
||
treeName: string = '家族',
|
||
baseUrl: string = ''
|
||
): Promise<void> {
|
||
const groupedMembers = groupMembersByGeneration(members)
|
||
const membersMap = new Map<string, FamilyMember>()
|
||
members.forEach(m => membersMap.set(m.id, m))
|
||
|
||
// 已处理的成员ID(避免配偶重复出现)
|
||
const processedIds = new Set<string>()
|
||
|
||
// 构建页面数据:每页一对夫妻
|
||
const pages: PageData[] = []
|
||
|
||
for (const [generation, genMembers] of groupedMembers) {
|
||
// 按排行排序
|
||
genMembers.sort((a, b) => (a.rank || 999) - (b.rank || 999))
|
||
|
||
for (const member of genMembers) {
|
||
// 跳过已处理的成员(作为配偶已出现)
|
||
if (processedIds.has(member.id)) continue
|
||
|
||
processedIds.add(member.id)
|
||
|
||
// 获取配偶列表
|
||
const spouses: FamilyMember[] = []
|
||
if (member.spouseIds && member.spouseIds.length > 0) {
|
||
for (const spouseId of member.spouseIds) {
|
||
const spouse = membersMap.get(spouseId)
|
||
if (spouse && !processedIds.has(spouseId)) {
|
||
spouses.push(spouse)
|
||
processedIds.add(spouseId)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 获取配偶的世代(通常与主成员相同)
|
||
const spouseGeneration = spouses.length > 0 ? (spouses[0].generation || generation) : generation
|
||
|
||
pages.push({
|
||
leftMember: member,
|
||
leftGeneration: generation,
|
||
rightMembers: spouses,
|
||
rightGeneration: spouseGeneration
|
||
})
|
||
}
|
||
}
|
||
|
||
let html = `
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<title>${treeName}族谱</title>
|
||
<style>
|
||
@page {
|
||
size: A4;
|
||
margin: 10mm;
|
||
}
|
||
@media print {
|
||
.page { page-break-after: always; }
|
||
.page:last-child { page-break-after: avoid; }
|
||
.no-print { display: none !important; }
|
||
}
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
body {
|
||
font-family: "SimSun", "宋体", "Microsoft YaHei", serif;
|
||
font-size: 11pt;
|
||
line-height: 1.4;
|
||
color: #333;
|
||
background: #f5f5f5;
|
||
}
|
||
.page {
|
||
width: 210mm;
|
||
min-height: 297mm;
|
||
padding: 10mm;
|
||
margin: 0 auto 10px;
|
||
background: white;
|
||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.page-header {
|
||
text-align: center;
|
||
border-bottom: 2px solid #8b4513;
|
||
padding-bottom: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.page-header h1 {
|
||
color: #8b4513;
|
||
font-size: 18pt;
|
||
margin-bottom: 3px;
|
||
}
|
||
.page-header .subtitle {
|
||
font-size: 10pt;
|
||
color: #666;
|
||
}
|
||
.members-container {
|
||
flex: 1;
|
||
display: flex;
|
||
gap: 10mm;
|
||
}
|
||
.member-card {
|
||
flex: 1;
|
||
border: 2px solid #d4c4a8;
|
||
background: linear-gradient(to bottom, #fefcf8, #f9f5ef);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
.member-card.empty {
|
||
border: 2px dashed #e0d8c8;
|
||
background: #fafafa;
|
||
}
|
||
.member-header {
|
||
background: linear-gradient(to right, #8b4513, #a0522d);
|
||
color: white;
|
||
padding: 8px 12px;
|
||
text-align: center;
|
||
}
|
||
.member-name {
|
||
font-size: 16pt;
|
||
font-weight: bold;
|
||
letter-spacing: 2px;
|
||
}
|
||
.member-gen {
|
||
font-size: 10pt;
|
||
opacity: 0.9;
|
||
margin-top: 2px;
|
||
}
|
||
.member-body {
|
||
flex: 1;
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.avatar-section {
|
||
text-align: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.avatar {
|
||
width: 80px;
|
||
height: 80px;
|
||
border: 2px solid #8b4513;
|
||
object-fit: cover;
|
||
background: #f0e6d3;
|
||
}
|
||
.avatar-placeholder {
|
||
width: 80px;
|
||
height: 80px;
|
||
border: 2px solid #d4c4a8;
|
||
background: #f0e6d3;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #8b4513;
|
||
font-size: 10pt;
|
||
}
|
||
.status-badge {
|
||
display: inline-block;
|
||
padding: 2px 10px;
|
||
font-size: 9pt;
|
||
border-radius: 10px;
|
||
margin-top: 5px;
|
||
}
|
||
.status-living {
|
||
background: #d4edda;
|
||
color: #155724;
|
||
}
|
||
.status-deceased {
|
||
background: #e2e3e5;
|
||
color: #383d41;
|
||
}
|
||
.info-section {
|
||
flex: 1;
|
||
}
|
||
.info-group {
|
||
margin-bottom: 8px;
|
||
padding-bottom: 8px;
|
||
border-bottom: 1px dashed #e0d8c8;
|
||
}
|
||
.info-group:last-child {
|
||
border-bottom: none;
|
||
margin-bottom: 0;
|
||
}
|
||
.info-group-title {
|
||
font-size: 9pt;
|
||
color: #8b4513;
|
||
font-weight: bold;
|
||
margin-bottom: 4px;
|
||
padding-left: 5px;
|
||
border-left: 3px solid #8b4513;
|
||
}
|
||
.info-row {
|
||
display: flex;
|
||
margin-bottom: 3px;
|
||
font-size: 10pt;
|
||
}
|
||
.info-label {
|
||
width: 55px;
|
||
color: #666;
|
||
flex-shrink: 0;
|
||
}
|
||
.info-value {
|
||
flex: 1;
|
||
color: #333;
|
||
word-break: break-all;
|
||
}
|
||
.info-value.empty {
|
||
color: #ccc;
|
||
}
|
||
.page-footer {
|
||
text-align: center;
|
||
font-size: 9pt;
|
||
color: #999;
|
||
padding-top: 5px;
|
||
border-top: 1px solid #e0d8c8;
|
||
margin-top: 10px;
|
||
}
|
||
.print-btn {
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
padding: 12px 24px;
|
||
background: #8b4513;
|
||
color: white;
|
||
border: none;
|
||
cursor: pointer;
|
||
font-size: 14pt;
|
||
border-radius: 5px;
|
||
}
|
||
.print-btn:hover {
|
||
background: #6b3410;
|
||
}
|
||
.spouse-column {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 5mm;
|
||
}
|
||
.spouse-card {
|
||
flex: 1;
|
||
border: 2px dashed #d4c4a8;
|
||
background: linear-gradient(to bottom, #fefcf8, #f9f5ef);
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 0;
|
||
}
|
||
.spouse-card .member-header {
|
||
background: linear-gradient(to right, #a0522d, #cd853f);
|
||
}
|
||
.spouse-label {
|
||
font-size: 8pt;
|
||
color: #8b4513;
|
||
text-align: center;
|
||
padding: 2px;
|
||
background: #f5f0e6;
|
||
}
|
||
.couple-card {
|
||
flex: 1;
|
||
border: 3px solid #8b4513;
|
||
background: linear-gradient(to bottom, #fefcf8, #f9f5ef);
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.couple-header {
|
||
background: linear-gradient(to right, #8b4513, #a0522d, #8b4513);
|
||
color: white;
|
||
padding: 10px 20px;
|
||
text-align: center;
|
||
font-size: 16pt;
|
||
font-weight: bold;
|
||
letter-spacing: 3px;
|
||
}
|
||
.couple-body {
|
||
flex: 1;
|
||
display: flex;
|
||
padding: 15px;
|
||
gap: 0;
|
||
}
|
||
.couple-divider {
|
||
width: 2px;
|
||
background: linear-gradient(to bottom, transparent, #d4c4a8, #8b4513, #d4c4a8, transparent);
|
||
margin: 10px 15px;
|
||
}
|
||
.person-card {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.person-card.empty {
|
||
justify-content: center;
|
||
align-items: center;
|
||
color: #999;
|
||
}
|
||
.person-header {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 15px;
|
||
margin-bottom: 10px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px dashed #d4c4a8;
|
||
}
|
||
.person-avatar {
|
||
width: 70px;
|
||
height: 70px;
|
||
border: 2px solid #8b4513;
|
||
object-fit: cover;
|
||
background: #f0e6d3;
|
||
flex-shrink: 0;
|
||
}
|
||
.person-avatar-placeholder {
|
||
width: 70px;
|
||
height: 70px;
|
||
border: 2px solid #d4c4a8;
|
||
background: #f0e6d3;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #8b4513;
|
||
font-size: 24pt;
|
||
flex-shrink: 0;
|
||
}
|
||
.person-title {
|
||
flex: 1;
|
||
}
|
||
.person-name {
|
||
font-size: 18pt;
|
||
font-weight: bold;
|
||
color: #8b4513;
|
||
letter-spacing: 2px;
|
||
}
|
||
.person-meta {
|
||
font-size: 10pt;
|
||
color: #666;
|
||
margin-top: 5px;
|
||
}
|
||
.person-info {
|
||
flex: 1;
|
||
}
|
||
.children-section {
|
||
background: #f5f0e6;
|
||
padding: 10px 20px;
|
||
border-top: 2px solid #d4c4a8;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
}
|
||
.children-label {
|
||
font-weight: bold;
|
||
color: #8b4513;
|
||
flex-shrink: 0;
|
||
}
|
||
.children-names {
|
||
color: #333;
|
||
line-height: 1.6;
|
||
}
|
||
.spouses-container {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.spouse-label-tag {
|
||
font-size: 9pt;
|
||
color: #8b4513;
|
||
background: #f5f0e6;
|
||
padding: 2px 8px;
|
||
border-radius: 3px;
|
||
margin-bottom: 5px;
|
||
display: inline-block;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<button class="print-btn no-print" onclick="window.print()">打印 / 保存PDF</button>
|
||
`
|
||
|
||
// 遍历每一页(每页一对夫妻)
|
||
const totalPages = pages.length
|
||
|
||
for (let pageIndex = 0; pageIndex < totalPages; pageIndex++) {
|
||
const pageData = pages[pageIndex]
|
||
|
||
// 获取子女名单(夫妻共用)
|
||
const childrenNames = pageData.leftMember.childrenIds?.map(id => getMemberName(id)).filter(Boolean).join('、') || ''
|
||
|
||
html += `
|
||
<div class="page">
|
||
<div class="page-header">
|
||
<h1>${treeName}族谱</h1>
|
||
<div class="subtitle">导出日期:${new Date().toLocaleDateString('zh-CN')}</div>
|
||
</div>
|
||
|
||
<div class="couple-card">
|
||
<div class="couple-header">
|
||
<span>第${pageData.leftGeneration}世</span>
|
||
</div>
|
||
<div class="couple-body">
|
||
${renderPersonCard(pageData.leftMember, getMemberName, baseUrl, true)}
|
||
<div class="couple-divider"></div>
|
||
${pageData.rightMembers.length > 0
|
||
? renderSpousesCards(pageData.rightMembers, getMemberName, baseUrl)
|
||
: '<div class="person-card empty"></div>'
|
||
}
|
||
</div>
|
||
${childrenNames ? `
|
||
<div class="children-section">
|
||
<div class="children-label">子女:</div>
|
||
<div class="children-names">${childrenNames}</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
|
||
<div class="page-footer">
|
||
第 ${pageIndex + 1} 页 / 共 ${totalPages} 页
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
html += `
|
||
</body>
|
||
</html>`
|
||
|
||
// 创建 Blob 并下载
|
||
const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${treeName}族谱_${new Date().toISOString().split('T')[0]}.html`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
}
|
||
|
||
function renderMemberCardHTMLNew(
|
||
member: FamilyMember,
|
||
generation: number,
|
||
getMemberName: (id: string) => string,
|
||
baseUrl: string = ''
|
||
): string {
|
||
const info = getMemberInfo(member, getMemberName)
|
||
const isDead = !!member.deathDate
|
||
const statusClass = isDead ? 'status-deceased' : 'status-living'
|
||
const statusText = isDead ? '已故' : '在世'
|
||
|
||
// 获取完整的头像 URL
|
||
const avatarUrl = getFullImageUrl(member.avatarUrl, baseUrl)
|
||
|
||
let html = `
|
||
<div class="member-card">
|
||
<div class="member-header">
|
||
<div class="member-name">${member.fullName || '未知'}</div>
|
||
<div class="member-gen">第${generation}世${member.rank ? ` · 排行第${member.rank}` : ''}</div>
|
||
</div>
|
||
|
||
<div class="member-body">
|
||
<div class="avatar-section">
|
||
${avatarUrl
|
||
? `<img class="avatar" src="${avatarUrl}" alt="${member.fullName}" onerror="this.style.display='none';this.nextElementSibling.style.display='inline-flex';" /><div class="avatar-placeholder" style="display:none;">无照片</div>`
|
||
: `<div class="avatar-placeholder">${member.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
}
|
||
<div><span class="status-badge ${statusClass}">${statusText}</span></div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<!-- 基本信息 -->
|
||
<div class="info-group">
|
||
<div class="info-group-title">基本信息</div>
|
||
${renderInfoRow('性别', member.gender === 'MALE' ? '男' : member.gender === 'FEMALE' ? '女' : '-')}
|
||
${renderInfoRow('字辈', member.generationName)}
|
||
${renderInfoRow('字', member.courtesyName)}
|
||
${renderInfoRow('号', member.artName)}
|
||
</div>
|
||
|
||
<!-- 生卒信息 -->
|
||
<div class="info-group">
|
||
<div class="info-group-title">生卒信息</div>
|
||
${renderInfoRow('出生', formatDate(member.birthDate))}
|
||
${renderInfoRow('逝世', formatDate(member.deathDate))}
|
||
${renderInfoRow('年龄', calculateAge(member.birthDate, member.deathDate))}
|
||
</div>
|
||
|
||
<!-- 地址信息 -->
|
||
<div class="info-group">
|
||
<div class="info-group-title">地址信息</div>
|
||
${renderInfoRow('籍贯', member.ancestralHome)}
|
||
${renderInfoRow('出生地', member.birthPlace)}
|
||
${renderInfoRow('现居地', member.address)}
|
||
${renderInfoRow('墓地', member.burialPlace)}
|
||
</div>
|
||
|
||
<!-- 联系方式 -->
|
||
<div class="info-group">
|
||
<div class="info-group-title">联系方式</div>
|
||
${renderInfoRow('手机', member.phone)}
|
||
${renderInfoRow('电话', member.telephone)}
|
||
${renderInfoRow('邮箱', member.email)}
|
||
${renderInfoRow('职业', member.occupation)}
|
||
</div>
|
||
|
||
<!-- 家庭关系 -->
|
||
<div class="info-group">
|
||
<div class="info-group-title">家庭关系</div>
|
||
${renderInfoRow('父亲', member.fatherId ? getMemberName(member.fatherId) : '')}
|
||
${renderInfoRow('母亲', member.motherId ? getMemberName(member.motherId) : '')}
|
||
${renderInfoRow('配偶', member.spouseIds?.map(id => getMemberName(id)).filter(Boolean).join('、'))}
|
||
${renderInfoRow('子女', member.childrenIds?.map(id => getMemberName(id)).filter(Boolean).join('、'))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
|
||
return html
|
||
}
|
||
|
||
function renderInfoRow(label: string, value?: string): string {
|
||
const displayValue = value || '-'
|
||
const emptyClass = !value ? 'empty' : ''
|
||
return `
|
||
<div class="info-row">
|
||
<span class="info-label">${label}:</span>
|
||
<span class="info-value ${emptyClass}">${displayValue}</span>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
// 渲染配偶列(支持多配偶上下排列)
|
||
function renderSpouseColumn(
|
||
spouses: FamilyMember[],
|
||
generation: number,
|
||
getMemberName: (id: string) => string,
|
||
baseUrl: string = ''
|
||
): string {
|
||
if (spouses.length === 0) {
|
||
return '<div class="member-card empty"><div class="member-header"><div class="member-name">无配偶</div></div></div>'
|
||
}
|
||
|
||
if (spouses.length === 1) {
|
||
// 单配偶,使用完整卡片
|
||
return renderSpouseCardHTML(spouses[0], generation, getMemberName, false, baseUrl)
|
||
}
|
||
|
||
// 多配偶,上下排列
|
||
let html = '<div class="spouse-column">'
|
||
spouses.forEach((spouse, index) => {
|
||
html += `<div class="spouse-label">配偶${index + 1}</div>`
|
||
html += renderSpouseCardHTML(spouse, generation, getMemberName, true, baseUrl)
|
||
})
|
||
html += '</div>'
|
||
return html
|
||
}
|
||
|
||
// 渲染配偶卡片(简化版,用于多配偶时)
|
||
function renderSpouseCardHTML(
|
||
member: FamilyMember,
|
||
generation: number,
|
||
getMemberName: (id: string) => string,
|
||
isCompact: boolean,
|
||
baseUrl: string = ''
|
||
): string {
|
||
const isDead = !!member.deathDate
|
||
const statusClass = isDead ? 'status-deceased' : 'status-living'
|
||
const statusText = isDead ? '已故' : '在世'
|
||
const avatarUrl = getFullImageUrl(member.avatarUrl, baseUrl)
|
||
|
||
if (isCompact) {
|
||
// 紧凑版(多配偶时使用)
|
||
return `
|
||
<div class="spouse-card">
|
||
<div class="member-header">
|
||
<div class="member-name" style="font-size: 14pt;">${member.fullName || '未知'}</div>
|
||
<div class="member-gen">第${generation}世</div>
|
||
</div>
|
||
<div class="member-body" style="padding: 8px;">
|
||
<div style="display: flex; gap: 10px;">
|
||
<div class="avatar-section" style="margin-bottom: 0;">
|
||
${avatarUrl
|
||
? `<img class="avatar" style="width: 60px; height: 60px;" src="${avatarUrl}" alt="${member.fullName}" onerror="this.style.display='none';this.nextElementSibling.style.display='inline-flex';" /><div class="avatar-placeholder" style="width: 60px; height: 60px; display:none;">无照片</div>`
|
||
: `<div class="avatar-placeholder" style="width: 60px; height: 60px;">${member.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
}
|
||
<div><span class="status-badge ${statusClass}" style="font-size: 8pt;">${statusText}</span></div>
|
||
</div>
|
||
<div class="info-section" style="font-size: 9pt;">
|
||
${renderInfoRow('性别', member.gender === 'MALE' ? '男' : member.gender === 'FEMALE' ? '女' : '-')}
|
||
${renderInfoRow('出生', formatDate(member.birthDate))}
|
||
${renderInfoRow('逝世', formatDate(member.deathDate))}
|
||
${renderInfoRow('籍贯', member.ancestralHome)}
|
||
${renderInfoRow('职业', member.occupation)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
// 完整版(单配偶时使用)
|
||
return `
|
||
<div class="spouse-card" style="border-style: dashed;">
|
||
<div class="member-header">
|
||
<div class="member-name">${member.fullName || '未知'}</div>
|
||
<div class="member-gen">第${generation}世${member.rank ? ` · 排行第${member.rank}` : ''}</div>
|
||
</div>
|
||
|
||
<div class="member-body">
|
||
<div class="avatar-section">
|
||
${avatarUrl
|
||
? `<img class="avatar" src="${avatarUrl}" alt="${member.fullName}" onerror="this.style.display='none';this.nextElementSibling.style.display='inline-flex';" /><div class="avatar-placeholder" style="display:none;">无照片</div>`
|
||
: `<div class="avatar-placeholder">${member.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
}
|
||
<div><span class="status-badge ${statusClass}">${statusText}</span></div>
|
||
</div>
|
||
|
||
<div class="info-section">
|
||
<div class="info-group">
|
||
<div class="info-group-title">基本信息</div>
|
||
${renderInfoRow('性别', member.gender === 'MALE' ? '男' : member.gender === 'FEMALE' ? '女' : '-')}
|
||
${renderInfoRow('字辈', member.generationName)}
|
||
${renderInfoRow('字', member.courtesyName)}
|
||
${renderInfoRow('号', member.artName)}
|
||
</div>
|
||
|
||
<div class="info-group">
|
||
<div class="info-group-title">生卒信息</div>
|
||
${renderInfoRow('出生', formatDate(member.birthDate))}
|
||
${renderInfoRow('逝世', formatDate(member.deathDate))}
|
||
${renderInfoRow('年龄', calculateAge(member.birthDate, member.deathDate))}
|
||
</div>
|
||
|
||
<div class="info-group">
|
||
<div class="info-group-title">地址信息</div>
|
||
${renderInfoRow('籍贯', member.ancestralHome)}
|
||
${renderInfoRow('出生地', member.birthPlace)}
|
||
${renderInfoRow('现居地', member.address)}
|
||
${renderInfoRow('墓地', member.burialPlace)}
|
||
</div>
|
||
|
||
<div class="info-group">
|
||
<div class="info-group-title">联系方式</div>
|
||
${renderInfoRow('手机', member.phone)}
|
||
${renderInfoRow('电话', member.telephone)}
|
||
${renderInfoRow('邮箱', member.email)}
|
||
${renderInfoRow('职业', member.occupation)}
|
||
</div>
|
||
|
||
<div class="info-group">
|
||
<div class="info-group-title">家庭关系</div>
|
||
${renderInfoRow('父亲', member.fatherId ? getMemberName(member.fatherId) : '')}
|
||
${renderInfoRow('母亲', member.motherId ? getMemberName(member.motherId) : '')}
|
||
${renderInfoRow('子女', member.childrenIds?.map(id => getMemberName(id)).filter(Boolean).join('、'))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
// 渲染单个人员卡片(用于夫妻共用外框布局)
|
||
function renderPersonCard(
|
||
member: FamilyMember,
|
||
getMemberName: (id: string) => string,
|
||
baseUrl: string,
|
||
isMain: boolean
|
||
): string {
|
||
const isDead = !!member.deathDate
|
||
const statusText = isDead ? '已故' : '在世'
|
||
const statusClass = isDead ? 'status-deceased' : 'status-living'
|
||
const avatarUrl = getFullImageUrl(member.avatarUrl, baseUrl)
|
||
|
||
const genderText = member.gender === 'MALE' ? '男' : member.gender === 'FEMALE' ? '女' : ''
|
||
const rankText = member.rank ? `排行第${member.rank}` : ''
|
||
const metaItems = [genderText, rankText].filter(Boolean).join(' · ')
|
||
|
||
return `
|
||
<div class="person-card">
|
||
<div class="person-header">
|
||
${avatarUrl
|
||
? `<img class="person-avatar" src="${avatarUrl}" alt="${member.fullName}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" /><div class="person-avatar-placeholder" style="display:none;">${member.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
: `<div class="person-avatar-placeholder">${member.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
}
|
||
<div class="person-title">
|
||
<div class="person-name">${member.fullName || '未知'}</div>
|
||
<div class="person-meta">${metaItems}</div>
|
||
<div style="margin-top: 5px;"><span class="status-badge ${statusClass}">${statusText}</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="person-info">
|
||
<div class="info-group">
|
||
${member.generationName ? renderInfoRow('字辈', member.generationName) : ''}
|
||
${member.courtesyName ? renderInfoRow('字', member.courtesyName) : ''}
|
||
${member.artName ? renderInfoRow('号', member.artName) : ''}
|
||
${renderInfoRow('出生', formatDate(member.birthDate))}
|
||
${renderInfoRow('逝世', formatDate(member.deathDate))}
|
||
${renderInfoRow('年龄', calculateAge(member.birthDate, member.deathDate))}
|
||
</div>
|
||
<div class="info-group">
|
||
${renderInfoRow('籍贯', member.ancestralHome)}
|
||
${member.birthPlace ? renderInfoRow('出生地', member.birthPlace) : ''}
|
||
${member.address ? renderInfoRow('现居地', member.address) : ''}
|
||
${member.burialPlace ? renderInfoRow('墓地', member.burialPlace) : ''}
|
||
</div>
|
||
<div class="info-group">
|
||
${member.phone ? renderInfoRow('手机', member.phone) : ''}
|
||
${member.email ? renderInfoRow('邮箱', member.email) : ''}
|
||
${member.occupation ? renderInfoRow('职业', member.occupation) : ''}
|
||
</div>
|
||
${isMain ? `
|
||
<div class="info-group">
|
||
${renderInfoRow('父亲', member.fatherId ? getMemberName(member.fatherId) : '')}
|
||
${renderInfoRow('母亲', member.motherId ? getMemberName(member.motherId) : '')}
|
||
</div>
|
||
` : ''}
|
||
|
||
${member.tags && member.tags.length > 0 ? `
|
||
<div class="info-group">
|
||
<div class="info-group-title">标签</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px;">
|
||
${member.tags.map(tag => `<span style="background: #f5f0e6; color: #8b4513; padding: 2px 8px; border-radius: 10px; font-size: 9pt;">${tag}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${member.bio ? `
|
||
<div class="info-group">
|
||
<div class="info-group-title">个人简介</div>
|
||
<div style="font-size: 9pt; color: #333; line-height: 1.5; margin-top: 4px; text-align: justify;">
|
||
${member.bio}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
${member.stories && member.stories.length > 0 ? `
|
||
<div class="info-group">
|
||
<div class="info-group-title">家族故事</div>
|
||
${member.stories.map(story => `
|
||
<div style="margin-top: 6px; padding: 6px; background: #faf8f5; border-left: 3px solid #d4c4a8;">
|
||
<div style="font-weight: bold; font-size: 10pt; color: #8b4513;">${story.title}</div>
|
||
${story.date ? `<div style="font-size: 8pt; color: #999; margin-top: 2px;">${story.date}</div>` : ''}
|
||
<div style="font-size: 9pt; color: #333; line-height: 1.4; margin-top: 4px;">${story.content.length > 200 ? story.content.substring(0, 200) + '...' : story.content}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`
|
||
}
|
||
|
||
// 渲染配偶卡片列表
|
||
function renderSpousesCards(
|
||
spouses: FamilyMember[],
|
||
getMemberName: (id: string) => string,
|
||
baseUrl: string
|
||
): string {
|
||
if (spouses.length === 1) {
|
||
return renderPersonCard(spouses[0], getMemberName, baseUrl, false)
|
||
}
|
||
|
||
// 多配偶,上下排列,每个配偶都有头像
|
||
let html = '<div class="spouses-container">'
|
||
spouses.forEach((spouse, index) => {
|
||
const isDead = !!spouse.deathDate
|
||
const statusText = isDead ? '已故' : '在世'
|
||
const statusClass = isDead ? 'status-deceased' : 'status-living'
|
||
const avatarUrl = getFullImageUrl(spouse.avatarUrl, baseUrl)
|
||
const genderText = spouse.gender === 'MALE' ? '男' : spouse.gender === 'FEMALE' ? '女' : ''
|
||
|
||
html += `
|
||
<div class="spouse-item" style="border: 1px dashed #d4c4a8; padding: 8px; background: #fefcf8; ${index > 0 ? 'margin-top: 8px;' : ''}">
|
||
<div style="display: flex; align-items: flex-start; gap: 10px;">
|
||
<!-- 配偶头像 -->
|
||
${avatarUrl
|
||
? `<img style="width: 50px; height: 50px; border: 2px solid #8b4513; object-fit: cover; flex-shrink: 0;" src="${avatarUrl}" alt="${spouse.fullName}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" /><div style="width: 50px; height: 50px; border: 2px solid #d4c4a8; background: #f0e6d3; display: none; align-items: center; justify-content: center; color: #8b4513; font-size: 18pt; flex-shrink: 0;">${spouse.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
: `<div style="width: 50px; height: 50px; border: 2px solid #d4c4a8; background: #f0e6d3; display: flex; align-items: center; justify-content: center; color: #8b4513; font-size: 18pt; flex-shrink: 0;">${spouse.gender === 'MALE' ? '👤' : '👩'}</div>`
|
||
}
|
||
<!-- 配偶信息 -->
|
||
<div style="flex: 1; min-width: 0;">
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 4px;">
|
||
<span style="font-size: 9pt; color: #8b4513; background: #f5f0e6; padding: 1px 6px; border-radius: 3px;">配偶${index + 1}</span>
|
||
<span style="font-size: 14pt; font-weight: bold; color: #8b4513;">${spouse.fullName || '未知'}</span>
|
||
<span class="status-badge ${statusClass}" style="font-size: 8pt;">${statusText}</span>
|
||
</div>
|
||
<div style="font-size: 9pt; color: #666;">
|
||
${genderText ? `<span>${genderText}</span>` : ''}
|
||
${spouse.birthDate ? `<span style="margin-left: 8px;">生于 ${formatDate(spouse.birthDate)}</span>` : ''}
|
||
${spouse.deathDate ? `<span style="margin-left: 8px;">卒于 ${formatDate(spouse.deathDate)}</span>` : ''}
|
||
</div>
|
||
${spouse.ancestralHome || spouse.occupation ? `
|
||
<div style="font-size: 9pt; color: #666; margin-top: 2px;">
|
||
${spouse.ancestralHome ? `<span>籍贯: ${spouse.ancestralHome}</span>` : ''}
|
||
${spouse.occupation ? `<span style="margin-left: 8px;">职业: ${spouse.occupation}</span>` : ''}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`
|
||
})
|
||
html += '</div>'
|
||
return html
|
||
}
|