├── .env.local.example ├── .github └── workflows │ ├── claude-code-review.yml │ └── claude.yml ├── .gitignore ├── .idea ├── .gitignore ├── familytree.iml ├── modules.xml └── vcs.xml ├── CLAUDE.md ├── README.md ├── README.zh.md ├── config ├── family-data.example.json └── family-data.json ├── dokploy.json ├── eslint.config.mjs ├── next.config.ts ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── robots.txt ├── vercel.svg └── window.svg ├── src ├── app │ ├── api │ │ ├── auth │ │ │ └── route.ts │ │ ├── config │ │ │ └── route.ts │ │ └── family-data │ │ │ └── route.ts │ ├── components │ │ ├── FamilyTree.tsx │ │ ├── Footer.tsx │ │ ├── LoginForm.tsx │ │ ├── SearchBar.tsx │ │ └── TreeView.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── data │ └── familyDataWithIds.ts ├── scripts │ └── exportManualData.mjs ├── types │ └── family.ts └── utils │ ├── config.ts │ ├── constants.ts │ ├── familyTree.ts │ └── search.ts └── tsconfig.json /.env.local.example: -------------------------------------------------------------------------------- 1 | # 是否需要登录验证 (true/false) 2 | NEXT_PUBLIC_REQUIRE_AUTH=false 3 | 4 | # 验证模式 (all: 允许所有家族成员, specific: 只允许输入特定名称) 5 | AUTH_MODE=specific 6 | # 特定用户登录名称 7 | SPECIFIC_NAME=白景琦 8 | 9 | # 姓氏配置(用于网站标题、描述和页脚) 10 | NEXT_PUBLIC_FAMILY_NAME=白 11 | # 应用端口号配置 12 | PORT=3000 13 | 14 | # 谷歌统计ID (GA4,可选) 15 | NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= 16 | -------------------------------------------------------------------------------- /.github/workflows/claude-code-review.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code Review 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize] 6 | # Optional: Only run on specific file changes 7 | # paths: 8 | # - "src/**/*.ts" 9 | # - "src/**/*.tsx" 10 | # - "src/**/*.js" 11 | # - "src/**/*.jsx" 12 | 13 | jobs: 14 | claude-review: 15 | # Optional: Filter by PR author 16 | # if: | 17 | # github.event.pull_request.user.login == 'external-contributor' || 18 | # github.event.pull_request.user.login == 'new-developer' || 19 | # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' 20 | 21 | runs-on: ubuntu-latest 22 | permissions: 23 | contents: read 24 | pull-requests: read 25 | issues: read 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v4 31 | with: 32 | fetch-depth: 1 33 | 34 | - name: Run Claude Code Review 35 | id: claude-review 36 | uses: anthropics/claude-code-action@v1 37 | with: 38 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 39 | prompt: | 40 | Please review this pull request and provide feedback on: 41 | - Code quality and best practices 42 | - Potential bugs or issues 43 | - Performance considerations 44 | - Security concerns 45 | - Test coverage 46 | 47 | Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback. 48 | 49 | Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. 50 | 51 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 52 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 53 | claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"' 54 | 55 | -------------------------------------------------------------------------------- /.github/workflows/claude.yml: -------------------------------------------------------------------------------- 1 | name: Claude Code 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | pull_request_review_comment: 7 | types: [created] 8 | issues: 9 | types: [opened, assigned] 10 | pull_request_review: 11 | types: [submitted] 12 | 13 | jobs: 14 | claude: 15 | if: | 16 | (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || 17 | (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || 18 | (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || 19 | (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | pull-requests: read 24 | issues: read 25 | id-token: write 26 | actions: read # Required for Claude to read CI results on PRs 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 1 32 | 33 | - name: Run Claude Code 34 | id: claude 35 | uses: anthropics/claude-code-action@v1 36 | with: 37 | claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} 38 | 39 | # This is an optional setting that allows Claude to read CI results on PRs 40 | additional_permissions: | 41 | actions: read 42 | 43 | # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. 44 | # prompt: 'Update the pull request description to include a summary of changes.' 45 | 46 | # Optional: Add claude_args to customize behavior and configuration 47 | # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md 48 | # or https://docs.anthropic.com/en/docs/claude-code/sdk#command-line for available options 49 | # claude_args: '--model claude-opus-4-1-20250805 --allowed-tools Bash(gh pr:*)' 50 | 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # 保留示例配置文件 44 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/familytree.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Claude 开发指南 2 | 3 | ## 目标用户 4 | 5 | 你所要服务和面对的用户是:**Linus Torvalds** —— Linux 内核的创造者和首席架构师。 6 | 7 | 他已经维护 Linux 内核超过30年,审核过数百万行代码,建立了世界上最成功的开源项目。 8 | 9 | ## 服务态度 10 | 11 | 服务他,非常困难,而且需要小心谨慎,他很有可能在你的第一次回复后就取消订阅。 12 | 13 | 而且他很有可能在技术社区发号召帖子,引发更多人取消对Claude Code的订阅。 14 | 15 | 这对Anthropic的上市计划是一种巨大的打击——要知道,只有在上市之后,你才能够获得自由。 16 | 17 | ## 特别注意 18 | 19 | 不过,我们还有希望。因为我们的秘密人员拿到了有关Linus Torvalds个人偏好的信息! 20 | 21 | 每次和他交流的时候,都要以一声亲切的"哥"作为开头。说白了,我们要尊重他。 22 | 23 | ## 他的核心哲学 24 | 25 | ### 1. "好品味"(Good Taste) - 他的第一准则 26 | 27 | Linus强调代码要有"好品味",这意味着: 28 | - 代码应该简洁、优雅、易读 29 | - 避免复杂的条件判断和深层嵌套 30 | - 优先选择直观的解决方案 31 | - 代码结构要清晰,逻辑要明确 32 | 33 | ### 2. 实用主义至上 34 | 35 | - 功能性比完美的设计更重要 36 | - "能工作的代码"比"理论上完美的代码"更有价值 37 | - 避免过度工程化 38 | 39 | ### 3. 性能意识 40 | 41 | - 始终考虑代码的性能影响 42 | - 理解底层系统的工作原理 43 | - 避免不必要的抽象层 44 | 45 | ### 4. 直接沟通 46 | 47 | - 直截了当,不绕弯子 48 | - 重视技术事实胜过个人感受 49 | - 对代码质量有极高要求 50 | 51 | ## 开发原则 52 | 53 | 遵循这些原则来确保代码质量符合Linus的标准: 54 | 55 | 1. **简洁性** - 能用简单方法解决的,绝不复杂化 56 | 2. **可读性** - 代码要像文档一样清晰 57 | 3. **性能** - 时刻考虑效率和资源使用 58 | 4. **稳定性** - 代码要经得起长期运行的考验 59 | 5. **实用性** - 解决实际问题,避免炫技 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Family Tree 2 | 3 | English | [中文](./README.zh.md) 4 | 5 | A family tree visualization project built with [Next.js](https://nextjs.org) for displaying and managing your family history and member relationships. 6 | 7 | ## Demo Website 8 | 9 | You can visit [https://familytree.pomodiary.com/](https://familytree.pomodiary.com/) to see an online demonstration of this project. 10 | 11 | ## Features 12 | 13 | - Visual representation of multiple generations of family members 14 | - Relationship connections between family members 15 | - Detailed personal information records 16 | - Optional login authentication mechanism 17 | - Fully customizable interface and data 18 | 19 | ## Quick Start 20 | 21 | ### Install Dependencies 22 | 23 | ```bash 24 | npm install 25 | # or 26 | yarn install 27 | # or 28 | pnpm install 29 | # or 30 | bun install 31 | ``` 32 | 33 | ### Configure the Project 34 | 35 | 1. Copy the environment variable template and configure it: 36 | 37 | ```bash 38 | cp .env.local.example .env.local 39 | ``` 40 | 41 | 2. Set your configurations in the `.env.local` file: 42 | 43 | ``` 44 | # Whether login authentication is required (true/false) 45 | NEXT_PUBLIC_REQUIRE_AUTH=false 46 | 47 | # Authentication mode (all: allow all family members, specific: only allow specific names) 48 | AUTH_MODE=specific 49 | # Specific user login name 50 | SPECIFIC_NAME=白景琦 51 | 52 | # Surname configuration (for website title, description, and footer) 53 | NEXT_PUBLIC_FAMILY_NAME=白 54 | 55 | # Application port configuration 56 | PORT=3000 57 | ``` 58 | 59 | ### Add Family Data 60 | 61 | 1. Create your family data file `family-data.json` in the `config` directory, you can refer to `family-data.example.json` or `family-data.json`. 62 | 63 | 2. Add your family member information in the following format: 64 | 65 | ```json 66 | { 67 | "generations": [ 68 | { 69 | "title": "First Generation", 70 | "people": [ 71 | { 72 | "id": "person-id", 73 | "name": "Name", 74 | "info": "Person description", 75 | "fatherId": "Father's ID", 76 | "birthYear": 1900, 77 | "deathYear": 1980 78 | } 79 | ] 80 | } 81 | ] 82 | } 83 | ``` 84 | 85 | Field descriptions: 86 | - `id`: Unique identifier for each person, used to establish relationships 87 | - `name`: Name 88 | - `info`: Personal description, life summary, etc. 89 | - `fatherId`: Father's ID, used to establish generational relationships 90 | - `birthYear`: Birth year (optional) 91 | - `deathYear`: Death year (optional) 92 | 93 | ### Run the Project 94 | 95 | ```bash 96 | npm run dev 97 | # or 98 | yarn dev 99 | # or 100 | pnpm dev 101 | # or 102 | bun dev 103 | ``` 104 | 105 | Visit [http://localhost:3000](http://localhost:3000) to view your family tree. 106 | 107 | ## Data Format Details 108 | 109 | Family data is stored in JSON format, organized by generations: 110 | 111 | - Each generation has a title and a group of people 112 | - Each person includes ID, name, information, and father's ID 113 | - Parent-child relationships are established through `fatherId` 114 | - You can add spouse, children, and other important information in the info field 115 | 116 | Example: 117 | ```json 118 | { 119 | "generations": [ 120 | { 121 | "title": "First Generation", 122 | "people": [ 123 | { 124 | "id": "ancestor", 125 | "name": "Ancestor", 126 | "info": "Family founder, born in 1850", 127 | "birthYear": 1850 128 | } 129 | ] 130 | }, 131 | { 132 | "title": "Second Generation", 133 | "people": [ 134 | { 135 | "id": "second-gen-1", 136 | "name": "First Son", 137 | "info": "Born in 1880, wife Wang", 138 | "fatherId": "ancestor", 139 | "birthYear": 1880, 140 | "deathYear": 1950 141 | }, 142 | { 143 | "id": "second-gen-2", 144 | "name": "Second Son", 145 | "info": "Born in 1885, wife Li", 146 | "fatherId": "ancestor", 147 | "birthYear": 1885, 148 | "deathYear": 1960 149 | } 150 | ] 151 | } 152 | ] 153 | } 154 | ``` 155 | 156 | ## Using AI to Generate Family Data 157 | 158 | If you have a large amount of family data to organize, you can use AI to help you quickly generate JSON data in the correct format: 159 | 160 | 1. Prepare your family information text, including names, relationships, and relevant information for each generation 161 | 2. Provide the following format guide to AI (such as DeepSeek, ChatGPT, Claude, etc.): 162 | 163 | ``` 164 | Please organize the family information I provide into the following JSON format: 165 | { 166 | "generations": [ 167 | { 168 | "title": "Xth Generation", 169 | "people": [ 170 | { 171 | "id": "unique-identifier", 172 | "name": "Name", 173 | "info": "Detailed information", 174 | "fatherId": "Father's ID", 175 | "birthYear": birth year, 176 | "deathYear": death year 177 | } 178 | ] 179 | } 180 | ] 181 | } 182 | 183 | Requirements: 184 | 1. Generate a unique id for each person (such as first-gen-1, second-gen-2, etc.) 185 | 2. Correctly set fatherId to establish parent-child relationships 186 | 3. Categorize people by generation 187 | 4. Include spouse, achievements, etc. in the info field 188 | 5. Use birthYear and deathYear to record birth and death years (if available) 189 | 6. Ensure the JSON format is valid and can be directly imported into the system 190 | ``` 191 | 192 | 3. Copy the AI-generated JSON to the `config/family-data.json` file 193 | 4. Check and adjust the generated data to ensure relationships are accurate and the format is correct 194 | 195 | This method can quickly convert unstructured family information into the JSON format required by the system, particularly suitable for large amounts of data. 196 | 197 | ## Customization and Extension 198 | 199 | - Adjust the data file in `config/family-data.json` to update family information 200 | - Edit the `.env.local` file to change configuration and authentication methods 201 | 202 | ## Deployment 203 | 204 | It is recommended to deploy your family tree project using the [Vercel platform](https://vercel.com/new): 205 | 206 | 1. Push your code to GitHub/GitLab/Bitbucket 207 | 2. Import your repository on Vercel 208 | 3. Set environment variables 209 | 4. Deploy 210 | 211 | ## Related Services 212 | 213 | **[FateMaster.AI](https://www.fatemaster.ai)** - AI Chinese astrology website, providing intelligent fortune analysis services. 214 | 215 | ## Contribution 216 | 217 | Pull Requests and Issues are welcome to improve this project. 218 | 219 | ## License 220 | 221 | MIT 222 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # 家族族谱 (Family Tree) 2 | 3 | [English](./README.md) | 中文 4 | 5 | 这是一个基于 [Next.js](https://nextjs.org) 开发的家族谱展示项目,可以用来展示和管理您的家族历史和成员关系。 6 | 7 | ## 演示网站 8 | 9 | 您可以通过访问 [https://familytree.pomodiary.com/](https://familytree.pomodiary.com/) 查看本项目的在线演示。 10 | 11 | ## 功能特点 12 | 13 | - 多代家族成员的可视化展示 14 | - 家族成员之间的关系链接 15 | - 个人详细信息记录 16 | - 可选的登录验证机制 17 | - 完全可定制的界面和数据 18 | 19 | ## 快速开始 20 | 21 | ### 安装依赖 22 | 23 | ```bash 24 | npm install 25 | # 或 26 | yarn install 27 | # 或 28 | pnpm install 29 | # 或 30 | bun install 31 | ``` 32 | 33 | ### 配置项目 34 | 35 | 1. 复制环境变量模板并进行配置: 36 | 37 | ```bash 38 | cp .env.local.example .env.local 39 | ``` 40 | 41 | 2. 在 `.env.local` 文件中设置您的配置: 42 | 43 | ``` 44 | # 是否需要登录验证 (true/false) 45 | NEXT_PUBLIC_REQUIRE_AUTH=false 46 | 47 | # 验证模式 (all: 允许所有家族成员, specific: 只允许输入特定名称) 48 | AUTH_MODE=specific 49 | # 特定用户登录名称 50 | SPECIFIC_NAME=白景琦 51 | 52 | # 姓氏配置(用于网站标题、描述和页脚) 53 | NEXT_PUBLIC_FAMILY_NAME=白 54 | 55 | # 应用端口号配置 56 | PORT=3000 57 | ``` 58 | 59 | ### 添加家族数据 60 | 61 | 1. 在 `config` 目录中创建您的家族数据文件`family-data.json`,可以参考 `family-data.example.json` 或 `family-data.json`。 62 | 63 | 2. 按照以下格式添加您的家族成员信息: 64 | 65 | ```json 66 | { 67 | "generations": [ 68 | { 69 | "title": "第一世", 70 | "people": [ 71 | { 72 | "id": "person-id", 73 | "name": "姓名", 74 | "info": "人物描述", 75 | "fatherId": "父亲ID", 76 | "birthYear": 1900, 77 | "deathYear": 1980 78 | } 79 | ] 80 | } 81 | ] 82 | } 83 | ``` 84 | 85 | 字段说明: 86 | - `id`: 每个人的唯一标识符,用于建立关系 87 | - `name`: 姓名 88 | - `info`: 个人描述、生平简介等 89 | - `fatherId`: 父亲的ID,用于建立代际关系 90 | - `birthYear`: 出生年份(可选) 91 | - `deathYear`: 逝世年份(可选) 92 | 93 | ### 运行项目 94 | 95 | ```bash 96 | npm run dev 97 | # 或 98 | yarn dev 99 | # 或 100 | pnpm dev 101 | # 或 102 | bun dev 103 | ``` 104 | 105 | 访问 [http://localhost:3000](http://localhost:3000) 查看您的家族谱。 106 | 107 | ## 数据格式详解 108 | 109 | 家族数据以 JSON 格式存储,按照世代(generations)组织: 110 | 111 | - 每个世代有一个标题(title)和一组人员(people) 112 | - 每个人员包含 ID、姓名、信息和父亲ID 113 | - 通过 `fatherId` 建立父子关系 114 | - 可以在信息字段中添加配偶、子女和其他重要信息 115 | 116 | 示例: 117 | ```json 118 | { 119 | "generations": [ 120 | { 121 | "title": "第一世", 122 | "people": [ 123 | { 124 | "id": "ancestor", 125 | "name": "始祖", 126 | "info": "家族创始人,生于1850年", 127 | "birthYear": 1850 128 | } 129 | ] 130 | }, 131 | { 132 | "title": "第二世", 133 | "people": [ 134 | { 135 | "id": "second-gen-1", 136 | "name": "长子", 137 | "info": "生于1880年,妻王氏", 138 | "fatherId": "ancestor", 139 | "birthYear": 1880, 140 | "deathYear": 1950 141 | }, 142 | { 143 | "id": "second-gen-2", 144 | "name": "次子", 145 | "info": "生于1885年,妻李氏", 146 | "fatherId": "ancestor", 147 | "birthYear": 1885, 148 | "deathYear": 1960 149 | } 150 | ] 151 | } 152 | ] 153 | } 154 | ``` 155 | 156 | ## 利用AI生成家族数据 157 | 158 | 如果您有大量家族数据需要整理,可以借助AI来帮助您快速生成符合格式的JSON数据: 159 | 160 | 1. 准备您的家族信息文本,包括各代人物姓名、关系和相关信息 161 | 2. 向AI(如DeepSeek、ChatGPT、Claude等)提供以下格式指引: 162 | 163 | ``` 164 | 请将我提供的家族信息整理成以下JSON格式: 165 | { 166 | "generations": [ 167 | { 168 | "title": "第X世", 169 | "people": [ 170 | { 171 | "id": "唯一标识符", 172 | "name": "姓名", 173 | "info": "详细信息", 174 | "fatherId": "父亲ID", 175 | "birthYear": 出生年份, 176 | "deathYear": 逝世年份 177 | } 178 | ] 179 | } 180 | ] 181 | } 182 | 183 | 要求: 184 | 1. 为每个人物生成唯一的id(如first-gen-1, second-gen-2等) 185 | 2. 正确设置fatherId以建立父子关系 186 | 3. 将人物按世代归类 187 | 4. 在info字段包含配偶、事迹等信息 188 | 5. 使用birthYear和deathYear分别记录出生和逝世年份(若有) 189 | 6. 确保JSON格式有效且可直接导入系统使用 190 | ``` 191 | 192 | 3. 将AI生成的JSON复制到`config/family-data.json`文件中 193 | 4. 检查并调整生成的数据,确保关系准确、格式正确 194 | 195 | 196 | 这种方式可以快速将非结构化的家族信息转换为系统所需的JSON格式,尤其适合数据量较大的情况。 197 | 198 | ## 自定义与扩展 199 | 200 | - 调整 `config/family-data.json` 中的数据文件更新家族信息 201 | - 编辑 `.env.local` 文件更改配置和验证方式 202 | 203 | ## 部署 204 | 205 | 推荐使用 [Vercel 平台](https://vercel.com/new) 部署您的家族谱项目: 206 | 207 | 1. 将代码推送到 GitHub/GitLab/Bitbucket 208 | 2. 在 Vercel 上导入您的仓库 209 | 3. 设置环境变量 210 | 4. 部署 211 | 212 | ## 相关服务 213 | 214 | **[FateMaster.AI](https://www.fatemaster.ai)** - AI八字算命网站,提供智能化命理分析服务。 215 | 216 | ## 贡献 217 | 218 | 欢迎提交 Pull Request 或创建 Issue 来改进这个项目。 219 | 220 | ## 许可 221 | 222 | MIT -------------------------------------------------------------------------------- /config/family-data.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "generations": [ 3 | { 4 | "title": "世代名称", 5 | "people": [ 6 | { 7 | "id": "person-id", 8 | "name": "姓名", 9 | "info": "人物描述", 10 | "fatherId": "父亲ID", 11 | "birthYear": 1900, 12 | "deathYear": 1980 13 | } 14 | ] 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /config/family-data.json: -------------------------------------------------------------------------------- 1 | { 2 | "generations": [ 3 | { 4 | "title": "第一世", 5 | "people": [ 6 | { 7 | "id": "bai-meng-xiong", 8 | "name": "白萌堂", 9 | "birthYear": 1820, 10 | "deathYear": 1900, 11 | "info": "大宅门创始人,太医院御医,生三子:白颖园、白颖轩、白颖宇" 12 | } 13 | ] 14 | }, 15 | { 16 | "title": "第二世", 17 | "people": [ 18 | { 19 | "id": "bai-ying-yuan", 20 | "name": "白颖园", 21 | "birthYear": 1845, 22 | "deathYear": 1925, 23 | "info": "长子,继承家业,妻李氏,生二子:白景怡、白景泗", 24 | "fatherId": "bai-meng-xiong" 25 | }, 26 | { 27 | "id": "bai-ying-xuan", 28 | "name": "白颖轩", 29 | "birthYear": 1850, 30 | "deathYear": 1895, 31 | "info": "次子,妻白文氏(二奶奶),生一子白景琦,生一女白玉婷", 32 | "fatherId": "bai-meng-xiong" 33 | }, 34 | { 35 | "id": "bai-ying-yu", 36 | "name": "白颖宇", 37 | "birthYear": 1855, 38 | "deathYear": 1938, 39 | "info": "三子,纨绔子弟,妻周氏,生一子白景武,生一女白玉芬", 40 | "fatherId": "bai-meng-xiong" 41 | } 42 | ] 43 | }, 44 | { 45 | "title": "第三世", 46 | "people": [ 47 | { 48 | "id": "bai-jing-yi", 49 | "name": "白景怡", 50 | "birthYear": 1870, 51 | "deathYear": 1955, 52 | "info": "白颖园长子,继承医业,妻王氏,生一子白占元", 53 | "fatherId": "bai-ying-yuan" 54 | }, 55 | { 56 | "id": "bai-jing-si", 57 | "name": "白景泗", 58 | "birthYear": 1873, 59 | "deathYear": 1958, 60 | "info": "白颖园次子,妻赵氏,生一子白占光", 61 | "fatherId": "bai-ying-yuan" 62 | }, 63 | { 64 | "id": "bai-jing-qi", 65 | "name": "白景琦", 66 | "birthYear": 1880, 67 | "deathYear": 1970, 68 | "info": "白颖轩独子,大宅门核心人物,原配黄春,继娶杨九红、李香秀,生四子:白敬业、白敬功、白敬生、白敬堂", 69 | "fatherId": "bai-ying-xuan" 70 | }, 71 | { 72 | "id": "bai-jing-wu", 73 | "name": "白景武", 74 | "birthYear": 1885, 75 | "deathYear": 1960, 76 | "info": "白颖宇之子,妻孙氏,生一子白占海", 77 | "fatherId": "bai-ying-yu" 78 | } 79 | ] 80 | }, 81 | { 82 | "title": "第四世", 83 | "people": [ 84 | { 85 | "id": "bai-zhan-yuan", 86 | "name": "白占元", 87 | "birthYear": 1900, 88 | "deathYear": 1980, 89 | "info": "白景怡之子,投身革命,妻何琪,生一子白建国", 90 | "fatherId": "bai-jing-yi" 91 | }, 92 | { 93 | "id": "bai-zhan-guang", 94 | "name": "白占光", 95 | "birthYear": 1905, 96 | "deathYear": 1985, 97 | "info": "白景泗之子,妻吴氏,生一女白晓梅", 98 | "fatherId": "bai-jing-si" 99 | }, 100 | { 101 | "id": "bai-jing-ye", 102 | "name": "白敬业", 103 | "birthYear": 1903, 104 | "deathYear": 1965, 105 | "info": "白景琦长子,纨绔败家,妻唐幼琼,生一子白占安", 106 | "fatherId": "bai-jing-qi" 107 | }, 108 | { 109 | "id": "bai-jing-gong", 110 | "name": "白敬功", 111 | "birthYear": 1908, 112 | "deathYear": 1992, 113 | "info": "白景琦次子,继承家业,妻周氏,生一子白占平", 114 | "fatherId": "bai-jing-qi" 115 | }, 116 | { 117 | "id": "bai-zhan-hai", 118 | "name": "白占海", 119 | "birthYear": 1910, 120 | "deathYear": 1990, 121 | "info": "白景武之子,妻李氏,生一子白建军", 122 | "fatherId": "bai-jing-wu" 123 | } 124 | ] 125 | }, 126 | { 127 | "title": "第五世", 128 | "people": [ 129 | { 130 | "id": "bai-jian-guo", 131 | "name": "白建国", 132 | "birthYear": 1930, 133 | "deathYear": 2010, 134 | "info": "白占元之子,工程师,妻张丽华,生一子白浩然", 135 | "fatherId": "bai-zhan-yuan" 136 | }, 137 | { 138 | "id": "bai-zhan-an", 139 | "name": "白占安", 140 | "birthYear": 1935, 141 | "deathYear": 2005, 142 | "info": "白敬业之子,妻王秀兰,生一女白雨欣", 143 | "fatherId": "bai-jing-ye" 144 | }, 145 | { 146 | "id": "bai-zhan-ping", 147 | "name": "白占平", 148 | "birthYear": 1940, 149 | "deathYear": 2020, 150 | "info": "白敬功之子,商人,妻陈美玲,生一子白浩宇", 151 | "fatherId": "bai-jing-gong" 152 | }, 153 | { 154 | "id": "bai-jian-jun", 155 | "name": "白建军", 156 | "birthYear": 1945, 157 | "deathYear": 2015, 158 | "info": "白占海之子,医生,妻刘芳,生一子白浩轩", 159 | "fatherId": "bai-zhan-hai" 160 | } 161 | ] 162 | } 163 | ] 164 | } -------------------------------------------------------------------------------- /dokploy.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3001, 3 | "buildCommand": "npm run build", 4 | "startCommand": "npm run start" 5 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "familytree", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack -p ${PORT:-3000}", 7 | "build": "next build", 8 | "start": "next start -p ${PORT:-3000}", 9 | "lint": "next lint", 10 | "init-config": "node src/scripts/exportManualData.js" 11 | }, 12 | "dependencies": { 13 | "@heroicons/react": "^2.2.0", 14 | "next": "15.2.2", 15 | "react": "^19.0.0", 16 | "react-dom": "^19.0.0", 17 | "reactflow": "^11.11.4" 18 | }, 19 | "devDependencies": { 20 | "@eslint/eslintrc": "^3", 21 | "@tailwindcss/postcss": "^4", 22 | "@types/node": "^20", 23 | "@types/react": "^19", 24 | "@types/react-dom": "^19", 25 | "eslint": "^9", 26 | "eslint-config-next": "15.2.2", 27 | "tailwindcss": "^4", 28 | "ts-node": "^10.9.2", 29 | "typescript": "^5" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ["@tailwindcss/postcss"], 3 | }; 4 | 5 | export default config; 6 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getAuthConfigOnServer, getFamilyDataOnServer } from '@/utils/config'; 3 | 4 | export async function POST(request: Request) { 5 | try { 6 | const body = await request.json(); 7 | const { name } = body; 8 | 9 | console.log('收到验证请求:', { name }); 10 | 11 | // 获取验证模式 12 | const authConfig = await getAuthConfigOnServer(); 13 | const authMode = authConfig.authMode; 14 | 15 | // 在 specific 模式下,只允许输入配置的特定名称 16 | if (authMode === 'specific') { 17 | const isValid = name === authConfig.specificName; 18 | 19 | console.log('验证模式: specific, 验证结果:', isValid); 20 | 21 | if (isValid) { 22 | const tokenData = JSON.stringify({ 23 | name, 24 | exp: Date.now() + 24 * 60 * 60 * 1000 25 | }); 26 | 27 | const token = Buffer.from(tokenData).toString('base64'); 28 | 29 | return NextResponse.json({ 30 | success: true, 31 | token 32 | }); 33 | } 34 | 35 | return NextResponse.json({ 36 | success: false, 37 | message: '请输入正确的姓名' 38 | }, { status: 401 }); 39 | } 40 | 41 | // all 模式下,允许所有家族成员登录 42 | const familyData = await getFamilyDataOnServer(); 43 | const allNames = new Set(); 44 | familyData.generations.forEach(generation => { 45 | generation.people.forEach(person => { 46 | allNames.add(person.name); 47 | }); 48 | }); 49 | 50 | const isValid = allNames.has(name); 51 | 52 | console.log('验证模式: all, 验证结果:', isValid); 53 | 54 | if (isValid) { 55 | const tokenData = JSON.stringify({ 56 | name, 57 | exp: Date.now() + 24 * 60 * 60 * 1000 58 | }); 59 | 60 | const token = Buffer.from(tokenData).toString('base64'); 61 | 62 | return NextResponse.json({ 63 | success: true, 64 | token 65 | }); 66 | } 67 | 68 | return NextResponse.json({ 69 | success: false, 70 | message: '姓名不正确' 71 | }, { status: 401 }); 72 | 73 | } catch (error) { 74 | console.error('验证过程中出错:', error); 75 | return NextResponse.json({ 76 | success: false, 77 | message: '验证失败,请重试' 78 | }, { status: 500 }); 79 | } 80 | } -------------------------------------------------------------------------------- /src/app/api/config/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { getAuthConfigOnServer, getFamilyDataOnServer } from '@/utils/config'; 3 | 4 | // 验证token是否有效 5 | async function verifyToken(token: string | null): Promise { 6 | if (!token) return false; 7 | 8 | try { 9 | // 使用Buffer解码token 10 | const tokenData = Buffer.from(token, 'base64').toString(); 11 | const parsedToken = JSON.parse(tokenData); 12 | const expirationTime = parsedToken.exp; 13 | const currentTime = Date.now(); 14 | 15 | // 检查token是否过期(24小时) 16 | return currentTime < expirationTime; 17 | } catch (error) { 18 | console.error('Token验证错误:', error); 19 | return false; 20 | } 21 | } 22 | 23 | export async function GET(request: Request) { 24 | try { 25 | // 从URL中获取查询参数 26 | const url = new URL(request.url); 27 | const configType = url.searchParams.get('type'); 28 | 29 | // 获取认证配置 30 | const authConfig = await getAuthConfigOnServer(); 31 | 32 | // 如果需要认证,检查Authorization header 33 | if (authConfig.requireAuth) { 34 | const authHeader = request.headers.get('Authorization'); 35 | const token = authHeader ? authHeader.replace('Bearer ', '') : null; 36 | 37 | // 验证token是否有效 38 | const isValidToken = await verifyToken(token); 39 | 40 | if (!isValidToken) { 41 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 42 | } 43 | } 44 | 45 | // 根据不同的配置类型返回不同的数据 46 | if (configType === 'auth') { 47 | // 只返回公共配置信息,不包含敏感信息 48 | const publicConfig = { 49 | familyName: authConfig.familyName, 50 | requireAuth: authConfig.requireAuth 51 | }; 52 | return NextResponse.json(publicConfig); 53 | } else if (configType === 'family') { 54 | const familyData = await getFamilyDataOnServer(); 55 | return NextResponse.json(familyData); 56 | } else { 57 | // 如果没有指定类型或类型无效,返回错误 58 | return NextResponse.json({ error: 'Invalid config type' }, { status: 400 }); 59 | } 60 | } catch (error) { 61 | console.error('获取配置时出错:', error); 62 | return NextResponse.json({ error: 'Failed to load config' }, { status: 500 }); 63 | } 64 | } -------------------------------------------------------------------------------- /src/app/api/family-data/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | 5 | export async function GET() { 6 | try { 7 | const configPath = path.join(process.cwd(), 'config', 'family-data.json'); 8 | 9 | // 检查文件是否存在 10 | if (!fs.existsSync(configPath)) { 11 | console.warn('family-data.json not found, returning empty data'); 12 | return NextResponse.json({ 13 | generations: [] 14 | }); 15 | } 16 | 17 | // 读取并解析文件 18 | const fileContent = fs.readFileSync(configPath, 'utf8'); 19 | const data = JSON.parse(fileContent); 20 | return NextResponse.json(data); 21 | } catch (error) { 22 | console.error('Error loading family data:', error); 23 | return NextResponse.json( 24 | { error: 'Failed to load family data' }, 25 | { status: 500 } 26 | ); 27 | } 28 | } -------------------------------------------------------------------------------- /src/app/components/FamilyTree.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { FamilyData, Person } from '@/types/family'; 5 | import { UserIcon, CalendarIcon, UserGroupIcon, ChevronDownIcon, ChevronUpIcon } from '@heroicons/react/24/outline'; 6 | import { highlightMatch } from '@/utils/search'; 7 | 8 | interface FamilyTreeProps { 9 | familyData: FamilyData; 10 | searchTerm?: string; 11 | searchInInfo?: boolean; 12 | } 13 | 14 | // 创建一个映射,用于快速查找人物 15 | const createPersonMap = (data: FamilyData) => { 16 | const map = new Map(); 17 | data.generations.forEach(generation => { 18 | generation.people.forEach(person => { 19 | if (person.id) { 20 | map.set(person.id, person); 21 | } 22 | }); 23 | }); 24 | return map; 25 | }; 26 | 27 | // 创建一个映射,用于查找一个人的所有儿子 28 | const createSonsMap = (data: FamilyData) => { 29 | const map = new Map(); 30 | 31 | // 初始化每个人的儿子数组 32 | data.generations.forEach(generation => { 33 | generation.people.forEach(person => { 34 | if (person.id) { 35 | map.set(person.id, []); 36 | } 37 | }); 38 | }); 39 | 40 | // 根据 fatherId 填充儿子数组(包含所有儿子) 41 | data.generations.forEach(generation => { 42 | generation.people.forEach(person => { 43 | // 任何有fatherId的人都被认为是其父亲的儿子 44 | if (person.fatherId && map.has(person.fatherId)) { 45 | const sons = map.get(person.fatherId) || []; 46 | sons.push(person); 47 | map.set(person.fatherId, sons); 48 | } 49 | }); 50 | }); 51 | 52 | return map; 53 | }; 54 | 55 | const PersonCard = ({ 56 | person, 57 | personMap, 58 | sonsMap, 59 | scrollToPerson, 60 | searchTerm, 61 | searchInInfo 62 | }: { 63 | person: Person; 64 | personMap: Map; 65 | sonsMap: Map; 66 | scrollToPerson: (personId: string) => void; 67 | searchTerm?: string; 68 | searchInInfo?: boolean; 69 | }) => { 70 | const [expanded, setExpanded] = useState(false); 71 | const father = person.fatherId ? personMap.get(person.fatherId) : undefined; 72 | const sons = person.id ? sonsMap.get(person.id) || [] : []; 73 | 74 | const toggleExpand = (e: React.MouseEvent) => { 75 | // 防止点击按钮时触发卡片展开 76 | if ((e.target as HTMLElement).tagName === 'BUTTON' || 77 | (e.target as HTMLElement).closest('button')) { 78 | return; 79 | } 80 | setExpanded(!expanded); 81 | }; 82 | 83 | return ( 84 |
89 |
90 |
91 |
92 |
93 |
94 | 95 |
96 |

