Files
chinese-family-tree-2/lib/export-pdf.ts
T
freedakgmail 47be495fcf 0.6.1.0
2025-12-10 11:41:28 +08:00

1079 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> {
// 创建 PDFA4 纸张
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
}