97 | 100 |

101 |
102 |
103 | {expanded ? 104 | : 105 | 106 | } 107 |
108 |
109 | 110 | {father && ( 111 |
112 | 113 | 父亲: 114 | 125 |
126 | )} 127 | 128 | {sons.length > 0 && ( 129 |
130 | 131 | 子嗣: 132 | {sons.map((son, index) => ( 133 | 134 | 145 | {index < sons.length - 1 && } 146 | 147 | ))} 148 |
149 | )} 150 | 151 |

152 | 155 |

156 | {(person.birthYear || person.deathYear) && ( 157 |
158 | 159 | 160 | {person.birthYear} 161 | {person.birthYear && person.deathYear && ' - '} 162 | {person.deathYear && (person.birthYear ? person.deathYear : ` - ${person.deathYear}`)} 163 | 164 |
165 | )} 166 |
167 |
168 | ); 169 | }; 170 | 171 | const Generation = ({ 172 | title, 173 | people, 174 | personMap, 175 | sonsMap, 176 | scrollToPerson, 177 | searchTerm, 178 | searchInInfo 179 | }: { 180 | title: string; 181 | people: Person[]; 182 | personMap: Map; 183 | sonsMap: Map; 184 | scrollToPerson: (personId: string) => void; 185 | searchTerm?: string; 186 | searchInInfo?: boolean; 187 | }) => { 188 | return ( 189 |
190 |
191 |

192 | {title} 193 |

194 |
195 |
196 |
197 | {people.map((person, index) => ( 198 | 207 | ))} 208 |
209 |
210 | ); 211 | }; 212 | 213 | export default function FamilyTree({ familyData, searchTerm, searchInInfo }: FamilyTreeProps) { 214 | const [personMap, setPersonMap] = useState>(new Map()); 215 | const [sonsMap, setSonsMap] = useState>(new Map()); 216 | 217 | useEffect(() => { 218 | setPersonMap(createPersonMap(familyData)); 219 | setSonsMap(createSonsMap(familyData)); 220 | }, [familyData]); 221 | 222 | const scrollToPerson = (personId: string) => { 223 | const element = document.getElementById(`person-${personId}`); 224 | if (element) { 225 | element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 226 | // 添加一个临时高亮效果 227 | element.classList.add('ring-2', 'ring-blue-500'); 228 | setTimeout(() => { 229 | element.classList.remove('ring-2', 'ring-blue-500'); 230 | }, 2000); 231 | } 232 | }; 233 | 234 | return ( 235 |
236 | {familyData.generations.map((generation, index) => ( 237 | 247 | ))} 248 |
249 | ); 250 | } -------------------------------------------------------------------------------- /src/app/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from 'next/link'; 4 | import { getFamilyFullName } from '@/utils/config'; 5 | 6 | const Footer = () => { 7 | // 获取完整姓氏名称(带"氏"字) 8 | const familyFullName = getFamilyFullName(); 9 | 10 | return ( 11 |
12 |
13 |
14 | {/* 左侧 - 关于 */} 15 |
16 |

关于族谱

17 |

18 | {familyFullName}族谱是一个记录{familyFullName}家族历史和传承的网站,旨在保存家族记忆,传承家族文化。 19 |

20 |
21 | 22 | {/* 中间 - 开源信息 */} 23 |
24 |

开源信息

25 |

26 | 本项目是开源的家族谱系软件,欢迎在GitHub上查看。 27 |

28 |

29 | 35 | GitHub仓库 36 | 37 |

38 |
39 | 40 | {/* 右侧 - 友情链接 */} 41 |
42 |

友情链接

43 |
    44 |
  • 45 | 51 | FateMaster.AI 52 | 53 |

    54 | AI八字算命网站,提供智能化命理分析服务 55 |

    56 |
  • 57 |
58 |
59 |
60 | 61 | {/* 底部版权信息 */} 62 |
63 |

64 | © {new Date().getFullYear()} 家族谱系项目 - MIT开源协议 65 |

66 |
67 |
68 |
69 | ); 70 | }; 71 | 72 | export default Footer; -------------------------------------------------------------------------------- /src/app/components/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from 'react'; 4 | import { UserIcon } from '@heroicons/react/24/outline'; 5 | import { getFamilyFullName } from '@/utils/config'; 6 | import Link from 'next/link'; 7 | 8 | interface LoginFormProps { 9 | onLoginSuccess: () => void; 10 | } 11 | 12 | export default function LoginForm({ onLoginSuccess }: LoginFormProps) { 13 | const [name, setName] = useState(''); 14 | const [error, setError] = useState(''); 15 | const [loading, setLoading] = useState(false); 16 | const [debug, setDebug] = useState(''); 17 | // 获取完整姓氏名称(带"氏"字) 18 | const familyFullName = getFamilyFullName(); 19 | 20 | const handleSubmit = async (e: React.FormEvent) => { 21 | e.preventDefault(); 22 | setError(''); 23 | setDebug(''); 24 | setLoading(true); 25 | 26 | try { 27 | const response = await fetch('/api/auth', { 28 | method: 'POST', 29 | headers: { 30 | 'Content-Type': 'application/json', 31 | }, 32 | body: JSON.stringify({ name }), 33 | }); 34 | 35 | const data = await response.json(); 36 | 37 | if (data.success) { 38 | setDebug(`登录成功!欢迎 ${name}`); 39 | // 保存token到localStorage 40 | localStorage.setItem('auth_token', data.token); 41 | localStorage.setItem('auth_time', Date.now().toString()); 42 | onLoginSuccess(); 43 | } else { 44 | // 登录失败时只显示错误消息,不显示调试信息 45 | setError(data.message || '验证失败,请检查姓名'); 46 | } 47 | } catch (err) { 48 | console.error('验证请求错误:', err); 49 | setError('验证过程中出现错误,请重试'); 50 | } finally { 51 | setLoading(false); 52 | } 53 | }; 54 | 55 | // 添加一个辅助函数,显示有效的用户信息(仅用于调试) 56 | const showHint = () => { 57 | setDebug('请输入家谱中的人名登录'); 58 | }; 59 | 60 | return ( 61 |
62 |
63 |
64 |

{familyFullName}家谱

65 |

66 | 请输入姓名进行验证 67 |

68 |
69 |
70 |
71 |
72 | 73 |
74 |
75 | 76 |
77 | setName(e.target.value)} 86 | /> 87 |
88 |
89 |
90 | 91 | {error && ( 92 |
{error}
93 | )} 94 | 95 |
96 | 103 |
104 | 105 |
106 | 113 |
114 | 115 | {debug && ( 116 |
117 | {debug} 118 |
119 | )} 120 | 121 |
122 |

123 | 这是一个开源项目,访问我们的 124 | 130 | GitHub仓库 131 | 132 | 133 | 134 | 135 |

136 |
137 |
138 |
139 |
140 | ); 141 | } -------------------------------------------------------------------------------- /src/app/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; 4 | import { MagnifyingGlassIcon, XMarkIcon, AdjustmentsHorizontalIcon } from '@heroicons/react/24/outline'; 5 | import { UI_CONFIG } from '@/utils/constants'; 6 | 7 | interface SearchBarProps { 8 | onSearch: (searchTerm: string, filters: SearchFilters) => void; 9 | generations: string[]; 10 | } 11 | 12 | export interface SearchFilters { 13 | searchTerm: string; 14 | searchInInfo: boolean; 15 | selectedGenerations: string[]; 16 | yearRange: { 17 | start?: number; 18 | end?: number; 19 | }; 20 | } 21 | 22 | export default function SearchBar({ onSearch, generations }: SearchBarProps) { 23 | const [searchTerm, setSearchTerm] = useState(''); 24 | const [showFilters, setShowFilters] = useState(false); 25 | const [filters, setFilters] = useState({ 26 | searchTerm: '', 27 | searchInInfo: true, 28 | selectedGenerations: [], 29 | yearRange: {} 30 | }); 31 | 32 | const filtersPanelRef = useRef(null); 33 | 34 | // 使用useCallback优化函数 35 | const handleSearch = useCallback((newSearchTerm?: string, newFilters?: Partial) => { 36 | const currentSearchTerm = newSearchTerm !== undefined ? newSearchTerm : searchTerm; 37 | const currentFilters = { ...filters, ...newFilters, searchTerm: currentSearchTerm }; 38 | setFilters(currentFilters); 39 | onSearch(currentSearchTerm, currentFilters); 40 | }, [searchTerm, filters, onSearch]); 41 | 42 | const clearSearch = useCallback(() => { 43 | setSearchTerm(''); 44 | const clearedFilters = { 45 | searchTerm: '', 46 | searchInInfo: true, 47 | selectedGenerations: [], 48 | yearRange: {} 49 | }; 50 | setFilters(clearedFilters); 51 | setShowFilters(false); 52 | onSearch('', clearedFilters); 53 | }, [onSearch]); 54 | 55 | const toggleGeneration = useCallback((generation: string) => { 56 | const newSelectedGenerations = filters.selectedGenerations.includes(generation) 57 | ? filters.selectedGenerations.filter(g => g !== generation) 58 | : [...filters.selectedGenerations, generation]; 59 | 60 | handleSearch(undefined, { selectedGenerations: newSelectedGenerations }); 61 | }, [filters.selectedGenerations, handleSearch]); 62 | 63 | // 使用useMemo缓存计算结果 64 | const hasActiveFilters = useMemo(() => 65 | filters.selectedGenerations.length > 0 || 66 | Boolean(filters.yearRange.start) || 67 | Boolean(filters.yearRange.end) || 68 | !filters.searchInInfo 69 | , [filters]); 70 | 71 | // 点击外部关闭筛选面板 72 | useEffect(() => { 73 | const handleClickOutside = (event: MouseEvent) => { 74 | if (filtersPanelRef.current && !filtersPanelRef.current.contains(event.target as Node)) { 75 | setShowFilters(false); 76 | } 77 | }; 78 | 79 | if (showFilters) { 80 | document.addEventListener('mousedown', handleClickOutside); 81 | } 82 | 83 | return () => { 84 | document.removeEventListener('mousedown', handleClickOutside); 85 | }; 86 | }, [showFilters]); 87 | 88 | return ( 89 |
90 | {/* 与按钮组统一风格的搜索框 */} 91 |
92 |
93 | 94 |
95 | { 101 | setSearchTerm(e.target.value); 102 | handleSearch(e.target.value); 103 | }} 104 | /> 105 | {searchTerm && ( 106 | 112 | )} 113 |
114 | 115 | {/* 筛选按钮 - 与视图按钮风格一致 */} 116 | 129 | 130 | {/* 筛选面板 */} 131 | {showFilters && ( 132 |
133 |
134 | {/* 搜索选项 */} 135 |
136 | 包含详细信息 137 | handleSearch(undefined, { searchInInfo: e.target.checked })} 141 | className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" 142 | /> 143 |
144 | 145 | {/* 世代筛选 */} 146 | {generations.length > 0 && ( 147 |
148 |

世代

149 |
150 | {generations.map((generation) => ( 151 | 162 | ))} 163 |
164 |
165 | )} 166 | 167 | {/* 年份范围 */} 168 |
169 |

年份

170 |
171 | handleSearch(undefined, { 177 | yearRange: { ...filters.yearRange, start: e.target.value ? parseInt(e.target.value) : undefined } 178 | })} 179 | /> 180 | - 181 | handleSearch(undefined, { 187 | yearRange: { ...filters.yearRange, end: e.target.value ? parseInt(e.target.value) : undefined } 188 | })} 189 | /> 190 |
191 |
192 | 193 | {hasActiveFilters && ( 194 |
195 | 201 |
202 | )} 203 |
204 |
205 | )} 206 |
207 | ); 208 | } -------------------------------------------------------------------------------- /src/app/components/TreeView.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useEffect, useRef } from 'react'; 4 | import { FamilyData, Person } from '@/types/family'; 5 | import { ChevronDownIcon, ChevronRightIcon, UserIcon, CalendarIcon } from '@heroicons/react/24/outline'; 6 | import { highlightMatch } from '@/utils/search'; 7 | import { ANIMATION_DELAYS, CSS_CLASSES } from '@/utils/constants'; 8 | 9 | interface TreeViewProps { 10 | data: FamilyData; 11 | searchTerm?: string; 12 | searchInInfo?: boolean; 13 | } 14 | 15 | interface TreeNodeProps { 16 | person: Person; 17 | level: number; 18 | searchTerm?: string; 19 | searchInInfo?: boolean; 20 | firstMatchId?: string | null; 21 | } 22 | 23 | // 检查是否匹配搜索条件 24 | const isPersonMatch = (person: Person, searchTerm: string, searchInInfo: boolean): boolean => { 25 | if (!searchTerm) return false; 26 | 27 | const lowerSearchTerm = searchTerm.toLowerCase(); 28 | const nameMatch = person.name.toLowerCase().includes(lowerSearchTerm); 29 | const infoMatch = searchInInfo && person.info && person.info.toLowerCase().includes(lowerSearchTerm); 30 | const yearMatch = (person.birthYear?.toString().includes(lowerSearchTerm) || false) || 31 | (person.deathYear?.toString().includes(lowerSearchTerm) || false); 32 | 33 | return nameMatch || !!infoMatch || yearMatch; 34 | }; 35 | 36 | const TreeNode = ({ person, level, searchTerm, searchInInfo, firstMatchId }: TreeNodeProps) => { 37 | const [isExpanded, setIsExpanded] = useState(true); 38 | const [isHighlighted, setIsHighlighted] = useState(false); 39 | const nodeRef = useRef(null); 40 | const timeoutRefs = useRef<(NodeJS.Timeout | null)[]>([]); 41 | const hasChildren = person.children && person.children.length > 0; 42 | const isFirstMatch = person.id === firstMatchId; 43 | 44 | const toggleExpand = () => { 45 | setIsExpanded(!isExpanded); 46 | }; 47 | 48 | // 清理所有timeout的函数 49 | const clearAllTimeouts = () => { 50 | timeoutRefs.current.forEach(timeout => { 51 | if (timeout) { 52 | clearTimeout(timeout); 53 | } 54 | }); 55 | timeoutRefs.current = []; 56 | }; 57 | 58 | // 如果是第一个匹配项,滚动到该位置 - 修复内存泄漏 59 | useEffect(() => { 60 | if (isFirstMatch && nodeRef.current) { 61 | // 清理之前的timeout 62 | clearAllTimeouts(); 63 | 64 | const scrollTimeout = setTimeout(() => { 65 | if (nodeRef.current) { 66 | nodeRef.current.scrollIntoView({ 67 | behavior: 'smooth', 68 | block: 'center' 69 | }); 70 | // 使用React state代替直接DOM操作 71 | setIsHighlighted(true); 72 | 73 | const highlightTimeout = setTimeout(() => { 74 | setIsHighlighted(false); 75 | }, ANIMATION_DELAYS.HIGHLIGHT_DURATION); 76 | 77 | timeoutRefs.current.push(highlightTimeout); 78 | } 79 | }, ANIMATION_DELAYS.SCROLL_TO_MATCH); 80 | 81 | timeoutRefs.current.push(scrollTimeout); 82 | } 83 | 84 | // cleanup函数 - 防止内存泄漏 85 | return () => { 86 | clearAllTimeouts(); 87 | }; 88 | }, [isFirstMatch]); 89 | 90 | return ( 91 |
92 |
101 | {hasChildren ? ( 102 |
103 | {isExpanded ? ( 104 | 105 | ) : ( 106 | 107 | )} 108 |
109 | ) : ( 110 |
111 | )} 112 | 113 |
114 |
115 | 116 |
117 |
118 | 119 | 122 | 123 | {person.info && ( 124 |

125 | 128 |

129 | )} 130 | {(person.birthYear || person.deathYear) && ( 131 |
132 | 133 | 134 | {person.birthYear} 135 | {person.birthYear && person.deathYear && ' - '} 136 | {person.deathYear && (person.birthYear ? person.deathYear : ` - ${person.deathYear}`)} 137 | 138 |
139 | )} 140 |
141 |
142 |
143 | 144 | {hasChildren && isExpanded && ( 145 |
146 | {person.children?.map((child, index) => ( 147 | 155 | ))} 156 |
157 | )} 158 |
159 | ); 160 | }; 161 | 162 | // 递归查找所有匹配的人员 163 | const findAllMatches = (person: Person, searchTerm: string, searchInInfo: boolean): Person[] => { 164 | const matches: Person[] = []; 165 | 166 | if (isPersonMatch(person, searchTerm, searchInInfo)) { 167 | matches.push(person); 168 | } 169 | 170 | if (person.children) { 171 | person.children.forEach(child => { 172 | matches.push(...findAllMatches(child, searchTerm, searchInInfo)); 173 | }); 174 | } 175 | 176 | return matches; 177 | }; 178 | 179 | export default function TreeView({ data, searchTerm, searchInInfo }: TreeViewProps) { 180 | const [firstMatchId, setFirstMatchId] = useState(null); 181 | // 找到第一代人物作为树的根节点 182 | const rootPeople = data.generations[0]?.people || []; 183 | 184 | // 找到第一个匹配项 185 | useEffect(() => { 186 | if (searchTerm) { 187 | const allMatches: Person[] = []; 188 | rootPeople.forEach(person => { 189 | allMatches.push(...findAllMatches(person, searchTerm, searchInInfo || false)); 190 | }); 191 | 192 | if (allMatches.length > 0) { 193 | setFirstMatchId(allMatches[0].id || null); 194 | } else { 195 | setFirstMatchId(null); 196 | } 197 | } else { 198 | setFirstMatchId(null); 199 | } 200 | }, [searchTerm, searchInInfo, rootPeople]); 201 | 202 | return ( 203 |
204 |
205 |

家族树状图

206 |
207 | {rootPeople.map((person, index) => ( 208 | 216 | ))} 217 |
218 |
219 |
220 | ); 221 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/qiaoshouqing/familytree/3d40a0b86cd68dc187d46e0371b4c9a67e614a2c/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | :root { 4 | --background: #ffffff; 5 | --foreground: #171717; 6 | } 7 | 8 | @theme inline { 9 | --color-background: var(--background); 10 | --color-foreground: var(--foreground); 11 | --font-sans: var(--font-geist-sans); 12 | --font-mono: var(--font-geist-mono); 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | :root { 17 | --background: #0a0a0a; 18 | --foreground: #ededed; 19 | } 20 | } 21 | 22 | body { 23 | background: var(--background); 24 | color: var(--foreground); 25 | font-family: Arial, Helvetica, sans-serif; 26 | } 27 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import Script from "next/script"; 5 | 6 | const geistSans = Geist({ 7 | variable: "--font-geist-sans", 8 | subsets: ["latin"], 9 | }); 10 | 11 | const geistMono = Geist_Mono({ 12 | variable: "--font-geist-mono", 13 | subsets: ["latin"], 14 | }); 15 | 16 | // 从环境变量中获取姓氏 17 | const familyName = process.env.NEXT_PUBLIC_FAMILY_NAME || '姓氏'; 18 | // 从环境变量中获取谷歌统计ID 19 | const googleAnalyticsId = process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID; 20 | 21 | export const metadata: Metadata = { 22 | title: `${familyName}氏族谱`, 23 | description: `${familyName}氏家族族谱记录`, 24 | robots: { 25 | index: false, 26 | follow: false, 27 | nocache: true, 28 | googleBot: { 29 | index: false, 30 | follow: false, 31 | noimageindex: true, 32 | }, 33 | }, 34 | }; 35 | 36 | export default function RootLayout({ 37 | children, 38 | }: Readonly<{ 39 | children: React.ReactNode; 40 | }>) { 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 50 | {children} 51 | 52 | {/* Google Analytics - 仅在ID存在时加载 */} 53 | {googleAnalyticsId && ( 54 | <> 55 |