chore: sync remaining local changes to Gitea
This commit is contained in:
178
AGENTS.md
178
AGENTS.md
@@ -7,28 +7,174 @@ STOP. Your knowledge of Cloudflare Workers APIs and limits may be outdated. Alwa
|
||||
- https://developers.cloudflare.com/workers/
|
||||
- MCP: `https://docs.mcp.cloudflare.com/mcp`
|
||||
|
||||
For all limits and quotas, retrieve from the product's `/platform/limits/` page. eg. `/workers/platform/limits`
|
||||
For all limits and quotas, retrieve from the product's `/platform/limits/` page. e.g. `/workers/platform/limits`
|
||||
|
||||
## Commands
|
||||
## Product Docs
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npx wrangler dev` | Local development |
|
||||
| `npx wrangler deploy` | Deploy to Cloudflare |
|
||||
| `npx wrangler types` | Generate TypeScript types |
|
||||
|
||||
Run `wrangler types` after changing bindings in wrangler.jsonc.
|
||||
|
||||
## Node.js Compatibility
|
||||
|
||||
https://developers.cloudflare.com/workers/runtime-apis/nodejs/
|
||||
Retrieve API references and limits from:
|
||||
`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`
|
||||
|
||||
## Errors
|
||||
|
||||
- **Error 1102** (CPU/Memory exceeded): Retrieve limits from `/workers/platform/limits/`
|
||||
- **All errors**: https://developers.cloudflare.com/workers/observability/errors/
|
||||
|
||||
## Product Docs
|
||||
---
|
||||
|
||||
Retrieve API references and limits from:
|
||||
`/kv/` · `/r2/` · `/d1/` · `/durable-objects/` · `/queues/` · `/vectorize/` · `/workers-ai/` · `/agents/`
|
||||
# Project: tangfamily
|
||||
|
||||
A Cloudflare Workers full-stack family tree website. The single Worker at `src/index.ts` serves all API routes; static HTML pages in `public/` are served via Cloudflare's asset pipeline (`env.ASSETS`). Data is stored in a KV namespace (`FAMILY_DATA`). Deployed at https://tangfamily.shumengya666.workers.dev.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `npm run dev` | Start local dev server (http://127.0.0.1:8787) |
|
||||
| `npm run deploy` | Deploy to Cloudflare |
|
||||
| `npm test` | Run all tests (vitest watch mode) |
|
||||
| `npm run cf-typegen` | Regenerate `worker-configuration.d.ts` from `wrangler.jsonc` |
|
||||
|
||||
**Run a single test file:**
|
||||
```bash
|
||||
npx vitest run test/index.spec.ts
|
||||
```
|
||||
|
||||
**Run a single test by name:**
|
||||
```bash
|
||||
npx vitest run -t "test name here"
|
||||
```
|
||||
|
||||
Always run `npm run cf-typegen` after changing any bindings in `wrangler.jsonc`.
|
||||
|
||||
There is no lint script. Format with Prettier manually if needed:
|
||||
```bash
|
||||
npx prettier --write "src/**/*.ts"
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/index.ts # Single-file Worker: all API handlers + routing + CORS
|
||||
public/index.html # Visitor login page
|
||||
public/tree.html # Family tree viewer (authenticated visitors)
|
||||
public/admin-login.html # Admin login
|
||||
public/admin.html # Admin CRUD panel
|
||||
test/index.spec.ts # Vitest integration tests (runs inside Workers runtime)
|
||||
test/tsconfig.json # Extends root tsconfig; adds vitest-pool-workers types
|
||||
test/env.d.ts # Merges Env into cloudflare:test module
|
||||
vitest.config.mts # Vitest config — uses defineWorkersConfig + wrangler.jsonc
|
||||
wrangler.jsonc # Worker config: entry, KV binding, assets, compat flags
|
||||
worker-configuration.d.ts # Auto-generated by cf-typegen — do not edit manually
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- All API logic lives in `src/index.ts`; there are no other Worker source files.
|
||||
- `public/*.html` pages are pure HTML with inline CSS and inline `<script>` — no bundler, no frameworks.
|
||||
- KV stores two kinds of keys:
|
||||
- `members` → JSON-serialised `FamilyMember[]`
|
||||
- `session:{uuid}` → JSON `{ isAdmin: boolean, createdAt: number }`, TTL 24h
|
||||
- Auth is token-based: login returns a UUID token stored in KV; the client stores it in `localStorage`.
|
||||
- CORS headers are applied in the main `fetch` dispatcher, not inside individual handlers.
|
||||
- Static assets are served by `env.ASSETS.fetch(request)` as the default fallback.
|
||||
|
||||
## API Routes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| `POST` | `/api/login` | none | Returns session token |
|
||||
| `GET` | `/api/members` | visitor+ | List all members |
|
||||
| `POST` | `/api/members` | admin | Add or update a member |
|
||||
| `POST` | `/api/members/delete` | admin | Delete a member by `id` |
|
||||
|
||||
## Data Shape
|
||||
|
||||
```typescript
|
||||
interface FamilyMember {
|
||||
id: string;
|
||||
name: string;
|
||||
birthYear: number;
|
||||
gender: '男' | '女';
|
||||
generation?: string;
|
||||
fatherId?: string;
|
||||
motherId?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Node.js Compatibility
|
||||
|
||||
https://developers.cloudflare.com/workers/runtime-apis/nodejs/
|
||||
|
||||
The `nodejs_compat` flag is enabled. The `global_fetch_strictly_public` flag is also active — `fetch()` cannot reach private/internal addresses.
|
||||
|
||||
## TypeScript
|
||||
|
||||
- **`strict: true`** — all strict checks are on; do not disable any of them.
|
||||
- **`target/lib: es2024`** — use modern JS features freely.
|
||||
- **`moduleResolution: "Bundler"`** — do not use Node-style `require()` or `.js` extensions in imports.
|
||||
- **`noEmit: true`** — Wrangler compiles; `tsc` is for type-checking only.
|
||||
- **`isolatedModules: true`** — every file must be independently transpilable; avoid type-only re-exports without the `type` keyword.
|
||||
- Prefer `interface` over `type` for object shapes.
|
||||
- Use `satisfies` instead of `as` for the default Worker export: `} satisfies ExportedHandler<Env>`.
|
||||
- Cast JSON parse results with `as`: `await request.json() as MyType`.
|
||||
- Use literal union types for constrained values: `gender: '男' | '女'`.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Formatting (Prettier + EditorConfig)
|
||||
- **Indentation:** tabs (not spaces), everywhere except YAML.
|
||||
- **Line endings:** LF.
|
||||
- **Quotes:** single quotes in TypeScript/JS.
|
||||
- **Semicolons:** always.
|
||||
- **Print width:** 140 characters.
|
||||
- Trim trailing whitespace; always end files with a newline.
|
||||
|
||||
### Naming Conventions
|
||||
- **Functions:** `camelCase`; request handlers prefixed with `handle` (`handleLogin`, `handleGetMembers`).
|
||||
- **Interfaces/Types:** `PascalCase` (`FamilyMember`, `Env`).
|
||||
- **Constants:** `SCREAMING_SNAKE_CASE` (`VISITOR_PASSWORD`, `ADMIN_PASSWORD`).
|
||||
- **Variables:** `camelCase` (`membersJson`, `sessionData`, `corsHeaders`).
|
||||
- **KV keys:** lowercase with colon-separated namespaces (`session:{uuid}`, `members`).
|
||||
- **HTML element IDs:** `camelCase` (`membersList`, `memberBirthYear`).
|
||||
- **CSS classes:** `kebab-case` (`member-card`, `btn-danger`).
|
||||
|
||||
### Imports
|
||||
- Worker source (`src/`) has **no imports** — use Workers runtime globals only (`crypto`, `KVNamespace`, `Request`, `Response`, `Headers`, `URL`, `ExecutionContext`, `ExportedHandler`).
|
||||
- In tests, import from `cloudflare:test` and `vitest` first, then local modules.
|
||||
- No path aliases. Use relative paths (`../src`).
|
||||
|
||||
### Error Handling
|
||||
- All async handlers use `try/catch`.
|
||||
- The `catch` block returns a structured JSON error response with an appropriate HTTP status code.
|
||||
- Do not log or inspect the caught `error` variable in Worker code — translate to a user-facing message in Chinese.
|
||||
- Return `401` for unauthenticated, `403` for unauthorised (missing admin), `400` for bad input, `404` for unknown routes, `405` for wrong method.
|
||||
|
||||
```typescript
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ success: false, error: '操作失败' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Response Pattern
|
||||
- Construct JSON responses with `new Response(JSON.stringify({...}), { headers: { 'Content-Type': 'application/json' } })`.
|
||||
- Do not add CORS headers inside handlers — they are merged in the main dispatcher.
|
||||
- Return `null` body with `{ headers: corsHeaders }` for `OPTIONS` preflight.
|
||||
|
||||
### Async/Await
|
||||
- All handlers are `async function`, returning `Promise<Response>`.
|
||||
- Use `async/await` exclusively — no `.then()/.catch()` chains.
|
||||
- KV reads are awaited sequentially; use `Promise.all` only when operations are truly independent.
|
||||
- Call `await verifyToken(...)` as the first gate in every protected handler.
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests run inside the real Cloudflare Workers runtime via `@cloudflare/vitest-pool-workers` — not Node.js.
|
||||
- `vitest.config.mts` uses `defineWorkersConfig` and points at `wrangler.jsonc` for bindings.
|
||||
- The `test/` directory has its own `tsconfig.json` (extends root, adds `@cloudflare/vitest-pool-workers` types).
|
||||
- Use `env` from `cloudflare:test` to access bindings directly; use `SELF.fetch()` for integration-style HTTP requests.
|
||||
- Type-cast `.json()` results: `await res.json() as { token: string }`.
|
||||
- Do not add test-only logic to `src/index.ts`.
|
||||
- After adding new bindings to `wrangler.jsonc`, run `npm run cf-typegen` before writing tests.
|
||||
|
||||
117
DEPLOYMENT.md
Normal file
117
DEPLOYMENT.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# 部署成功!
|
||||
|
||||
你的唐家族谱网站已成功部署到 Cloudflare Workers!
|
||||
|
||||
## 访问地址
|
||||
|
||||
**主网站**: https://tangfamily.shumengya666.workers.dev
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 管理员首次登录
|
||||
|
||||
访问管理员登录页面:
|
||||
https://tangfamily.shumengya666.workers.dev/admin-login.html
|
||||
|
||||
- 管理员密码:`shumengya5201314`
|
||||
|
||||
### 2. 添加家族成员
|
||||
|
||||
登录管理后台后,点击"添加成员"按钮,建议按以下顺序添加初始成员:
|
||||
|
||||
#### 第一代(父母)
|
||||
1. **唐治明**
|
||||
- 出生年份:1950(请根据实际修改)
|
||||
- 性别:男
|
||||
- 父亲:无
|
||||
- 母亲:无
|
||||
|
||||
2. **舒邦莲**
|
||||
- 出生年份:1952(请根据实际修改)
|
||||
- 性别:女
|
||||
- 父亲:无
|
||||
- 母亲:无
|
||||
|
||||
#### 第二代(子女)
|
||||
3. **唐永洪**
|
||||
- 出生年份:1975(请根据实际修改)
|
||||
- 性别:男
|
||||
- 父亲:选择"唐治明"
|
||||
- 母亲:选择"舒邦莲"
|
||||
|
||||
4. **唐伟**
|
||||
- 出生年份:1980(请根据实际修改)
|
||||
- 性别:男
|
||||
- 父亲:选择"唐治明"
|
||||
- 母亲:选择"舒邦莲"
|
||||
|
||||
### 3. 访客访问
|
||||
|
||||
添加成员后,分享主页地址给家族成员:
|
||||
https://tangfamily.shumengya666.workers.dev
|
||||
|
||||
- 访客密码:`tangfamily`
|
||||
|
||||
## 页面说明
|
||||
|
||||
| 页面 | 地址 | 密码 | 说明 |
|
||||
|------|------|------|------|
|
||||
| 访客登录 | `/` | `tangfamily` | 家族成员登录查看族谱 |
|
||||
| 族谱树 | `/tree.html` | 需先登录 | 显示家族树状图 |
|
||||
| 管理员登录 | `/admin-login.html` | `shumengya5201314` | 管理员登录入口 |
|
||||
| 管理后台 | `/admin.html` | 需管理员权限 | 添加、编辑、删除成员 |
|
||||
|
||||
## 管理功能
|
||||
|
||||
在管理后台可以:
|
||||
- ✅ 添加新成员
|
||||
- ✅ 编辑现有成员信息
|
||||
- ✅ 删除成员
|
||||
- ✅ 设置父母关系
|
||||
|
||||
## 自定义域名(可选)
|
||||
|
||||
如果你想使用自己的域名(如 tangfamily.com),可以按以下步骤操作:
|
||||
|
||||
1. 在 Cloudflare 添加你的域名
|
||||
2. 在 Workers 设置中添加自定义域名路由
|
||||
3. 更新 DNS 记录
|
||||
|
||||
详见:https://developers.cloudflare.com/workers/configuration/routing/custom-domains/
|
||||
|
||||
## 修改密码
|
||||
|
||||
如需修改默认密码,编辑 `src/index.ts` 文件:
|
||||
|
||||
```typescript
|
||||
const VISITOR_PASSWORD = 'tangfamily'; // 访客密码
|
||||
const ADMIN_PASSWORD = 'shumengya5201314'; // 管理员密码
|
||||
```
|
||||
|
||||
然后重新部署:
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## 数据备份
|
||||
|
||||
你可以使用以下命令备份 KV 数据:
|
||||
|
||||
```bash
|
||||
# 导出所有成员数据
|
||||
npx wrangler kv key get "members" --binding=FAMILY_DATA --namespace-id=07e25767617e469aa297cdf79ceeb010 > backup.json
|
||||
```
|
||||
|
||||
## 技术支持
|
||||
|
||||
- Cloudflare Workers 文档:https://developers.cloudflare.com/workers/
|
||||
- Cloudflare KV 文档:https://developers.cloudflare.com/kv/
|
||||
|
||||
## 下一步
|
||||
|
||||
1. 访问管理后台添加家族成员
|
||||
2. 测试访客登录和族谱查看功能
|
||||
3. 分享网站给家族成员
|
||||
4. 考虑绑定自定义域名
|
||||
|
||||
祝使用愉快!🎉
|
||||
140
README.md
Normal file
140
README.md
Normal file
@@ -0,0 +1,140 @@
|
||||
# 唐家族谱网站
|
||||
|
||||
基于 Cloudflare Workers 的全栈家族族谱网站,使用树状图展示家族成员关系。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 密码保护访问(访问密码:`tangfamily`)
|
||||
- 响应式设计,支持电脑端和手机端
|
||||
- 树状图展示家族成员信息
|
||||
- 管理后台用于添加、编辑、删除成员(管理密码:`shumengya5201314`)
|
||||
- 数据存储在 Cloudflare KV
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
tangfamily/
|
||||
├── src/
|
||||
│ ├── index.ts # Worker 主文件,包含 API 路由
|
||||
│ └── init-data.js # 初始化数据脚本
|
||||
├── public/
|
||||
│ ├── index.html # 访客登录页面
|
||||
│ ├── tree.html # 族谱树展示页面
|
||||
│ ├── admin-login.html # 管理员登录页面
|
||||
│ └── admin.html # 管理后台页面
|
||||
├── wrangler.jsonc # Cloudflare Workers 配置
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
1. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 启动开发服务器:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. 访问 http://localhost:8787/
|
||||
|
||||
## 初始化数据
|
||||
|
||||
项目已包含初始家族成员数据(唐治明、舒邦莲、唐永洪、唐伟)。
|
||||
|
||||
### 方式一:通过管理后台添加
|
||||
|
||||
1. 访问 http://localhost:8787/admin-login.html
|
||||
2. 输入管理员密码:`shumengya5201314`
|
||||
3. 在管理后台逐个添加成员
|
||||
|
||||
### 方式二:查看初始化数据
|
||||
|
||||
运行以下命令查看初始化数据:
|
||||
```bash
|
||||
node src/init-data.js
|
||||
```
|
||||
|
||||
## 访问说明
|
||||
|
||||
### 访客访问
|
||||
1. 访问首页
|
||||
2. 输入密码:`tangfamily`
|
||||
3. 查看族谱树
|
||||
|
||||
### 管理员访问
|
||||
1. 访问 `/admin-login.html`
|
||||
2. 输入管理员密码:`shumengya5201314`
|
||||
3. 在管理后台进行成员管理
|
||||
|
||||
## 部署到 Cloudflare
|
||||
|
||||
1. 登录 Cloudflare 账户:
|
||||
```bash
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
2. 创建 KV 命名空间:
|
||||
```bash
|
||||
npx wrangler kv:namespace create "FAMILY_DATA"
|
||||
```
|
||||
|
||||
3. 将输出的 ID 更新到 `wrangler.jsonc` 中的 `kv_namespaces.id` 字段
|
||||
|
||||
4. 部署 Worker:
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
5. 访问你的 Workers 域名即可使用
|
||||
|
||||
## 数据结构
|
||||
|
||||
每个家族成员包含以下信息:
|
||||
- `id`: 唯一标识符
|
||||
- `name`: 姓名
|
||||
- `birthYear`: 出生年份
|
||||
- `gender`: 性别(男/女)
|
||||
- `fatherId`: 父亲 ID(可选)
|
||||
- `motherId`: 母亲 ID(可选)
|
||||
|
||||
## API 接口
|
||||
|
||||
- `POST /api/login` - 登录(访客或管理员)
|
||||
- `GET /api/members` - 获取所有成员(需要认证)
|
||||
- `POST /api/members` - 添加/更新成员(需要管理员权限)
|
||||
- `POST /api/members/delete` - 删除成员(需要管理员权限)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Cloudflare Workers - 边缘计算平台
|
||||
- Cloudflare KV - 键值存储
|
||||
- TypeScript - 类型安全
|
||||
- 原生 HTML/CSS/JavaScript - 前端页面
|
||||
|
||||
## 安全说明
|
||||
|
||||
- 所有 API 接口都需要身份验证
|
||||
- Session Token 存储在 KV 中,有效期 24 小时
|
||||
- 管理操作需要管理员权限验证
|
||||
- 建议在生产环境中修改默认密码
|
||||
|
||||
## 自定义
|
||||
|
||||
### 修改密码
|
||||
|
||||
编辑 `src/index.ts` 中的常量:
|
||||
```typescript
|
||||
const VISITOR_PASSWORD = 'tangfamily';
|
||||
const ADMIN_PASSWORD = 'shumengya5201314';
|
||||
```
|
||||
|
||||
### 修改样式
|
||||
|
||||
所有样式都内联在 HTML 文件中,可直接修改 `public/*.html` 文件中的 `<style>` 标签。
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
54
analyze-data.js
Normal file
54
analyze-data.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const data = [{"id":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","name":"唐治明","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1"},{"id":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984","name":"舒邦莲","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1","name":"唐有奎","birthYear":1900,"gender":"男","generation":"祖父"},{"id":"1be537aa-88cb-4b82-8c09-e18e05922499","name":"唐治安","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1"},{"id":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b","name":"万华群","birthYear":1960,"gender":"女","generation":"父亲"},{"name":"唐治芳","birthYear":1960,"gender":"女","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1","id":"4b78f55d-34b0-4407-a672-3d6b3e826f0f"},{"name":"邓能春","birthYear":1960,"gender":"男","generation":"父亲","id":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678"},{"id":"208fb133-6a0b-4540-a1ff-ad8ce154c33f","name":"唐伟","birthYear":1990,"gender":"男","generation":"自己","fatherId":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","motherId":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984"},{"name":"唐永洪","birthYear":1990,"gender":"男","generation":"自己","fatherId":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","motherId":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984","id":"ab86880b-502c-4249-9ce4-ff3327c3fd1b"},{"id":"de8e2fc8-2a1e-4dc1-b010-cd29b9312105","name":"唐雪","birthYear":1990,"gender":"女","generation":"自己","fatherId":"1be537aa-88cb-4b82-8c09-e18e05922499","motherId":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b"},{"name":"唐波","birthYear":1990,"gender":"男","generation":"自己","fatherId":"1be537aa-88cb-4b82-8c09-e18e05922499","motherId":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b","id":"08a22bcd-5af6-4589-8739-9ea796c41a01"},{"name":"邓静","birthYear":1990,"gender":"女","generation":"自己","fatherId":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678","motherId":"4b78f55d-34b0-4407-a672-3d6b3e826f0f","id":"1ebd23c6-dadf-4cec-98fe-5bff971bc591"},{"id":"4c3f6e87-8756-48de-9141-5881778974ce","name":"邓正悦","birthYear":1990,"gender":"女","generation":"自己","fatherId":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678","motherId":"4b78f55d-34b0-4407-a672-3d6b3e826f0f"},{"name":"张红","birthYear":1990,"gender":"女","generation":"自己","id":"a74c5ba0-5fe1-4862-a651-f81b641deb72"},{"name":"郭思琪","birthYear":1990,"gender":"女","generation":"自己","id":"95672071-9f60-4728-9209-00a24a01fb3a"},{"id":"a1fa7080-3e49-49bb-b2d7-ec94e005369f","name":"唐世宁","birthYear":2020,"gender":"女","generation":"子女","fatherId":"ab86880b-502c-4249-9ce4-ff3327c3fd1b","motherId":"95672071-9f60-4728-9209-00a24a01fb3a"},{"id":"74ab3d7a-3dad-4279-aecb-13e7ef12c0ff","name":"唐瑾瑜","birthYear":2020,"gender":"女","generation":"子女","fatherId":"08a22bcd-5af6-4589-8739-9ea796c41a01","motherId":"a74c5ba0-5fe1-4862-a651-f81b641deb72"},{"id":"6365ca6a-f31f-4b7a-b233-bc5a101f7025","name":"唐世严","birthYear":2020,"gender":"男","generation":"子女","fatherId":"08a22bcd-5af6-4589-8739-9ea796c41a01","motherId":"a74c5ba0-5fe1-4862-a651-f81b641deb72"}];
|
||||
|
||||
console.log('=== 数据完整性检查 ===\n');
|
||||
|
||||
// 检查配偶关系
|
||||
const couples = [
|
||||
['唐治明', '舒邦莲'],
|
||||
['唐治安', '万华群'],
|
||||
['唐治芳', '邓能春'],
|
||||
['唐波', '张红'],
|
||||
['唐永洪', '郭思琪']
|
||||
];
|
||||
|
||||
const map = {};
|
||||
data.forEach(m => map[m.name] = m);
|
||||
|
||||
console.log('配偶关系检查:');
|
||||
couples.forEach(([p1, p2]) => {
|
||||
const m1 = map[p1];
|
||||
const m2 = map[p2];
|
||||
if (!m1 || !m2) {
|
||||
console.log(` ❌ ${p1} 或 ${p2} 数据缺失`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有共同的孩子
|
||||
const children = data.filter(c =>
|
||||
(c.fatherId === m1.id && c.motherId === m2.id) ||
|
||||
(c.fatherId === m2.id && c.motherId === m1.id)
|
||||
);
|
||||
|
||||
if (children.length > 0) {
|
||||
console.log(` ✓ ${p1} + ${p2} (孩子: ${children.map(c => c.name).join(', ')})`);
|
||||
} else {
|
||||
console.log(` ⚠ ${p1} + ${p2} (没有记录的孩子,无法确定配偶关系)`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== 关键问题 ===\n');
|
||||
|
||||
// 检查舒邦莲和万华群的父母信息
|
||||
console.log('外来配偶(嫁入)的父母信息:');
|
||||
['舒邦莲', '万华群', '邓能春', '张红', '郭思琪'].forEach(name => {
|
||||
const m = map[name];
|
||||
if (m) {
|
||||
console.log(` ${name}: fatherId=${m.fatherId || '无'}, motherId=${m.motherId || '无'}, generation=${m.generation}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n问题诊断:');
|
||||
console.log('1. 舒邦莲、万华群、邓能春没有fatherId/motherId,但generation设置为"父亲"');
|
||||
console.log('2. 这导致他们和唐治明、唐治安、唐治芳被排在同一行');
|
||||
console.log('3. 算法无法通过父母关系推断出他们应该作为配偶紧贴在唐治明/唐治安旁边');
|
||||
console.log('\n解决方案:需要改进算法,通过孩子的父母关系反推配偶关系');
|
||||
193
debug-sort.js
Normal file
193
debug-sort.js
Normal file
@@ -0,0 +1,193 @@
|
||||
const members = [{"id":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","name":"唐治明","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1"},{"id":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984","name":"舒邦莲","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1","name":"唐有奎","birthYear":1900,"gender":"男","generation":"祖父"},{"id":"1be537aa-88cb-4b82-8c09-e18e05922499","name":"唐治安","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1"},{"id":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b","name":"万华群","birthYear":1960,"gender":"女","generation":"父亲"},{"name":"唐治芳","birthYear":1960,"gender":"女","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1","id":"4b78f55d-34b0-4407-a672-3d6b3e826f0f"},{"name":"邓能春","birthYear":1960,"gender":"男","generation":"父亲","id":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678"},{"id":"208fb133-6a0b-4540-a1ff-ad8ce154c33f","name":"唐伟","birthYear":1990,"gender":"男","generation":"自己","fatherId":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","motherId":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984"},{"name":"唐永洪","birthYear":1990,"gender":"男","generation":"自己","fatherId":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","motherId":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984","id":"ab86880b-502c-4249-9ce4-ff3327c3fd1b"},{"id":"de8e2fc8-2a1e-4dc1-b010-cd29b9312105","name":"唐雪","birthYear":1990,"gender":"女","generation":"自己","fatherId":"1be537aa-88cb-4b82-8c09-e18e05922499","motherId":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b"},{"name":"唐波","birthYear":1990,"gender":"男","generation":"自己","fatherId":"1be537aa-88cb-4b82-8c09-e18e05922499","motherId":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b","id":"08a22bcd-5af6-4589-8739-9ea796c41a01"},{"name":"邓静","birthYear":1990,"gender":"女","generation":"自己","fatherId":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678","motherId":"4b78f55d-34b0-4407-a672-3d6b3e826f0f","id":"1ebd23c6-dadf-4cec-98fe-5bff971bc591"},{"id":"4c3f6e87-8756-48de-9141-5881778974ce","name":"邓正悦","birthYear":1990,"gender":"女","generation":"自己","fatherId":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678","motherId":"4b78f55d-34b0-4407-a672-3d6b3e826f0f"},{"name":"张红","birthYear":1990,"gender":"女","generation":"自己","id":"a74c5ba0-5fe1-4862-a651-f81b641deb72"},{"name":"郭思琪","birthYear":1990,"gender":"女","generation":"自己","id":"95672071-9f60-4728-9209-00a24a01fb3a"},{"id":"a1fa7080-3e49-49bb-b2d7-ec94e005369f","name":"唐世宁","birthYear":2020,"gender":"女","generation":"子女","fatherId":"ab86880b-502c-4249-9ce4-ff3327c3fd1b","motherId":"95672071-9f60-4728-9209-00a24a01fb3a"},{"id":"74ab3d7a-3dad-4279-aecb-13e7ef12c0ff","name":"唐瑾瑜","birthYear":2020,"gender":"女","generation":"子女","fatherId":"08a22bcd-5af6-4589-8739-9ea796c41a01","motherId":"a74c5ba0-5fe1-4862-a651-f81b641deb72"},{"id":"6365ca6a-f31f-4b7a-b233-bc5a101f7025","name":"唐世严","birthYear":2020,"gender":"男","generation":"子女","fatherId":"08a22bcd-5af6-4589-8739-9ea796c41a01","motherId":"a74c5ba0-5fe1-4862-a651-f81b641deb72"}];
|
||||
|
||||
const GEN_ORDER = ['鼻祖','远祖','太祖','烈祖','天祖','高祖','曾祖','祖父','父亲','自己','子女','孙辈','曾孙','玄孙','来孙','晜孙','仍孙','云孙','耳孙'];
|
||||
|
||||
const map = {};
|
||||
members.forEach(m => map[m.id] = m);
|
||||
|
||||
const childrenOf = {};
|
||||
members.forEach(m => {
|
||||
if (m.fatherId && map[m.fatherId]) (childrenOf[m.fatherId] = childrenOf[m.fatherId] || []).push(m.id);
|
||||
if (m.motherId && map[m.motherId]) (childrenOf[m.motherId] = childrenOf[m.motherId] || []).push(m.id);
|
||||
});
|
||||
Object.keys(childrenOf).forEach(pid => { childrenOf[pid] = [...new Set(childrenOf[pid])]; });
|
||||
|
||||
const rankOf = {};
|
||||
members.forEach(m => {
|
||||
if (m.generation) {
|
||||
const idx = GEN_ORDER.indexOf(m.generation);
|
||||
rankOf[m.id] = (idx >= 0 ? idx : 9) * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
const bfsQueue = Object.keys(rankOf);
|
||||
const visitedBFS = new Set(bfsQueue);
|
||||
let qi = 0;
|
||||
while (qi < bfsQueue.length) {
|
||||
const pid = bfsQueue[qi++];
|
||||
(childrenOf[pid] || []).forEach(cid => {
|
||||
const needed = Math.max(
|
||||
map[cid].fatherId && rankOf[map[cid].fatherId] !== undefined ? rankOf[map[cid].fatherId] + 1000 : 0,
|
||||
map[cid].motherId && rankOf[map[cid].motherId] !== undefined ? rankOf[map[cid].motherId] + 1000 : 0
|
||||
);
|
||||
if (rankOf[cid] === undefined || needed > rankOf[cid]) rankOf[cid] = needed;
|
||||
if (!visitedBFS.has(cid)) { visitedBFS.add(cid); bfsQueue.push(cid); }
|
||||
});
|
||||
}
|
||||
members.forEach(m => { if (rankOf[m.id] === undefined) rankOf[m.id] = 9 * 1000; });
|
||||
|
||||
const uniqueRanks = [...new Set(Object.values(rankOf))].sort((a, b) => a - b);
|
||||
const rankToRow = {};
|
||||
uniqueRanks.forEach((r, i) => { rankToRow[r] = i; });
|
||||
const rowOf = {};
|
||||
members.forEach(m => { rowOf[m.id] = rankToRow[rankOf[m.id]]; });
|
||||
|
||||
const families = {};
|
||||
const familyKeyOfChild = {};
|
||||
members.forEach(child => {
|
||||
const fatherId = child.fatherId && map[child.fatherId] ? child.fatherId : '';
|
||||
const motherId = child.motherId && map[child.motherId] ? child.motherId : '';
|
||||
if (!fatherId && !motherId) return;
|
||||
const key = `${fatherId}|${motherId}`;
|
||||
familyKeyOfChild[child.id] = key;
|
||||
if (!families[key]) families[key] = { fatherId: fatherId || null, motherId: motherId || null, children: [] };
|
||||
families[key].children.push(child.id);
|
||||
});
|
||||
|
||||
const familyOrderList = Object.keys(families)
|
||||
.map(key => {
|
||||
const fam = families[key];
|
||||
const fatherYear = fam.fatherId ? map[fam.fatherId].birthYear : -Infinity;
|
||||
const motherYear = fam.motherId ? map[fam.motherId].birthYear : -Infinity;
|
||||
return { key, orderYear: Math.max(fatherYear, motherYear) };
|
||||
})
|
||||
.sort((a, b) => b.orderYear - a.orderYear || a.key.localeCompare(b.key));
|
||||
|
||||
const familyOrderIndex = {};
|
||||
familyOrderList.forEach((item, idx) => { familyOrderIndex[item.key] = idx; });
|
||||
|
||||
const parentFamilyIndices = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
const idx = familyOrderIndex[key];
|
||||
if (fam.fatherId) (parentFamilyIndices[fam.fatherId] = parentFamilyIndices[fam.fatherId] || []).push(idx);
|
||||
if (fam.motherId) (parentFamilyIndices[fam.motherId] = parentFamilyIndices[fam.motherId] || []).push(idx);
|
||||
});
|
||||
|
||||
const parentPrimaryFamilyKey = {};
|
||||
Object.keys(parentFamilyIndices).forEach(pid => {
|
||||
const indices = parentFamilyIndices[pid];
|
||||
const minIdx = Math.min(...indices);
|
||||
parentPrimaryFamilyKey[pid] = familyOrderList[minIdx] ? familyOrderList[minIdx].key : null;
|
||||
});
|
||||
|
||||
function compareByBirthName(a, b) {
|
||||
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
function sortRowWithParentOrder(row, prevOrderIndex) {
|
||||
const memberSet = new Set(row.map(m => m.id));
|
||||
|
||||
// 找出当前行中的配偶关系(双方都在当前行)
|
||||
const spouseOf = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
if (!fam || !fam.fatherId || !fam.motherId) return;
|
||||
if (memberSet.has(fam.fatherId) && memberSet.has(fam.motherId)) {
|
||||
spouseOf[fam.fatherId] = fam.motherId;
|
||||
spouseOf[fam.motherId] = fam.fatherId;
|
||||
}
|
||||
});
|
||||
|
||||
// 按父母分组
|
||||
const parentGroups = {};
|
||||
row.forEach(m => {
|
||||
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
|
||||
if (!parentGroups[key]) parentGroups[key] = [];
|
||||
parentGroups[key].push(m);
|
||||
});
|
||||
|
||||
// 计算每个父母组的排序索引
|
||||
function getParentGroupIndex(key) {
|
||||
if (key.startsWith('solo:')) return null;
|
||||
const parts = key.split('|');
|
||||
const fId = parts[0] || null;
|
||||
const mId = parts[1] || null;
|
||||
const indices = [];
|
||||
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
|
||||
if (mId && prevOrderIndex[mId] !== undefined) indices.push(prevOrderIndex[mId]);
|
||||
return indices.length > 0 ? Math.min(...indices) : null;
|
||||
}
|
||||
|
||||
// 对父母组排序
|
||||
const sortedGroupKeys = Object.keys(parentGroups).sort((a, b) => {
|
||||
const aIdx = getParentGroupIndex(a);
|
||||
const bIdx = getParentGroupIndex(b);
|
||||
if (aIdx !== null && bIdx !== null && aIdx !== bIdx) return aIdx - bIdx;
|
||||
if (aIdx !== null && bIdx === null) return -1;
|
||||
if (aIdx === null && bIdx !== null) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 展开每个父母组,先放兄弟姐妹,再放他们的配偶
|
||||
const result = [];
|
||||
const processed = new Set();
|
||||
sortedGroupKeys.forEach(groupKey => {
|
||||
const groupMembers = parentGroups[groupKey].slice().sort(compareByBirthName);
|
||||
const spousesToAdd = [];
|
||||
|
||||
// 先添加所有兄弟姐妹
|
||||
groupMembers.forEach(m => {
|
||||
if (processed.has(m.id)) return;
|
||||
result.push(m);
|
||||
processed.add(m.id);
|
||||
|
||||
// 记录需要添加的配偶(如果配偶不在当前组)
|
||||
const spouseId = spouseOf[m.id];
|
||||
if (spouseId && memberSet.has(spouseId) && !processed.has(spouseId)) {
|
||||
const spouseKey = familyKeyOfChild[spouseId] || `solo:${spouseId}`;
|
||||
if (spouseKey !== groupKey) {
|
||||
spousesToAdd.push(map[spouseId]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 然后添加配偶
|
||||
spousesToAdd.forEach(spouse => {
|
||||
if (!processed.has(spouse.id)) {
|
||||
result.push(spouse);
|
||||
processed.add(spouse.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortTopRow(row) {
|
||||
const sorted = row.slice().sort((a, b) => {
|
||||
const aKey = parentPrimaryFamilyKey[a.id];
|
||||
const bKey = parentPrimaryFamilyKey[b.id];
|
||||
if (aKey && bKey && aKey === bKey) {
|
||||
const fam = families[aKey];
|
||||
if (fam && fam.fatherId === a.id && fam.motherId === b.id) return -1;
|
||||
if (fam && fam.motherId === a.id && fam.fatherId === b.id) return 1;
|
||||
}
|
||||
return compareByBirthName(a, b);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const maxRow = Math.max(...Object.values(rowOf));
|
||||
const gens = [];
|
||||
let prevOrderIndex = {};
|
||||
for (let g = 0; g <= maxRow; g++) {
|
||||
const rowMembers = members.filter(m => rowOf[m.id] === g);
|
||||
const ordered = g === 0 ? sortTopRow(rowMembers) : sortRowWithParentOrder(rowMembers, prevOrderIndex);
|
||||
gens.push(ordered);
|
||||
prevOrderIndex = {};
|
||||
ordered.forEach((m, idx) => { prevOrderIndex[m.id] = idx; });
|
||||
|
||||
console.log(`第${g}行排序结果: ${ordered.map(m => m.name).join(', ')}`);
|
||||
}
|
||||
91
public/admin-login.html
Normal file
91
public/admin-login.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理员登录 - 唐氏族谱</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', Arial, sans-serif;
|
||||
background: #f0f7ee;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(120,170,100,0.13);
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
border-top: 4px solid #8bc34a;
|
||||
animation: up .4s ease;
|
||||
}
|
||||
@keyframes up { from { opacity:0; transform:translateY(16px);} to { opacity:1; transform:translateY(0);} }
|
||||
.logo { text-align: center; margin-bottom: 28px; }
|
||||
.logo h1 { font-size: 24px; color: #558b2f; letter-spacing: 1px; margin-bottom: 4px; }
|
||||
.logo p { color: #aaa; font-size: 13px; }
|
||||
label { display: block; margin-bottom: 6px; color: #555; font-size: 14px; }
|
||||
input[type=password] {
|
||||
width: 100%; padding: 10px 13px; border: 1.5px solid #d4e8c2;
|
||||
border-radius: 8px; font-size: 15px; background: #f8fdf3;
|
||||
transition: border-color .2s, box-shadow .2s; margin-bottom: 18px;
|
||||
}
|
||||
input[type=password]:focus { outline: none; border-color: #8bc34a; box-shadow: 0 0 0 3px rgba(139,195,74,.15); }
|
||||
.btn {
|
||||
width: 100%; padding: 10px; border: none;
|
||||
border-radius: 8px; font-size: 14px; font-weight: 600;
|
||||
cursor: pointer; transition: background .2s, transform .15s; margin-bottom: 8px;
|
||||
}
|
||||
.btn-primary { background: #8bc34a; color: #fff; }
|
||||
.btn-primary:hover { background: #7cb342; transform: translateY(-1px); }
|
||||
.btn-secondary { background: #f1f8e9; color: #558b2f; border: 1.5px solid #c5e1a5; }
|
||||
.btn-secondary:hover { background: #e8f5e9; }
|
||||
.error { color: #e57373; font-size: 13px; margin-top: 8px; text-align: center; display: none; }
|
||||
.error.show { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>管理员登录</h1>
|
||||
<p>唐氏族谱后台管理</p>
|
||||
</div>
|
||||
<form id="loginForm">
|
||||
<label>管理员密码</label>
|
||||
<input type="password" id="password" placeholder="请输入管理员密码" required>
|
||||
<button type="submit" class="btn btn-primary">登录</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="location.href='/'">返回首页</button>
|
||||
<div class="error" id="error">密码错误,请重试</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('error');
|
||||
err.classList.remove('show');
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: document.getElementById('password').value, isAdmin: true })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.isAdmin) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('isAdmin', 'true');
|
||||
location.href = '/admin.html';
|
||||
} else {
|
||||
err.textContent = data.error || '密码错误,请重试';
|
||||
err.classList.add('show');
|
||||
document.getElementById('password').value = '';
|
||||
}
|
||||
} catch { err.textContent = '连接失败,请重试'; err.classList.add('show'); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
357
public/admin.html
Normal file
357
public/admin.html
Normal file
@@ -0,0 +1,357 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>唐氏族谱 - 后台管理</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Microsoft YaHei', Arial, sans-serif; background: #f0f7ee; min-height: 100vh; }
|
||||
|
||||
/* 顶栏 */
|
||||
.topbar {
|
||||
background: #fff; border-bottom: 2px solid #c5e1a5;
|
||||
padding: 10px 16px; display: flex; align-items: center;
|
||||
justify-content: space-between; position: sticky; top: 0; z-index: 200;
|
||||
}
|
||||
.topbar h1 { font-size: 18px; color: #558b2f; }
|
||||
.topbar-right { display: flex; gap: 6px; align-items: center; }
|
||||
.tbtn {
|
||||
padding: 5px 12px; border-radius: 6px; border: 1.5px solid #c5e1a5;
|
||||
background: #f1f8e9; color: #558b2f; font-size: 13px; cursor: pointer; transition: background .15s;
|
||||
}
|
||||
.tbtn:hover { background: #dcedc8; }
|
||||
.tbtn.green { background: #8bc34a; color: #fff; border-color: #8bc34a; }
|
||||
.tbtn.green:hover { background: #7cb342; }
|
||||
.tbtn.red { background: #ef9a9a; color: #fff; border-color: #ef9a9a; }
|
||||
.tbtn.red:hover { background: #e57373; }
|
||||
.tbtn.active { background: #8bc34a; color: #fff; border-color: #8bc34a; }
|
||||
.tbtn.active:hover { background: #7cb342; }
|
||||
|
||||
/* 主体 */
|
||||
.main { padding: 14px 16px; max-width: 960px; margin: 0 auto; }
|
||||
|
||||
/* 快速新增行 */
|
||||
.quick-add {
|
||||
background: #fff; border: 1.5px solid #c5e1a5; border-radius: 10px;
|
||||
padding: 10px 14px; margin-bottom: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 80px 70px 1.4fr 1fr 1fr auto;
|
||||
gap: 8px; align-items: center;
|
||||
}
|
||||
.quick-add input, .quick-add select {
|
||||
padding: 6px 9px; border: 1.5px solid #d4e8c2; border-radius: 6px;
|
||||
font-size: 13px; background: #f8fdf3; width: 100%;
|
||||
}
|
||||
.quick-add input:focus, .quick-add select:focus { outline: none; border-color: #8bc34a; }
|
||||
.quick-add label { display: none; }
|
||||
|
||||
/* 成员表格 */
|
||||
.table-wrap { background: #fff; border: 1.5px solid #c5e1a5; border-radius: 10px; overflow: hidden; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
thead th {
|
||||
background: #f1f8e9; color: #558b2f; padding: 8px 10px;
|
||||
text-align: left; font-weight: 600; border-bottom: 1.5px solid #c5e1a5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid #f0f7ee; transition: background .15s; }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
tbody tr:hover { background: #f9fef5; }
|
||||
td { padding: 6px 10px; color: #333; vertical-align: middle; }
|
||||
td.name-cell { font-weight: 600; color: #2e4a1a; }
|
||||
td.gender-m { color: #1976d2; }
|
||||
td.gender-f { color: #c2185b; }
|
||||
|
||||
/* 行内编辑 */
|
||||
.row-edit input, .row-edit select {
|
||||
padding: 4px 6px; border: 1.5px solid #a5d6a7; border-radius: 5px;
|
||||
font-size: 13px; background: #f8fdf3; width: 100%;
|
||||
}
|
||||
.row-edit input:focus, .row-edit select:focus { outline: none; border-color: #66bb6a; }
|
||||
.actions { display: flex; gap: 5px; white-space: nowrap; }
|
||||
|
||||
/* 空/加载 */
|
||||
.tip { text-align: center; padding: 40px 0; color: #bbb; font-size: 14px; }
|
||||
|
||||
/* 提示 toast */
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: #558b2f; color: #fff; padding: 8px 22px;
|
||||
border-radius: 20px; font-size: 13px; opacity: 0;
|
||||
transition: opacity .3s; pointer-events: none; z-index: 9999;
|
||||
}
|
||||
#toast.show { opacity: 1; }
|
||||
|
||||
/* 响应式:小屏隐藏部分列 */
|
||||
@media (max-width: 700px) {
|
||||
.quick-add { grid-template-columns: 1fr 70px 60px auto; }
|
||||
.quick-add .col-father, .quick-add .col-mother, .quick-add .col-gen { display: none; }
|
||||
.hide-sm { display: none; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.quick-add { grid-template-columns: 1fr 60px auto; }
|
||||
.quick-add .col-gender { display: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1>族谱管理后台</h1>
|
||||
<div class="topbar-right">
|
||||
<span id="countBadge" style="color:#888;font-size:12px;"></span>
|
||||
<button class="tbtn" id="yearToggle" onclick="toggleYear()">显示年份</button>
|
||||
<button class="tbtn" onclick="location.href='/tree.html'">查看族谱</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<!-- 快速新增 -->
|
||||
<form id="quickForm" class="quick-add" onsubmit="quickAdd(event)">
|
||||
<input id="qName" placeholder="姓名 *" required>
|
||||
<input id="qYear" type="number" placeholder="出生年" min="1900" max="2100" required>
|
||||
<select id="qGender" class="col-gender" required>
|
||||
<option value="">性别</option>
|
||||
<option value="男">男</option>
|
||||
<option value="女">女</option>
|
||||
</select>
|
||||
<select id="qGen" class="col-gen">
|
||||
<option value="">辈分(选填)</option>
|
||||
<option value="鼻祖">鼻祖</option>
|
||||
<option value="远祖">远祖</option>
|
||||
<option value="太祖">太祖</option>
|
||||
<option value="烈祖">烈祖</option>
|
||||
<option value="天祖">天祖</option>
|
||||
<option value="高祖">高祖</option>
|
||||
<option value="曾祖">曾祖</option>
|
||||
<option value="祖父">祖父</option>
|
||||
<option value="父亲">父亲</option>
|
||||
<option value="自己">自己</option>
|
||||
<option value="子女">子女</option>
|
||||
<option value="孙辈">孙辈</option>
|
||||
<option value="曾孙">曾孙</option>
|
||||
<option value="玄孙">玄孙</option>
|
||||
<option value="来孙">来孙</option>
|
||||
<option value="晜孙">晜孙</option>
|
||||
<option value="仍孙">仍孙</option>
|
||||
<option value="云孙">云孙</option>
|
||||
<option value="耳孙">耳孙</option>
|
||||
</select>
|
||||
<select id="qFather" class="col-father"><option value="">父亲(选填)</option></select>
|
||||
<select id="qMother" class="col-mother"><option value="">母亲(选填)</option></select>
|
||||
<button type="submit" class="tbtn green">+ 添加</button>
|
||||
</form>
|
||||
|
||||
<!-- 成员列表 -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>姓名</th>
|
||||
<th>出生年</th>
|
||||
<th>性别</th>
|
||||
<th class="hide-sm">辈分</th>
|
||||
<th class="hide-sm">父亲</th>
|
||||
<th class="hide-sm">母亲</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbody">
|
||||
<tr><td colspan="7" class="tip">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('token');
|
||||
const isAdmin = localStorage.getItem('isAdmin') === 'true';
|
||||
if (!token || !isAdmin) location.href = '/';
|
||||
|
||||
let members = [];
|
||||
let editingId = null;
|
||||
|
||||
// ── 显示年份开关 ──────────────────────────────────────
|
||||
function getShowYear() { return localStorage.getItem('showYear') !== 'false'; }
|
||||
function toggleYear() {
|
||||
const next = !getShowYear();
|
||||
localStorage.setItem('showYear', next ? 'true' : 'false');
|
||||
updateYearToggleBtn();
|
||||
}
|
||||
function updateYearToggleBtn() {
|
||||
const btn = document.getElementById('yearToggle');
|
||||
if (!btn) return;
|
||||
const on = getShowYear();
|
||||
btn.textContent = on ? '显示年份:开' : '显示年份:关';
|
||||
btn.classList.toggle('active', on);
|
||||
}
|
||||
updateYearToggleBtn();
|
||||
|
||||
const GEN_OPTIONS = [
|
||||
'鼻祖','远祖','太祖','烈祖','天祖',
|
||||
'高祖','曾祖','祖父','父亲','自己',
|
||||
'子女','孙辈','曾孙','玄孙','来孙',
|
||||
'晜孙','仍孙','云孙','耳孙'
|
||||
];
|
||||
function genSelectOpts(current) {
|
||||
return `<option value="">无</option>` +
|
||||
GEN_OPTIONS.map(g => `<option value="${g}" ${current === g ? 'selected' : ''}>${g}</option>`).join('');
|
||||
}
|
||||
|
||||
// ── 数据加载 ──────────────────────────────────────────
|
||||
async function loadMembers() {
|
||||
try {
|
||||
const res = await fetch('/api/members', { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error();
|
||||
members = await res.json();
|
||||
members.sort((a, b) => a.birthYear - b.birthYear);
|
||||
renderTable();
|
||||
updateSelects();
|
||||
document.getElementById('countBadge').textContent = `共 ${members.length} 人`;
|
||||
} catch {
|
||||
document.getElementById('tbody').innerHTML = '<tr><td colspan="7" class="tip" style="color:#e57373">加载失败,请刷新</td></tr>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 渲染表格 ──────────────────────────────────────────
|
||||
function renderTable() {
|
||||
const map = {};
|
||||
members.forEach(m => map[m.id] = m);
|
||||
const tbody = document.getElementById('tbody');
|
||||
if (members.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="tip">暂无成员,请在上方快速添加</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = members.map(m => {
|
||||
if (editingId === m.id) return renderEditRow(m, map);
|
||||
const father = m.fatherId && map[m.fatherId] ? map[m.fatherId].name : '—';
|
||||
const mother = m.motherId && map[m.motherId] ? map[m.motherId].name : '—';
|
||||
return `<tr id="row-${m.id}">
|
||||
<td class="name-cell">${m.name}</td>
|
||||
<td>${m.birthYear}</td>
|
||||
<td class="${m.gender === '男' ? 'gender-m' : 'gender-f'}">${m.gender}</td>
|
||||
<td class="hide-sm">${m.generation || '—'}</td>
|
||||
<td class="hide-sm">${father}</td>
|
||||
<td class="hide-sm">${mother}</td>
|
||||
<td><div class="actions">
|
||||
<button class="tbtn" onclick="startEdit('${m.id}')">编辑</button>
|
||||
<button class="tbtn red" onclick="deleteMember('${m.id}','${m.name}')">删除</button>
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderEditRow(m, map) {
|
||||
const males = members.filter(x => x.gender === '男' && x.id !== m.id);
|
||||
const females = members.filter(x => x.gender === '女' && x.id !== m.id);
|
||||
const fOpts = `<option value="">无</option>` + males.map(x => `<option value="${x.id}" ${m.fatherId === x.id ? 'selected' : ''}>${x.name}(${x.birthYear})</option>`).join('');
|
||||
const mOpts = `<option value="">无</option>` + females.map(x => `<option value="${x.id}" ${m.motherId === x.id ? 'selected' : ''}>${x.name}(${x.birthYear})</option>`).join('');
|
||||
return `<tr id="row-${m.id}" class="row-edit" style="background:#f1f8e9">
|
||||
<td><input id="eName" value="${m.name}" required></td>
|
||||
<td><input id="eYear" type="number" value="${m.birthYear}" min="1900" max="2100" style="width:70px"></td>
|
||||
<td><select id="eGender">
|
||||
<option value="男" ${m.gender==='男'?'selected':''}>男</option>
|
||||
<option value="女" ${m.gender==='女'?'selected':''}>女</option>
|
||||
</select></td>
|
||||
<td class="hide-sm"><select id="eGen">${genSelectOpts(m.generation || '')}</select></td>
|
||||
<td class="hide-sm"><select id="eFather">${fOpts}</select></td>
|
||||
<td class="hide-sm"><select id="eMother">${mOpts}</select></td>
|
||||
<td><div class="actions">
|
||||
<button class="tbtn green" onclick="saveEdit('${m.id}')">保存</button>
|
||||
<button class="tbtn" onclick="cancelEdit()">取消</button>
|
||||
</div></td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// ── 编辑相关 ──────────────────────────────────────────
|
||||
function startEdit(id) {
|
||||
editingId = id;
|
||||
renderTable();
|
||||
const row = document.getElementById(`row-${id}`);
|
||||
if (row) row.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
const name = document.getElementById('eName').value.trim();
|
||||
const birthYear = parseInt(document.getElementById('eYear').value);
|
||||
const gender = document.getElementById('eGender').value;
|
||||
const generation = document.getElementById('eGen') ? document.getElementById('eGen').value || undefined : undefined;
|
||||
const fatherId = document.getElementById('eFather') ? document.getElementById('eFather').value || undefined : undefined;
|
||||
const motherId = document.getElementById('eMother') ? document.getElementById('eMother').value || undefined : undefined;
|
||||
if (!name || !birthYear || !gender) { toast('请填写完整信息', true); return; }
|
||||
await saveMember({ id, name, birthYear, gender, generation, fatherId, motherId });
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
// ── 快速添加 ──────────────────────────────────────────
|
||||
async function quickAdd(e) {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('qName').value.trim();
|
||||
const birthYear = parseInt(document.getElementById('qYear').value);
|
||||
const gender = document.getElementById('qGender').value;
|
||||
const generation = document.getElementById('qGen').value || undefined;
|
||||
const fatherId = document.getElementById('qFather').value || undefined;
|
||||
const motherId = document.getElementById('qMother').value || undefined;
|
||||
if (!name || !birthYear || !gender) { toast('请填写姓名、出生年、性别', true); return; }
|
||||
await saveMember({ name, birthYear, gender, generation, fatherId, motherId });
|
||||
document.getElementById('quickForm').reset();
|
||||
}
|
||||
|
||||
// ── 公共保存 ──────────────────────────────────────────
|
||||
async function saveMember(member) {
|
||||
try {
|
||||
const res = await fetch('/api/members', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(member)
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast(member.id ? '已保存' : '已添加');
|
||||
await loadMembers();
|
||||
} catch { toast('保存失败,请重试', true); }
|
||||
}
|
||||
|
||||
// ── 删除 ──────────────────────────────────────────────
|
||||
async function deleteMember(id, name) {
|
||||
if (!confirm(`确定删除「${name}」?`)) return;
|
||||
try {
|
||||
const res = await fetch('/api/members/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ id })
|
||||
});
|
||||
if (!res.ok) throw new Error();
|
||||
toast('已删除');
|
||||
await loadMembers();
|
||||
} catch { toast('删除失败', true); }
|
||||
}
|
||||
|
||||
// ── 更新父母下拉选项 ──────────────────────────────────
|
||||
function updateSelects() {
|
||||
const males = members.filter(m => m.gender === '男');
|
||||
const females = members.filter(m => m.gender === '女');
|
||||
document.getElementById('qFather').innerHTML = '<option value="">父亲(选填)</option>' +
|
||||
males.map(m => `<option value="${m.id}">${m.name}(${m.birthYear})</option>`).join('');
|
||||
document.getElementById('qMother').innerHTML = '<option value="">母亲(选填)</option>' +
|
||||
females.map(m => `<option value="${m.id}">${m.name}(${m.birthYear})</option>`).join('');
|
||||
}
|
||||
|
||||
// ── Toast 提示 ────────────────────────────────────────
|
||||
function toast(msg, err = false) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.style.background = err ? '#e57373' : '#558b2f';
|
||||
el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 2000);
|
||||
}
|
||||
|
||||
loadMembers();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,32 +1,89 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Hello, World!</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1 id="heading"></h1>
|
||||
<p>This page comes from a static asset stored at `public/index.html` as configured in `wrangler.jsonc`.</p>
|
||||
<button id="button" type="button">Fetch a random UUID</button>
|
||||
<output id="random" for="button"></output>
|
||||
<script>
|
||||
fetch('/message')
|
||||
.then((resp) => resp.text())
|
||||
.then((text) => {
|
||||
const h1 = document.getElementById('heading');
|
||||
h1.textContent = text;
|
||||
});
|
||||
|
||||
const button = document.getElementById("button");
|
||||
button.addEventListener("click", () => {
|
||||
fetch('/random')
|
||||
.then((resp) => resp.text())
|
||||
.then((text) => {
|
||||
const random = document.getElementById('random');
|
||||
random.textContent = text;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>唐家族谱</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', Arial, sans-serif;
|
||||
background: #f0f7ee;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 40px 36px 32px;
|
||||
box-shadow: 0 4px 24px rgba(120,170,100,0.13);
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
border-top: 4px solid #8bc34a;
|
||||
animation: up .4s ease;
|
||||
}
|
||||
@keyframes up { from { opacity:0; transform:translateY(16px);} to { opacity:1; transform:translateY(0);} }
|
||||
.logo { text-align: center; margin-bottom: 28px; }
|
||||
.logo h1 { font-size: 28px; color: #558b2f; letter-spacing: 2px; margin-bottom: 4px; }
|
||||
.logo p { color: #aaa; font-size: 13px; }
|
||||
label { display: block; margin-bottom: 6px; color: #555; font-size: 14px; }
|
||||
input[type=password] {
|
||||
width: 100%; padding: 10px 13px; border: 1.5px solid #d4e8c2;
|
||||
border-radius: 8px; font-size: 15px; background: #f8fdf3;
|
||||
transition: border-color .2s, box-shadow .2s; margin-bottom: 18px;
|
||||
}
|
||||
input[type=password]:focus { outline: none; border-color: #8bc34a; box-shadow: 0 0 0 3px rgba(139,195,74,.15); }
|
||||
.btn {
|
||||
width: 100%; padding: 11px; background: #8bc34a; border: none;
|
||||
border-radius: 8px; color: #fff; font-size: 15px; font-weight: 600;
|
||||
cursor: pointer; transition: background .2s, transform .15s;
|
||||
}
|
||||
.btn:hover { background: #7cb342; transform: translateY(-1px); }
|
||||
.btn:active { transform: translateY(0); }
|
||||
.error { color: #e57373; font-size: 13px; margin-top: 10px; text-align: center; display: none; }
|
||||
.error.show { display: block; }
|
||||
@media (max-width: 400px) { .card { padding: 28px 18px 24px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="logo">
|
||||
<h1>唐氏族谱</h1>
|
||||
<p>Tang Family Tree</p>
|
||||
</div>
|
||||
<form id="loginForm">
|
||||
<label>访问密码</label>
|
||||
<input type="password" id="password" placeholder="请输入密码" required>
|
||||
<button type="submit" class="btn">进入族谱</button>
|
||||
<div class="error" id="error">密码错误,请重试</div>
|
||||
</form>
|
||||
</div>
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const err = document.getElementById('error');
|
||||
err.classList.remove('show');
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: document.getElementById('password').value, isAdmin: false })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('token', data.token);
|
||||
localStorage.setItem('isAdmin', data.isAdmin);
|
||||
location.href = '/tree.html';
|
||||
} else {
|
||||
err.textContent = data.error || '密码错误,请重试';
|
||||
err.classList.add('show');
|
||||
document.getElementById('password').value = '';
|
||||
}
|
||||
} catch { err.textContent = '连接失败,请重试'; err.classList.add('show'); }
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
452
public/tree.html
Normal file
452
public/tree.html
Normal file
@@ -0,0 +1,452 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>唐氏族谱</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Microsoft YaHei', Arial, sans-serif;
|
||||
background: #f0f7ee;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
background: #fff;
|
||||
border-bottom: 2px solid #c5e1a5;
|
||||
padding: 10px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.topbar h1 { font-size: 20px; color: #558b2f; letter-spacing: 2px; }
|
||||
.topbar-right { display: flex; gap: 8px; }
|
||||
.tbtn {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1.5px solid #c5e1a5;
|
||||
background: #f1f8e9; color: #558b2f; font-size: 13px; cursor: pointer;
|
||||
transition: background .2s;
|
||||
}
|
||||
.tbtn:hover { background: #dcedc8; }
|
||||
.tbtn.primary { background: #8bc34a; color: #fff; border-color: #8bc34a; }
|
||||
.tbtn.primary:hover { background: #7cb342; }
|
||||
|
||||
/* 树图区域 */
|
||||
#treeWrap {
|
||||
padding: 30px 20px 60px;
|
||||
overflow-x: auto;
|
||||
min-height: calc(100vh - 52px);
|
||||
}
|
||||
#treeCanvas {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
}
|
||||
svg.lines {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* 代际行 */
|
||||
.gen-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
flex-wrap: nowrap;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* 成员卡片 */
|
||||
.node {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.card-box {
|
||||
background: #fff;
|
||||
border: 2px solid #c5e1a5;
|
||||
border-radius: 10px;
|
||||
padding: 7px 14px;
|
||||
text-align: center;
|
||||
cursor: default;
|
||||
transition: box-shadow .2s, border-color .2s;
|
||||
min-width: 72px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.card-box:hover { box-shadow: 0 4px 16px rgba(139,195,74,.25); border-color: #8bc34a; }
|
||||
.card-box.male { border-color: #90caf9; background: #f5fbff; }
|
||||
.card-box.female { border-color: #f48fb1; background: #fff5f8; }
|
||||
.card-name { font-size: 15px; font-weight: 700; color: #2e4a1a; }
|
||||
.card-year { font-size: 12px; color: #888; margin-top: 1px; }
|
||||
|
||||
.loading { text-align: center; padding: 80px 0; color: #8bc34a; font-size: 18px; }
|
||||
.empty { text-align: center; padding: 80px 0; color: #bbb; font-size: 16px; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.topbar h1 { font-size: 16px; }
|
||||
.tbtn { padding: 5px 10px; font-size: 12px; }
|
||||
#treeWrap { padding: 16px 6px 40px; }
|
||||
.card-name { font-size: 13px; }
|
||||
.card-year { font-size: 11px; }
|
||||
.card-box { padding: 5px 10px; min-width: 58px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="topbar">
|
||||
<h1>唐氏族谱</h1>
|
||||
<div class="topbar-right">
|
||||
<button class="tbtn" id="adminBtn" style="display:none" onclick="location.href='/admin.html'">后台管理</button>
|
||||
<button class="tbtn" onclick="location.reload()">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="treeWrap">
|
||||
<div id="treeCanvas"><div class="loading">加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('token');
|
||||
const isAdmin = localStorage.getItem('isAdmin') === 'true';
|
||||
if (!token) location.href = '/';
|
||||
if (isAdmin) document.getElementById('adminBtn').style.display = 'inline-block';
|
||||
|
||||
// ── 辈分顺序表(index = 行顺序,越小越靠上)─────────────────
|
||||
const GEN_ORDER = [
|
||||
'鼻祖','远祖','太祖','烈祖','天祖',
|
||||
'高祖','曾祖','祖父','父亲','自己',
|
||||
'子女','孙辈','曾孙','玄孙','来孙',
|
||||
'晜孙','仍孙','云孙','耳孙'
|
||||
];
|
||||
|
||||
// ── 布局常量 ──────────────────────────────────────────
|
||||
const NODE_W = 88; // 卡片宽
|
||||
const NODE_H = 48; // 卡片高
|
||||
const H_GAP = 28; // 水平间距
|
||||
const V_GAP = 70; // 代际间距
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch('/api/members', { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.status === 401) { localStorage.clear(); location.href = '/'; return; }
|
||||
const members = await res.json();
|
||||
const showYear = localStorage.getItem('showYear') !== 'false';
|
||||
render(members, showYear);
|
||||
} catch { document.getElementById('treeCanvas').innerHTML = '<div class="empty">加载失败,请刷新重试</div>'; }
|
||||
}
|
||||
|
||||
function render(members, showYear) {
|
||||
const canvas = document.getElementById('treeCanvas');
|
||||
if (!members || members.length === 0) {
|
||||
canvas.innerHTML = '<div class="empty">暂无族谱数据,请前往后台添加成员</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// id → member 映射
|
||||
const map = {};
|
||||
members.forEach(m => map[m.id] = m);
|
||||
|
||||
// ── 谁有孩子 ────────────────────────────────────────
|
||||
const hasChild = new Set();
|
||||
members.forEach(m => {
|
||||
if (m.fatherId && map[m.fatherId]) hasChild.add(m.fatherId);
|
||||
if (m.motherId && map[m.motherId]) hasChild.add(m.motherId);
|
||||
});
|
||||
|
||||
// ── 子女列表(去重)────────────────────────────────
|
||||
const childrenOf = {};
|
||||
members.forEach(m => {
|
||||
if (m.fatherId && map[m.fatherId]) (childrenOf[m.fatherId] = childrenOf[m.fatherId] || []).push(m.id);
|
||||
if (m.motherId && map[m.motherId]) (childrenOf[m.motherId] = childrenOf[m.motherId] || []).push(m.id);
|
||||
});
|
||||
Object.keys(childrenOf).forEach(pid => { childrenOf[pid] = [...new Set(childrenOf[pid])]; });
|
||||
|
||||
// ── 确定每个成员的行 rank ────────────────────────────
|
||||
// 优先 generation 字段;否则 BFS 从有 generation 的成员向下推算;
|
||||
// 最终孤立成员默认放 "自己"(rank=9000)
|
||||
const rankOf = {};
|
||||
members.forEach(m => {
|
||||
if (m.generation) {
|
||||
const idx = GEN_ORDER.indexOf(m.generation);
|
||||
rankOf[m.id] = (idx >= 0 ? idx : 9) * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
const bfsQueue = Object.keys(rankOf);
|
||||
const visitedBFS = new Set(bfsQueue);
|
||||
let qi = 0;
|
||||
while (qi < bfsQueue.length) {
|
||||
const pid = bfsQueue[qi++];
|
||||
(childrenOf[pid] || []).forEach(cid => {
|
||||
const needed = Math.max(
|
||||
map[cid].fatherId && rankOf[map[cid].fatherId] !== undefined ? rankOf[map[cid].fatherId] + 1000 : 0,
|
||||
map[cid].motherId && rankOf[map[cid].motherId] !== undefined ? rankOf[map[cid].motherId] + 1000 : 0
|
||||
);
|
||||
if (rankOf[cid] === undefined || needed > rankOf[cid]) rankOf[cid] = needed;
|
||||
if (!visitedBFS.has(cid)) { visitedBFS.add(cid); bfsQueue.push(cid); }
|
||||
});
|
||||
}
|
||||
members.forEach(m => { if (rankOf[m.id] === undefined) rankOf[m.id] = 9 * 1000; });
|
||||
|
||||
// 压缩 rank → 连续行号
|
||||
const uniqueRanks = [...new Set(Object.values(rankOf))].sort((a, b) => a - b);
|
||||
const rankToRow = {};
|
||||
uniqueRanks.forEach((r, i) => { rankToRow[r] = i; });
|
||||
const rowOf = {};
|
||||
members.forEach(m => { rowOf[m.id] = rankToRow[rankOf[m.id]]; });
|
||||
|
||||
// ── 家族单元与生物学排序 ─────────────────────────────
|
||||
const families = {};
|
||||
const familyKeyOfChild = {};
|
||||
members.forEach(child => {
|
||||
const fatherId = child.fatherId && map[child.fatherId] ? child.fatherId : '';
|
||||
const motherId = child.motherId && map[child.motherId] ? child.motherId : '';
|
||||
if (!fatherId && !motherId) return;
|
||||
const key = `${fatherId}|${motherId}`;
|
||||
familyKeyOfChild[child.id] = key;
|
||||
if (!families[key]) families[key] = { fatherId: fatherId || null, motherId: motherId || null, children: [] };
|
||||
families[key].children.push(child.id);
|
||||
});
|
||||
|
||||
const familyOrderList = Object.keys(families)
|
||||
.map(key => {
|
||||
const fam = families[key];
|
||||
const fatherYear = fam.fatherId ? map[fam.fatherId].birthYear : -Infinity;
|
||||
const motherYear = fam.motherId ? map[fam.motherId].birthYear : -Infinity;
|
||||
return { key, orderYear: Math.max(fatherYear, motherYear) };
|
||||
})
|
||||
.sort((a, b) => b.orderYear - a.orderYear || a.key.localeCompare(b.key));
|
||||
|
||||
const familyOrderIndex = {};
|
||||
familyOrderList.forEach((item, idx) => { familyOrderIndex[item.key] = idx; });
|
||||
|
||||
const parentFamilyIndices = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
const idx = familyOrderIndex[key];
|
||||
if (fam.fatherId) (parentFamilyIndices[fam.fatherId] = parentFamilyIndices[fam.fatherId] || []).push(idx);
|
||||
if (fam.motherId) (parentFamilyIndices[fam.motherId] = parentFamilyIndices[fam.motherId] || []).push(idx);
|
||||
});
|
||||
|
||||
const parentPrimaryFamilyKey = {};
|
||||
Object.keys(parentFamilyIndices).forEach(pid => {
|
||||
const indices = parentFamilyIndices[pid];
|
||||
const minIdx = Math.min(...indices);
|
||||
parentPrimaryFamilyKey[pid] = familyOrderList[minIdx] ? familyOrderList[minIdx].key : null;
|
||||
});
|
||||
|
||||
function familyIndexOfMember(m) {
|
||||
if (parentPrimaryFamilyKey[m.id]) return familyOrderIndex[parentPrimaryFamilyKey[m.id]];
|
||||
if (familyKeyOfChild[m.id]) return familyOrderIndex[familyKeyOfChild[m.id]];
|
||||
return null;
|
||||
}
|
||||
|
||||
function primaryFamilyKeyOfMember(m) {
|
||||
if (parentPrimaryFamilyKey[m.id]) return parentPrimaryFamilyKey[m.id];
|
||||
if (familyKeyOfChild[m.id]) return familyKeyOfChild[m.id];
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareByBirthName(a, b) {
|
||||
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
function sortRowWithParentOrder(row, prevOrderIndex) {
|
||||
const memberSet = new Set(row.map(m => m.id));
|
||||
|
||||
// 找出当前行中的配偶关系(双方都在当前行)
|
||||
const spouseOf = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
if (!fam || !fam.fatherId || !fam.motherId) return;
|
||||
if (memberSet.has(fam.fatherId) && memberSet.has(fam.motherId)) {
|
||||
spouseOf[fam.fatherId] = fam.motherId;
|
||||
spouseOf[fam.motherId] = fam.fatherId;
|
||||
}
|
||||
});
|
||||
|
||||
// 按父母分组
|
||||
const parentGroups = {};
|
||||
row.forEach(m => {
|
||||
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
|
||||
if (!parentGroups[key]) parentGroups[key] = [];
|
||||
parentGroups[key].push(m);
|
||||
});
|
||||
|
||||
// 计算每个父母组的排序索引
|
||||
function getParentGroupIndex(key) {
|
||||
if (key.startsWith('solo:')) return null;
|
||||
const parts = key.split('|');
|
||||
const fId = parts[0] || null;
|
||||
const mId = parts[1] || null;
|
||||
const indices = [];
|
||||
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
|
||||
if (mId && prevOrderIndex[mId] !== undefined) indices.push(prevOrderIndex[mId]);
|
||||
return indices.length > 0 ? Math.min(...indices) : null;
|
||||
}
|
||||
|
||||
// 对父母组排序
|
||||
const sortedGroupKeys = Object.keys(parentGroups).sort((a, b) => {
|
||||
const aIdx = getParentGroupIndex(a);
|
||||
const bIdx = getParentGroupIndex(b);
|
||||
if (aIdx !== null && bIdx !== null && aIdx !== bIdx) return aIdx - bIdx;
|
||||
if (aIdx !== null && bIdx === null) return -1;
|
||||
if (aIdx === null && bIdx !== null) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 展开每个父母组,先放兄弟姐妹,再放他们的配偶
|
||||
const result = [];
|
||||
const processed = new Set();
|
||||
sortedGroupKeys.forEach(groupKey => {
|
||||
const groupMembers = parentGroups[groupKey].slice().sort(compareByBirthName);
|
||||
const spousesToAdd = [];
|
||||
|
||||
// 先添加所有兄弟姐妹
|
||||
groupMembers.forEach(m => {
|
||||
if (processed.has(m.id)) return;
|
||||
result.push(m);
|
||||
processed.add(m.id);
|
||||
|
||||
// 记录需要添加的配偶(如果配偶不在当前组)
|
||||
const spouseId = spouseOf[m.id];
|
||||
if (spouseId && memberSet.has(spouseId) && !processed.has(spouseId)) {
|
||||
const spouseKey = familyKeyOfChild[spouseId] || `solo:${spouseId}`;
|
||||
if (spouseKey !== groupKey) {
|
||||
spousesToAdd.push(map[spouseId]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 然后添加配偶
|
||||
spousesToAdd.forEach(spouse => {
|
||||
if (!processed.has(spouse.id)) {
|
||||
result.push(spouse);
|
||||
processed.add(spouse.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortTopRow(row) {
|
||||
const sorted = row.slice().sort((a, b) => {
|
||||
const aKey = primaryFamilyKeyOfMember(a);
|
||||
const bKey = primaryFamilyKeyOfMember(b);
|
||||
if (aKey && bKey && aKey === bKey) {
|
||||
const fam = families[aKey];
|
||||
if (fam && fam.fatherId === a.id && fam.motherId === b.id) return -1;
|
||||
if (fam && fam.motherId === a.id && fam.fatherId === b.id) return 1;
|
||||
}
|
||||
return compareByBirthName(a, b);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
const maxRow = Math.max(...Object.values(rowOf));
|
||||
const gens = [];
|
||||
let prevOrderIndex = {};
|
||||
for (let g = 0; g <= maxRow; g++) {
|
||||
const rowMembers = members.filter(m => rowOf[m.id] === g);
|
||||
const ordered = g === 0 ? sortTopRow(rowMembers) : sortRowWithParentOrder(rowMembers, prevOrderIndex);
|
||||
gens.push(ordered);
|
||||
prevOrderIndex = {};
|
||||
ordered.forEach((m, idx) => { prevOrderIndex[m.id] = idx; });
|
||||
}
|
||||
|
||||
// ── 计算坐标 ──────────────────────────────────────────
|
||||
const posX = {};
|
||||
const posY = {};
|
||||
const rowWidth = gens.map(row => row.length * NODE_W + Math.max(0, row.length - 1) * H_GAP);
|
||||
const totalWidth = Math.max(...rowWidth, 200);
|
||||
|
||||
gens.forEach((row, gi) => {
|
||||
const rw = row.length * NODE_W + Math.max(0, row.length - 1) * H_GAP;
|
||||
const startX = (totalWidth - rw) / 2;
|
||||
row.forEach((m, i) => {
|
||||
posX[m.id] = startX + i * (NODE_W + H_GAP);
|
||||
posY[m.id] = gi * (NODE_H + V_GAP);
|
||||
});
|
||||
});
|
||||
|
||||
const totalHeight = (maxRow + 1) * (NODE_H + V_GAP) - V_GAP + 10;
|
||||
|
||||
// ── 渲染 HTML ─────────────────────────────────────────
|
||||
canvas.innerHTML = '';
|
||||
canvas.style.width = totalWidth + 'px';
|
||||
canvas.style.height = totalHeight + 'px';
|
||||
canvas.style.position = 'relative';
|
||||
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('class', 'lines');
|
||||
svg.setAttribute('width', totalWidth);
|
||||
svg.setAttribute('height', totalHeight);
|
||||
canvas.appendChild(svg);
|
||||
|
||||
members.forEach(m => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'node';
|
||||
node.style.left = posX[m.id] + 'px';
|
||||
node.style.top = posY[m.id] + 'px';
|
||||
node.style.width = NODE_W + 'px';
|
||||
const yearHtml = showYear ? `<div class="card-year">${m.birthYear}</div>` : '';
|
||||
node.innerHTML = `
|
||||
<div class="card-box ${m.gender === '男' ? 'male' : 'female'}">
|
||||
<div class="card-name">${m.name}</div>
|
||||
${yearHtml}
|
||||
</div>`;
|
||||
canvas.appendChild(node);
|
||||
});
|
||||
|
||||
// ── 绘制连线 ────────────────────────────────────────
|
||||
members.forEach(child => {
|
||||
const parents = [];
|
||||
if (child.fatherId && map[child.fatherId] && posX[child.fatherId] !== undefined) parents.push(map[child.fatherId]);
|
||||
if (child.motherId && map[child.motherId] && posX[child.motherId] !== undefined) parents.push(map[child.motherId]);
|
||||
if (parents.length === 0) return;
|
||||
|
||||
const cx = posX[child.id] + NODE_W / 2;
|
||||
const cy = posY[child.id];
|
||||
|
||||
if (parents.length === 1) {
|
||||
const p = parents[0];
|
||||
const px = posX[p.id] + NODE_W / 2;
|
||||
const py = posY[p.id] + NODE_H;
|
||||
const midY = py + (cy - py) / 2;
|
||||
drawPath(svg, `M ${px} ${py} L ${px} ${midY} L ${cx} ${midY} L ${cx} ${cy}`);
|
||||
} else {
|
||||
const p0x = posX[parents[0].id] + NODE_W / 2;
|
||||
const p1x = posX[parents[1].id] + NODE_W / 2;
|
||||
const p0y = posY[parents[0].id] + NODE_H;
|
||||
const p1y = posY[parents[1].id] + NODE_H;
|
||||
const midY = Math.max(p0y, p1y) + (cy - Math.max(p0y, p1y)) * 0.4;
|
||||
const midX = (p0x + p1x) / 2;
|
||||
drawPath(svg, `M ${p0x} ${p0y} L ${p0x} ${midY} L ${midX} ${midY}`);
|
||||
drawPath(svg, `M ${p1x} ${p1y} L ${p1x} ${midY} L ${midX} ${midY}`);
|
||||
drawPath(svg, `M ${midX} ${midY} L ${midX} ${cy - 2} L ${cx} ${cy - 2} L ${cx} ${cy}`);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('treeWrap').style.minWidth = (totalWidth + 40) + 'px';
|
||||
}
|
||||
|
||||
function drawPath(svg, d) {
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', d);
|
||||
path.setAttribute('stroke', '#a5d6a7');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
svg.appendChild(path);
|
||||
}
|
||||
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
217
src/index.ts
217
src/index.ts
@@ -1,26 +1,203 @@
|
||||
/**
|
||||
* Welcome to Cloudflare Workers! This is your first worker.
|
||||
*
|
||||
* - Run `npm run dev` in your terminal to start a development server
|
||||
* - Open a browser tab at http://localhost:8787/ to see your worker in action
|
||||
* - Run `npm run deploy` to publish your worker
|
||||
*
|
||||
* Bind resources to your worker in `wrangler.jsonc`. After adding bindings, a type definition for the
|
||||
* `Env` object can be regenerated with `npm run cf-typegen`.
|
||||
*
|
||||
* Learn more at https://developers.cloudflare.com/workers/
|
||||
* 唐家族谱网站 - Cloudflare Worker
|
||||
*/
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
switch (url.pathname) {
|
||||
case '/message':
|
||||
return new Response('Hello, World!');
|
||||
case '/random':
|
||||
return new Response(crypto.randomUUID());
|
||||
default:
|
||||
return new Response('Not Found', { status: 404 });
|
||||
interface FamilyMember {
|
||||
id: string;
|
||||
name: string;
|
||||
birthYear: number;
|
||||
gender: '男' | '女';
|
||||
generation?: string;
|
||||
fatherId?: string;
|
||||
motherId?: string;
|
||||
}
|
||||
|
||||
interface Env {
|
||||
FAMILY_DATA: KVNamespace;
|
||||
ASSETS: Fetcher;
|
||||
}
|
||||
|
||||
// 密码常量
|
||||
const VISITOR_PASSWORD = 'tangfamily';
|
||||
const ADMIN_PASSWORD = 'shumengya5201314';
|
||||
|
||||
// 生成简单的 session token
|
||||
function generateToken(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
// 验证 token
|
||||
async function verifyToken(token: string | null, env: Env, isAdmin: boolean = false): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
const session = await env.FAMILY_DATA.get(`session:${token}`);
|
||||
if (!session) return false;
|
||||
const sessionData = JSON.parse(session);
|
||||
return isAdmin ? sessionData.isAdmin === true : true;
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
async function handleLogin(request: Request, env: Env): Promise<Response> {
|
||||
try {
|
||||
const body = await request.json() as { password: string; isAdmin?: boolean };
|
||||
const isAdmin = body.isAdmin === true;
|
||||
const correctPassword = isAdmin ? ADMIN_PASSWORD : VISITOR_PASSWORD;
|
||||
|
||||
if (body.password === correctPassword) {
|
||||
const token = generateToken();
|
||||
const sessionData = { isAdmin, createdAt: Date.now() };
|
||||
await env.FAMILY_DATA.put(`session:${token}`, JSON.stringify(sessionData), { expirationTtl: 86400 }); // 24小时
|
||||
return new Response(JSON.stringify({ success: true, token, isAdmin }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify({ success: false, error: '密码错误' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ success: false, error: '请求格式错误' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有家族成员
|
||||
async function handleGetMembers(request: Request, env: Env): Promise<Response> {
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '') || null;
|
||||
if (!await verifyToken(token, env)) {
|
||||
return new Response(JSON.stringify({ error: '未授权' }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const membersJson = await env.FAMILY_DATA.get('members');
|
||||
const members = membersJson ? JSON.parse(membersJson) : [];
|
||||
return new Response(JSON.stringify(members), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// 添加或更新成员(仅管理员)
|
||||
async function handleSaveMember(request: Request, env: Env): Promise<Response> {
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '') || null;
|
||||
if (!await verifyToken(token, env, true)) {
|
||||
return new Response(JSON.stringify({ error: '需要管理员权限' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await request.json() as FamilyMember;
|
||||
if (!member.id) {
|
||||
member.id = crypto.randomUUID();
|
||||
}
|
||||
|
||||
const membersJson = await env.FAMILY_DATA.get('members');
|
||||
const members: FamilyMember[] = membersJson ? JSON.parse(membersJson) : [];
|
||||
|
||||
const index = members.findIndex(m => m.id === member.id);
|
||||
if (index >= 0) {
|
||||
members[index] = member;
|
||||
} else {
|
||||
members.push(member);
|
||||
}
|
||||
|
||||
await env.FAMILY_DATA.put('members', JSON.stringify(members));
|
||||
return new Response(JSON.stringify({ success: true, member }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ success: false, error: '保存失败' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 删除成员(仅管理员)
|
||||
async function handleDeleteMember(request: Request, env: Env): Promise<Response> {
|
||||
const token = request.headers.get('Authorization')?.replace('Bearer ', '') || null;
|
||||
if (!await verifyToken(token, env, true)) {
|
||||
return new Response(JSON.stringify({ error: '需要管理员权限' }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const { id } = await request.json() as { id: string };
|
||||
const membersJson = await env.FAMILY_DATA.get('members');
|
||||
const members: FamilyMember[] = membersJson ? JSON.parse(membersJson) : [];
|
||||
|
||||
const filtered = members.filter(m => m.id !== id);
|
||||
await env.FAMILY_DATA.put('members', JSON.stringify(filtered));
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify({ success: false, error: '删除失败' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// CORS 头
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
};
|
||||
|
||||
// 处理 OPTIONS 请求
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// API 路由
|
||||
if (path.startsWith('/api/')) {
|
||||
let response: Response;
|
||||
|
||||
switch (path) {
|
||||
case '/api/login':
|
||||
response = await handleLogin(request, env);
|
||||
break;
|
||||
case '/api/members':
|
||||
if (request.method === 'GET') {
|
||||
response = await handleGetMembers(request, env);
|
||||
} else if (request.method === 'POST') {
|
||||
response = await handleSaveMember(request, env);
|
||||
} else {
|
||||
response = new Response('Method not allowed', { status: 405 });
|
||||
}
|
||||
break;
|
||||
case '/api/members/delete':
|
||||
response = await handleDeleteMember(request, env);
|
||||
break;
|
||||
default:
|
||||
response = new Response('Not found', { status: 404 });
|
||||
}
|
||||
|
||||
// 添加 CORS 头到响应
|
||||
const headers = new Headers(response.headers);
|
||||
Object.entries(corsHeaders).forEach(([key, value]) => headers.set(key, value));
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers
|
||||
});
|
||||
}
|
||||
|
||||
// 静态文件由 assets 配置处理
|
||||
return env.ASSETS.fetch(request);
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
|
||||
47
src/init-data.js
Normal file
47
src/init-data.js
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 初始化家族数据脚本
|
||||
* 运行此脚本来初始化唐家族谱的初始数据
|
||||
*/
|
||||
|
||||
// 初始家族成员数据
|
||||
const initialMembers = [
|
||||
{
|
||||
id: 'tang-zhiming',
|
||||
name: '唐治明',
|
||||
birthYear: 1950, // 请根据实际情况修改
|
||||
gender: '男'
|
||||
},
|
||||
{
|
||||
id: 'shu-banglian',
|
||||
name: '舒邦莲',
|
||||
birthYear: 1952, // 请根据实际情况修改
|
||||
gender: '女'
|
||||
},
|
||||
{
|
||||
id: 'tang-yonghong',
|
||||
name: '唐永洪',
|
||||
birthYear: 1975, // 请根据实际情况修改
|
||||
gender: '男',
|
||||
fatherId: 'tang-zhiming',
|
||||
motherId: 'shu-banglian'
|
||||
},
|
||||
{
|
||||
id: 'tang-wei',
|
||||
name: '唐伟',
|
||||
birthYear: 1980, // 请根据实际情况修改
|
||||
gender: '男',
|
||||
fatherId: 'tang-zhiming',
|
||||
motherId: 'shu-banglian'
|
||||
}
|
||||
];
|
||||
|
||||
console.log('初始家族数据:');
|
||||
console.log(JSON.stringify(initialMembers, null, 2));
|
||||
|
||||
console.log('\n要初始化数据,请按以下步骤操作:');
|
||||
console.log('1. 运行 npm run dev 启动本地开发服务器');
|
||||
console.log('2. 访问 http://localhost:8787/admin-login.html');
|
||||
console.log('3. 使用管理员密码登录:shumengya5201314');
|
||||
console.log('4. 在管理后台逐个添加以上成员');
|
||||
console.log('\n或者,你也可以使用 Wrangler CLI 直接写入 KV:');
|
||||
console.log('wrangler kv:key put --binding=FAMILY_DATA "members" \'', JSON.stringify(initialMembers), '\'');
|
||||
136
test-fixed-sort.js
Normal file
136
test-fixed-sort.js
Normal file
@@ -0,0 +1,136 @@
|
||||
// 完整测试新算法
|
||||
const members = [{"id":"4661dd7b","name":"唐治明","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7"},{"id":"d4498bfb","name":"舒邦莲","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"f24a95c7","name":"唐有奎","birthYear":1900,"gender":"男","generation":"祖父"},{"id":"1be537aa","name":"唐治安","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7"},{"id":"e6d9c5b3","name":"万华群","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"4b78f55d","name":"唐治芳","birthYear":1960,"gender":"女","generation":"父亲","fatherId":"f24a95c7"},{"id":"a7bcc3b5","name":"邓能春","birthYear":1960,"gender":"男","generation":"父亲"}];
|
||||
|
||||
const map = {};
|
||||
members.forEach(m => map[m.id] = m);
|
||||
|
||||
const families = {
|
||||
'4661dd7b|d4498bfb': { fatherId: '4661dd7b', motherId: 'd4498bfb' },
|
||||
'1be537aa|e6d9c5b3': { fatherId: '1be537aa', motherId: 'e6d9c5b3' },
|
||||
'a7bcc3b5|4b78f55d': { fatherId: 'a7bcc3b5', motherId: '4b78f55d' }
|
||||
};
|
||||
|
||||
const familyKeyOfChild = {
|
||||
'4661dd7b': 'f24a95c7|',
|
||||
'1be537aa': 'f24a95c7|',
|
||||
'4b78f55d': 'f24a95c7|'
|
||||
};
|
||||
|
||||
const prevOrderIndex = { 'f24a95c7': 0 };
|
||||
const row = members;
|
||||
const memberSet = new Set(row.map(m => m.id));
|
||||
|
||||
// 1. 识别配偶关系
|
||||
const spouseOf = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
if (!fam || !fam.fatherId || !fam.motherId) return;
|
||||
if (memberSet.has(fam.fatherId) && memberSet.has(fam.motherId)) {
|
||||
spouseOf[fam.fatherId] = fam.motherId;
|
||||
spouseOf[fam.motherId] = fam.fatherId;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 按血缘分组
|
||||
const siblingGroups = {};
|
||||
row.forEach(m => {
|
||||
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
|
||||
if (!siblingGroups[key]) siblingGroups[key] = [];
|
||||
siblingGroups[key].push(m);
|
||||
});
|
||||
|
||||
console.log('兄弟姐妹分组:');
|
||||
Object.keys(siblingGroups).forEach(key => {
|
||||
console.log(` ${key}: ${siblingGroups[key].map(m => m.name).join(', ')}`);
|
||||
});
|
||||
|
||||
// 3. 构建家庭单元
|
||||
const familyUnits = [];
|
||||
const globalProcessed = new Set();
|
||||
|
||||
function compareByBirthName(a, b) {
|
||||
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
Object.keys(siblingGroups).forEach(groupKey => {
|
||||
const siblings = siblingGroups[groupKey].slice().sort(compareByBirthName);
|
||||
const unitMembers = [];
|
||||
|
||||
siblings.forEach(person => {
|
||||
if (globalProcessed.has(person.id)) return;
|
||||
globalProcessed.add(person.id);
|
||||
|
||||
const spouseId = spouseOf[person.id];
|
||||
if (spouseId && memberSet.has(spouseId)) {
|
||||
globalProcessed.add(spouseId);
|
||||
const spouse = map[spouseId];
|
||||
if (person.gender === '男') {
|
||||
unitMembers.push(person, spouse);
|
||||
} else {
|
||||
unitMembers.push(spouse, person);
|
||||
}
|
||||
} else {
|
||||
unitMembers.push(person);
|
||||
}
|
||||
});
|
||||
|
||||
if (unitMembers.length > 0) {
|
||||
familyUnits.push({ groupKey, members: unitMembers });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n家庭单元:');
|
||||
familyUnits.forEach((unit, i) => {
|
||||
const names = unit.members.map(m => m.name).join(' + ');
|
||||
console.log(` Unit ${i} (${unit.groupKey}): ${names}`);
|
||||
});
|
||||
|
||||
// 4. 计算权重并排序
|
||||
function getUnitWeight(unit) {
|
||||
const key = unit.groupKey;
|
||||
if (key.startsWith('solo:')) {
|
||||
return { parentIdx: 9999, birthYear: unit.members[0].birthYear };
|
||||
}
|
||||
|
||||
const parts = key.split('|');
|
||||
const fId = parts[0] || null;
|
||||
const mId = parts[1] || null;
|
||||
const indices = [];
|
||||
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
|
||||
if (mId && prevOrderIndex[mId] !== undefined) indices.push(prevOrderIndex[mId]);
|
||||
const parentIdx = indices.length > 0 ? Math.min(...indices) : 9999;
|
||||
|
||||
const birthYear = Math.max(...unit.members.map(m => m.birthYear));
|
||||
|
||||
return { parentIdx, birthYear };
|
||||
}
|
||||
|
||||
familyUnits.sort((a, b) => {
|
||||
const wa = getUnitWeight(a);
|
||||
const wb = getUnitWeight(b);
|
||||
|
||||
if (wa.parentIdx !== wb.parentIdx) return wa.parentIdx - wb.parentIdx;
|
||||
if (wa.birthYear !== wb.birthYear) return wb.birthYear - wa.birthYear;
|
||||
return 0;
|
||||
});
|
||||
|
||||
console.log('\n排序后的家庭单元:');
|
||||
familyUnits.forEach((unit, i) => {
|
||||
const names = unit.members.map(m => m.name).join(' + ');
|
||||
const weight = getUnitWeight(unit);
|
||||
console.log(` ${i}. ${names} (parentIdx=${weight.parentIdx}, birthYear=${weight.birthYear})`);
|
||||
});
|
||||
|
||||
// 5. 展开为最终结果
|
||||
const result = [];
|
||||
familyUnits.forEach(unit => result.push(...unit.members));
|
||||
|
||||
console.log('\n最终排序结果:');
|
||||
console.log(result.map(m => m.name).join(', '));
|
||||
|
||||
console.log('\n期望结果:');
|
||||
console.log('唐治安, 万华群, 唐治芳, 邓能春, 唐治明, 舒邦莲, 唐有奎');
|
||||
console.log('\n说明: 唐治安、唐治芳、唐治明都是唐有奎的孩子,应该排在前面(parentIdx=0)');
|
||||
console.log(' 舒邦莲、万华群、邓能春是外来配偶,紧贴在丈夫/妻子旁边');
|
||||
console.log(' 唐有奎没有父母信息,排到最后(parentIdx=9999)');
|
||||
77
test-layout.html
Normal file
77
test-layout.html
Normal file
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Layout Test</title>
|
||||
</head>
|
||||
<body>
|
||||
<pre id="output"></pre>
|
||||
<script>
|
||||
const members = [{"id":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","name":"唐治明","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1"},{"id":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984","name":"舒邦莲","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1","name":"唐有奎","birthYear":1900,"gender":"男","generation":"祖父"},{"id":"1be537aa-88cb-4b82-8c09-e18e05922499","name":"唐治安","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1"},{"id":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b","name":"万华群","birthYear":1960,"gender":"女","generation":"父亲"},{"name":"唐治芳","birthYear":1960,"gender":"女","generation":"父亲","fatherId":"f24a95c7-fa68-4f56-b0ed-e1f7fb2c91e1","id":"4b78f55d-34b0-4407-a672-3d6b3e826f0f"},{"name":"邓能春","birthYear":1960,"gender":"男","generation":"父亲","id":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678"},{"id":"208fb133-6a0b-4540-a1ff-ad8ce154c33f","name":"唐伟","birthYear":1990,"gender":"男","generation":"自己","fatherId":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","motherId":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984"},{"name":"唐永洪","birthYear":1990,"gender":"男","generation":"自己","fatherId":"4661dd7b-f3b9-42d1-bf00-b14899e2a92e","motherId":"d4498bfb-c0ce-4218-bc8f-3f0cc0cf6984","id":"ab86880b-502c-4249-9ce4-ff3327c3fd1b"},{"id":"de8e2fc8-2a1e-4dc1-b010-cd29b9312105","name":"唐雪","birthYear":1990,"gender":"女","generation":"自己","fatherId":"1be537aa-88cb-4b82-8c09-e18e05922499","motherId":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b"},{"name":"唐波","birthYear":1990,"gender":"男","generation":"自己","fatherId":"1be537aa-88cb-4b82-8c09-e18e05922499","motherId":"e6d9c5b3-6397-4d88-878b-e22e5c9f512b","id":"08a22bcd-5af6-4589-8739-9ea796c41a01"},{"name":"邓静","birthYear":1990,"gender":"女","generation":"自己","fatherId":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678","motherId":"4b78f55d-34b0-4407-a672-3d6b3e826f0f","id":"1ebd23c6-dadf-4cec-98fe-5bff971bc591"},{"id":"4c3f6e87-8756-48de-9141-5881778974ce","name":"邓正悦","birthYear":1990,"gender":"女","generation":"自己","fatherId":"a7bcc3b5-c4fc-4fca-8bd4-44cf6078d678","motherId":"4b78f55d-34b0-4407-a672-3d6b3e826f0f"},{"name":"张红","birthYear":1990,"gender":"女","generation":"自己","id":"a74c5ba0-5fe1-4862-a651-f81b641deb72"},{"name":"郭思琪","birthYear":1990,"gender":"女","generation":"自己","id":"95672071-9f60-4728-9209-00a24a01fb3a"},{"id":"a1fa7080-3e49-49bb-b2d7-ec94e005369f","name":"唐世宁","birthYear":2020,"gender":"女","generation":"子女","fatherId":"ab86880b-502c-4249-9ce4-ff3327c3fd1b","motherId":"95672071-9f60-4728-9209-00a24a01fb3a"},{"id":"74ab3d7a-3dad-4279-aecb-13e7ef12c0ff","name":"唐瑾瑜","birthYear":2020,"gender":"女","generation":"子女","fatherId":"08a22bcd-5af6-4589-8739-9ea796c41a01","motherId":"a74c5ba0-5fe1-4862-a651-f81b641deb72"},{"id":"6365ca6a-f31f-4b7a-b233-bc5a101f7025","name":"唐世严","birthYear":2020,"gender":"男","generation":"子女","fatherId":"08a22bcd-5af6-4589-8739-9ea796c41a01","motherId":"a74c5ba0-5fe1-4862-a651-f81b641deb72"}];
|
||||
|
||||
const GEN_ORDER = [
|
||||
'鼻祖','远祖','太祖','烈祖','天祖',
|
||||
'高祖','曾祖','祖父','父亲','自己',
|
||||
'子女','孙辈','曾孙','玄孙','来孙',
|
||||
'晜孙','仍孙','云孙','耳孙'
|
||||
];
|
||||
|
||||
const map = {};
|
||||
members.forEach(m => map[m.id] = m);
|
||||
|
||||
const childrenOf = {};
|
||||
members.forEach(m => {
|
||||
if (m.fatherId && map[m.fatherId]) (childrenOf[m.fatherId] = childrenOf[m.fatherId] || []).push(m.id);
|
||||
if (m.motherId && map[m.motherId]) (childrenOf[m.motherId] = childrenOf[m.motherId] || []).push(m.id);
|
||||
});
|
||||
Object.keys(childrenOf).forEach(pid => { childrenOf[pid] = [...new Set(childrenOf[pid])]; });
|
||||
|
||||
const rankOf = {};
|
||||
members.forEach(m => {
|
||||
if (m.generation) {
|
||||
const idx = GEN_ORDER.indexOf(m.generation);
|
||||
rankOf[m.id] = (idx >= 0 ? idx : 9) * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
const bfsQueue = Object.keys(rankOf);
|
||||
const visitedBFS = new Set(bfsQueue);
|
||||
let qi = 0;
|
||||
while (qi < bfsQueue.length) {
|
||||
const pid = bfsQueue[qi++];
|
||||
(childrenOf[pid] || []).forEach(cid => {
|
||||
const needed = Math.max(
|
||||
map[cid].fatherId && rankOf[map[cid].fatherId] !== undefined ? rankOf[map[cid].fatherId] + 1000 : 0,
|
||||
map[cid].motherId && rankOf[map[cid].motherId] !== undefined ? rankOf[map[cid].motherId] + 1000 : 0
|
||||
);
|
||||
if (rankOf[cid] === undefined || needed > rankOf[cid]) rankOf[cid] = needed;
|
||||
if (!visitedBFS.has(cid)) { visitedBFS.add(cid); bfsQueue.push(cid); }
|
||||
});
|
||||
}
|
||||
members.forEach(m => { if (rankOf[m.id] === undefined) rankOf[m.id] = 9 * 1000; });
|
||||
|
||||
const uniqueRanks = [...new Set(Object.values(rankOf))].sort((a, b) => a - b);
|
||||
const rankToRow = {};
|
||||
uniqueRanks.forEach((r, i) => { rankToRow[r] = i; });
|
||||
const rowOf = {};
|
||||
members.forEach(m => { rowOf[m.id] = rankToRow[rankOf[m.id]]; });
|
||||
|
||||
let output = "成员布局分析:\n\n";
|
||||
members.forEach(m => {
|
||||
output += `${m.name}: generation="${m.generation}" rank=${rankOf[m.id]} row=${rowOf[m.id]}`;
|
||||
if (m.fatherId) output += ` 父=${map[m.fatherId]?.name}(rank=${rankOf[m.fatherId]})`;
|
||||
if (m.motherId) output += ` 母=${map[m.motherId]?.name}(rank=${rankOf[m.motherId]})`;
|
||||
output += '\n';
|
||||
});
|
||||
|
||||
output += "\n\n按行分组:\n";
|
||||
const maxRow = Math.max(...Object.values(rowOf));
|
||||
for (let g = 0; g <= maxRow; g++) {
|
||||
const rowMembers = members.filter(m => rowOf[m.id] === g);
|
||||
output += `\n第${g}行: ${rowMembers.map(m => m.name).join(', ')}\n`;
|
||||
}
|
||||
|
||||
document.getElementById('output').textContent = output;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
172
test-left-right-spouse.js
Normal file
172
test-left-right-spouse.js
Normal file
@@ -0,0 +1,172 @@
|
||||
// 测试新的配偶放置策略(左右分离)
|
||||
|
||||
const members = [
|
||||
{ id: '4661dd7b', name: '唐治明', birthYear: 1960, gender: '男', generation: '父亲', fatherId: 'f24a95c7' },
|
||||
{ id: 'd4498bfb', name: '舒邦莲', birthYear: 1960, gender: '女', generation: '父亲' },
|
||||
{ id: 'f24a95c7', name: '唐有奎', birthYear: 1900, gender: '男', generation: '祖父' },
|
||||
{ id: '1be537aa', name: '唐治安', birthYear: 1960, gender: '男', generation: '父亲', fatherId: 'f24a95c7' },
|
||||
{ id: 'e6d9c5b3', name: '万华群', birthYear: 1960, gender: '女', generation: '父亲' },
|
||||
{ id: '4b78f55d', name: '唐治芳', birthYear: 1960, gender: '女', generation: '父亲', fatherId: 'f24a95c7' },
|
||||
{ id: 'a7bcc3b5', name: '邓能春', birthYear: 1960, gender: '男', generation: '父亲' },
|
||||
{ id: '208fb133', name: '唐伟', birthYear: 1990, gender: '男', generation: '自己', fatherId: '4661dd7b', motherId: 'd4498bfb' },
|
||||
{ id: 'ab86880b', name: '唐永洪', birthYear: 1990, gender: '男', generation: '自己', fatherId: '4661dd7b', motherId: 'd4498bfb' },
|
||||
{ id: 'de8e2fc8', name: '唐雪', birthYear: 1990, gender: '女', generation: '自己', fatherId: '1be537aa', motherId: 'e6d9c5b3' },
|
||||
{ id: '08a22bcd', name: '唐波', birthYear: 1990, gender: '男', generation: '自己', fatherId: '1be537aa', motherId: 'e6d9c5b3' },
|
||||
{ id: '1ebd23c6', name: '邓静', birthYear: 1990, gender: '女', generation: '自己', fatherId: 'a7bcc3b5', motherId: '4b78f55d' },
|
||||
{ id: '4c3f6e87', name: '邓正悦', birthYear: 1990, gender: '女', generation: '自己', fatherId: 'a7bcc3b5', motherId: '4b78f55d' },
|
||||
{ id: 'a74c5ba0', name: '张红', birthYear: 1990, gender: '女', generation: '自己' },
|
||||
{ id: '95672071', name: '郭思琪', birthYear: 1990, gender: '女', generation: '自己' },
|
||||
{ id: '6fb43781', name: '彭先生', birthYear: 1990, gender: '男', generation: '自己' },
|
||||
{ id: 'a1fa7080', name: '唐世宁', birthYear: 2020, gender: '女', generation: '子女', fatherId: 'ab86880b', motherId: '95672071' },
|
||||
{ id: '74ab3d7a', name: '唐瑾瑜', birthYear: 2020, gender: '女', generation: '子女', fatherId: '08a22bcd', motherId: 'a74c5ba0' },
|
||||
{ id: '6365ca6a', name: '唐世严', birthYear: 2020, gender: '男', generation: '子女', fatherId: '08a22bcd', motherId: 'a74c5ba0' },
|
||||
{ id: '4736c2bb', name: '彭瑾瑜', birthYear: 2020, gender: '女', generation: '子女', fatherId: '6fb43781', motherId: '1ebd23c6' }
|
||||
];
|
||||
|
||||
const map = {};
|
||||
members.forEach(m => (map[m.id] = m));
|
||||
|
||||
const families = {};
|
||||
const familyKeyOfChild = {};
|
||||
members.forEach(child => {
|
||||
const fatherId = child.fatherId && map[child.fatherId] ? child.fatherId : '';
|
||||
const motherId = child.motherId && map[child.motherId] ? child.motherId : '';
|
||||
if (!fatherId && !motherId) return;
|
||||
const key = `${fatherId}|${motherId}`;
|
||||
familyKeyOfChild[child.id] = key;
|
||||
if (!families[key]) families[key] = { fatherId: fatherId || null, motherId: motherId || null, children: [] };
|
||||
families[key].children.push(child.id);
|
||||
});
|
||||
|
||||
const gen2 = members.filter(m => m.generation === '自己');
|
||||
const memberSet = new Set(gen2.map(m => m.id));
|
||||
const spouseOf = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
if (!fam || !fam.fatherId || !fam.motherId) return;
|
||||
if (memberSet.has(fam.fatherId) && memberSet.has(fam.motherId)) {
|
||||
spouseOf[fam.fatherId] = fam.motherId;
|
||||
spouseOf[fam.motherId] = fam.fatherId;
|
||||
}
|
||||
});
|
||||
|
||||
const bloodMembers = [];
|
||||
const spouseMembers = new Set();
|
||||
|
||||
gen2.forEach(m => {
|
||||
const hasParents = (m.fatherId && map[m.fatherId]) || (m.motherId && map[m.motherId]);
|
||||
const isSpouseOnly = !hasParents && spouseOf[m.id];
|
||||
|
||||
if (isSpouseOnly) {
|
||||
spouseMembers.add(m.id);
|
||||
} else {
|
||||
bloodMembers.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
const siblingGroups = {};
|
||||
bloodMembers.forEach(m => {
|
||||
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
|
||||
if (!siblingGroups[key]) siblingGroups[key] = [];
|
||||
siblingGroups[key].push(m);
|
||||
});
|
||||
|
||||
function compareByBirthName(a, b) {
|
||||
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
const familyUnits = [];
|
||||
const globalProcessed = new Set();
|
||||
|
||||
Object.keys(siblingGroups).forEach(groupKey => {
|
||||
const siblings = siblingGroups[groupKey].slice().sort(compareByBirthName);
|
||||
|
||||
const leftSpouses = [];
|
||||
const coreMembers = [];
|
||||
const rightSpouses = [];
|
||||
|
||||
siblings.forEach(person => {
|
||||
if (globalProcessed.has(person.id)) return;
|
||||
globalProcessed.add(person.id);
|
||||
coreMembers.push(person);
|
||||
|
||||
const spouseId = spouseOf[person.id];
|
||||
if (spouseId && memberSet.has(spouseId)) {
|
||||
globalProcessed.add(spouseId);
|
||||
const spouse = map[spouseId];
|
||||
|
||||
if (person.gender === '男') {
|
||||
rightSpouses.push(spouse);
|
||||
} else {
|
||||
leftSpouses.push(spouse);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const unitMembers = [...leftSpouses, ...coreMembers, ...rightSpouses];
|
||||
|
||||
if (unitMembers.length > 0) {
|
||||
familyUnits.push({ groupKey, members: unitMembers });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('=== 新策略:家庭单元构建结果 ===');
|
||||
familyUnits.forEach((unit, idx) => {
|
||||
const names = unit.members.map(m => {
|
||||
const isSpouse = spouseMembers.has(m.id);
|
||||
return isSpouse ? `[${m.name}]` : m.name;
|
||||
});
|
||||
console.log(`单元 ${idx + 1} (${unit.groupKey}):`);
|
||||
console.log(` ${names.join(' - ')}`);
|
||||
});
|
||||
|
||||
// 排序(使用简化的父母位置)
|
||||
const gen1 = members.filter(m => m.generation === '父亲');
|
||||
const prevOrderIndex = {};
|
||||
gen1.forEach((m, idx) => {
|
||||
prevOrderIndex[m.id] = idx;
|
||||
});
|
||||
|
||||
function getUnitWeight(unit) {
|
||||
const key = unit.groupKey;
|
||||
if (key.startsWith('solo:')) {
|
||||
return { parentIdx: 9999, birthYear: unit.members[0].birthYear };
|
||||
}
|
||||
|
||||
const parts = key.split('|');
|
||||
const fId = parts[0] || null;
|
||||
const mId = parts[1] || null;
|
||||
const indices = [];
|
||||
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
|
||||
if (mId && prevOrderIndex[mId] !== undefined) indices.push(prevOrderIndex[mId]);
|
||||
const parentIdx = indices.length > 0 ? Math.min(...indices) : 9999;
|
||||
|
||||
const bloodMembersInUnit = unit.members.filter(m => !spouseMembers.has(m.id));
|
||||
const birthYear = bloodMembersInUnit.length > 0 ? Math.max(...bloodMembersInUnit.map(m => m.birthYear)) : Math.max(...unit.members.map(m => m.birthYear));
|
||||
|
||||
return { parentIdx, birthYear };
|
||||
}
|
||||
|
||||
familyUnits.sort((a, b) => {
|
||||
const wa = getUnitWeight(a);
|
||||
const wb = getUnitWeight(b);
|
||||
if (wa.parentIdx !== wb.parentIdx) return wa.parentIdx - wb.parentIdx;
|
||||
if (wa.birthYear !== wb.birthYear) return wb.birthYear - wa.birthYear;
|
||||
return 0;
|
||||
});
|
||||
|
||||
console.log('\n=== 最终排序结果(第三代)===');
|
||||
const result = [];
|
||||
familyUnits.forEach(unit => {
|
||||
result.push(...unit.members);
|
||||
});
|
||||
console.log(result.map(m => m.name).join(' → '));
|
||||
|
||||
console.log('\n=== 连接验证 ===');
|
||||
console.log('唐治明+舒邦莲 的孩子: 唐伟, 唐永洪');
|
||||
console.log(' 位置:', result.findIndex(m => m.name === '唐伟'), result.findIndex(m => m.name === '唐永洪'));
|
||||
console.log('唐治安+万华群 的孩子: 唐波, 唐雪');
|
||||
console.log(' 位置:', result.findIndex(m => m.name === '唐波'), result.findIndex(m => m.name === '唐雪'));
|
||||
console.log('唐治芳+邓能春 的孩子: 邓静, 邓正悦');
|
||||
console.log(' 位置:', result.findIndex(m => m.name === '邓静'), result.findIndex(m => m.name === '邓正悦'));
|
||||
128
test-new-sort.js
Normal file
128
test-new-sort.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// 测试新的排序逻辑
|
||||
const members = [{"id":"4661dd7b","name":"唐治明","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7"},{"id":"d4498bfb","name":"舒邦莲","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"f24a95c7","name":"唐有奎","birthYear":1900,"gender":"男","generation":"祖父"},{"id":"1be537aa","name":"唐治安","birthYear":1960,"gender":"男","generation":"父亲","fatherId":"f24a95c7"},{"id":"e6d9c5b3","name":"万华群","birthYear":1960,"gender":"女","generation":"父亲"},{"id":"4b78f55d","name":"唐治芳","birthYear":1960,"gender":"女","generation":"父亲","fatherId":"f24a95c7"},{"id":"a7bcc3b5","name":"邓能春","birthYear":1960,"gender":"男","generation":"父亲"}];
|
||||
|
||||
const map = {};
|
||||
members.forEach(m => map[m.id] = m);
|
||||
|
||||
// 构建配偶关系
|
||||
const families = {
|
||||
'4661dd7b|d4498bfb': { fatherId: '4661dd7b', motherId: 'd4498bfb' },
|
||||
'1be537aa|e6d9c5b3': { fatherId: '1be537aa', motherId: 'e6d9c5b3' },
|
||||
'a7bcc3b5|4b78f55d': { fatherId: 'a7bcc3b5', motherId: '4b78f55d' }
|
||||
};
|
||||
|
||||
const familyKeyOfChild = {
|
||||
'4661dd7b': 'f24a95c7|',
|
||||
'1be537aa': 'f24a95c7|',
|
||||
'4b78f55d': 'f24a95c7|'
|
||||
};
|
||||
|
||||
const prevOrderIndex = { 'f24a95c7': 0 };
|
||||
|
||||
// 识别配偶关系
|
||||
const spouseOf = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
spouseOf[fam.fatherId] = fam.motherId;
|
||||
spouseOf[fam.motherId] = fam.fatherId;
|
||||
});
|
||||
|
||||
console.log('配偶关系:', spouseOf);
|
||||
|
||||
// 按血缘分组(兄弟姐妹)
|
||||
const siblingGroups = {};
|
||||
members.forEach(m => {
|
||||
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
|
||||
if (!siblingGroups[key]) siblingGroups[key] = [];
|
||||
siblingGroups[key].push(m);
|
||||
});
|
||||
|
||||
console.log('\n兄弟姐妹分组:');
|
||||
Object.keys(siblingGroups).forEach(key => {
|
||||
console.log(` ${key}: ${siblingGroups[key].map(m => m.name).join(', ')}`);
|
||||
});
|
||||
|
||||
// 构建家庭单元(每个兄弟姐妹 + 配偶)
|
||||
const familyUnits = [];
|
||||
Object.keys(siblingGroups).forEach(groupKey => {
|
||||
const siblings = siblingGroups[groupKey];
|
||||
const processed = new Set();
|
||||
|
||||
siblings.forEach(person => {
|
||||
if (processed.has(person.id)) return;
|
||||
processed.add(person.id);
|
||||
|
||||
const spouseId = spouseOf[person.id];
|
||||
if (spouseId && map[spouseId] && !processed.has(spouseId)) {
|
||||
processed.add(spouseId);
|
||||
const spouse = map[spouseId];
|
||||
familyUnits.push({
|
||||
type: 'couple',
|
||||
groupKey,
|
||||
members: person.gender === '男' ? [person, spouse] : [spouse, person]
|
||||
});
|
||||
} else {
|
||||
familyUnits.push({
|
||||
type: 'single',
|
||||
groupKey,
|
||||
members: [person]
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n家庭单元:');
|
||||
familyUnits.forEach((unit, i) => {
|
||||
const names = unit.members.map(m => m.name).join(' + ');
|
||||
console.log(` Unit ${i} (${unit.type}, ${unit.groupKey}): ${names}`);
|
||||
});
|
||||
|
||||
// 对家庭单元排序
|
||||
function getUnitWeight(unit) {
|
||||
const key = unit.groupKey;
|
||||
if (key.startsWith('solo:')) {
|
||||
return { parentIdx: null, birthYear: unit.members[0].birthYear };
|
||||
}
|
||||
|
||||
const parts = key.split('|');
|
||||
const fId = parts[0] || null;
|
||||
const indices = [];
|
||||
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
|
||||
const parentIdx = indices.length > 0 ? Math.min(...indices) : null;
|
||||
const birthYear = Math.max(...unit.members.map(m => m.birthYear));
|
||||
|
||||
return { parentIdx, birthYear };
|
||||
}
|
||||
|
||||
familyUnits.sort((a, b) => {
|
||||
const wa = getUnitWeight(a);
|
||||
const wb = getUnitWeight(b);
|
||||
|
||||
if (wa.parentIdx !== null && wb.parentIdx !== null) {
|
||||
if (wa.parentIdx !== wb.parentIdx) return wa.parentIdx - wb.parentIdx;
|
||||
} else if (wa.parentIdx !== null) {
|
||||
return -1;
|
||||
} else if (wb.parentIdx !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (wa.birthYear !== wb.birthYear) return wb.birthYear - wa.birthYear;
|
||||
return 0;
|
||||
});
|
||||
|
||||
console.log('\n排序后的家庭单元:');
|
||||
familyUnits.forEach((unit, i) => {
|
||||
const names = unit.members.map(m => m.name).join(' + ');
|
||||
const weight = getUnitWeight(unit);
|
||||
console.log(` ${i}. ${names} (parentIdx=${weight.parentIdx}, birthYear=${weight.birthYear})`);
|
||||
});
|
||||
|
||||
// 展开为最终结果
|
||||
const result = [];
|
||||
familyUnits.forEach(unit => result.push(...unit.members));
|
||||
|
||||
console.log('\n最终排序结果:');
|
||||
console.log(result.map(m => m.name).join(', '));
|
||||
|
||||
console.log('\n期望结果:');
|
||||
console.log('唐治安, 万华群, 唐治芳, 邓能春, 唐治明, 舒邦莲');
|
||||
20
test-sort-siblings.js
Normal file
20
test-sort-siblings.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// 测试 compareByBirthName 排序
|
||||
|
||||
const members = [
|
||||
{ id: 'de8e2fc8', name: '唐雪', birthYear: 1990, gender: '女' },
|
||||
{ id: '08a22bcd', name: '唐波', birthYear: 1990, gender: '男' }
|
||||
];
|
||||
|
||||
function compareByBirthName(a, b) {
|
||||
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
const sorted = members.slice().sort(compareByBirthName);
|
||||
console.log('排序前:', members.map(m => m.name).join(', '));
|
||||
console.log('排序后:', sorted.map(m => m.name).join(', '));
|
||||
|
||||
// 测试中文姓名比较
|
||||
console.log('\n中文比较:');
|
||||
console.log('唐波 vs 唐雪:', '唐波'.localeCompare('唐雪', 'zh-Hans-CN'));
|
||||
console.log('唐雪 vs 唐波:', '唐雪'.localeCompare('唐波', 'zh-Hans-CN'));
|
||||
210
test-spouse-fix.js
Normal file
210
test-spouse-fix.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// 测试修复后的配偶排序逻辑
|
||||
|
||||
const members = [
|
||||
{ id: '4661dd7b', name: '唐治明', birthYear: 1960, gender: '男', generation: '父亲', fatherId: 'f24a95c7' },
|
||||
{ id: 'd4498bfb', name: '舒邦莲', birthYear: 1960, gender: '女', generation: '父亲' },
|
||||
{ id: 'f24a95c7', name: '唐有奎', birthYear: 1900, gender: '男', generation: '祖父' },
|
||||
{ id: '1be537aa', name: '唐治安', birthYear: 1960, gender: '男', generation: '父亲', fatherId: 'f24a95c7' },
|
||||
{ id: 'e6d9c5b3', name: '万华群', birthYear: 1960, gender: '女', generation: '父亲' },
|
||||
{ id: '4b78f55d', name: '唐治芳', birthYear: 1960, gender: '女', generation: '父亲', fatherId: 'f24a95c7' },
|
||||
{ id: 'a7bcc3b5', name: '邓能春', birthYear: 1960, gender: '男', generation: '父亲' },
|
||||
{ id: '208fb133', name: '唐伟', birthYear: 1990, gender: '男', generation: '自己', fatherId: '4661dd7b', motherId: 'd4498bfb' },
|
||||
{ id: 'ab86880b', name: '唐永洪', birthYear: 1990, gender: '男', generation: '自己', fatherId: '4661dd7b', motherId: 'd4498bfb' },
|
||||
{ id: 'de8e2fc8', name: '唐雪', birthYear: 1990, gender: '女', generation: '自己', fatherId: '1be537aa', motherId: 'e6d9c5b3' },
|
||||
{ id: '08a22bcd', name: '唐波', birthYear: 1990, gender: '男', generation: '自己', fatherId: '1be537aa', motherId: 'e6d9c5b3' },
|
||||
{ id: '1ebd23c6', name: '邓静', birthYear: 1990, gender: '女', generation: '自己', fatherId: 'a7bcc3b5', motherId: '4b78f55d' },
|
||||
{ id: '4c3f6e87', name: '邓正悦', birthYear: 1990, gender: '女', generation: '自己', fatherId: 'a7bcc3b5', motherId: '4b78f55d' },
|
||||
{ id: 'a74c5ba0', name: '张红', birthYear: 1990, gender: '女', generation: '自己' }, // 唐波的配偶
|
||||
{ id: '95672071', name: '郭思琪', birthYear: 1990, gender: '女', generation: '自己' }, // 唐永洪的配偶
|
||||
{ id: '6fb43781', name: '彭先生', birthYear: 1990, gender: '男', generation: '自己' }, // 邓静的配偶
|
||||
{ id: 'a1fa7080', name: '唐世宁', birthYear: 2020, gender: '女', generation: '子女', fatherId: 'ab86880b', motherId: '95672071' },
|
||||
{ id: '74ab3d7a', name: '唐瑾瑜', birthYear: 2020, gender: '女', generation: '子女', fatherId: '08a22bcd', motherId: 'a74c5ba0' },
|
||||
{ id: '6365ca6a', name: '唐世严', birthYear: 2020, gender: '男', generation: '子女', fatherId: '08a22bcd', motherId: 'a74c5ba0' },
|
||||
{ id: '4736c2bb', name: '彭瑾瑜', birthYear: 2020, gender: '女', generation: '子女', fatherId: '6fb43781', motherId: '1ebd23c6' }
|
||||
];
|
||||
|
||||
const map = {};
|
||||
members.forEach(m => (map[m.id] = m));
|
||||
|
||||
// 识别家族关系
|
||||
const families = {};
|
||||
const familyKeyOfChild = {};
|
||||
members.forEach(child => {
|
||||
const fatherId = child.fatherId && map[child.fatherId] ? child.fatherId : '';
|
||||
const motherId = child.motherId && map[child.motherId] ? child.motherId : '';
|
||||
if (!fatherId && !motherId) return;
|
||||
const key = `${fatherId}|${motherId}`;
|
||||
familyKeyOfChild[child.id] = key;
|
||||
if (!families[key]) families[key] = { fatherId: fatherId || null, motherId: motherId || null, children: [] };
|
||||
families[key].children.push(child.id);
|
||||
});
|
||||
|
||||
console.log('=== 家族单元 ===');
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
const fatherName = fam.fatherId ? map[fam.fatherId].name : '(无)';
|
||||
const motherName = fam.motherId ? map[fam.motherId].name : '(无)';
|
||||
const childrenNames = fam.children.map(id => map[id].name).join(', ');
|
||||
console.log(`${fatherName} + ${motherName} → ${childrenNames}`);
|
||||
});
|
||||
|
||||
// 第二代(自己)的成员
|
||||
const gen2 = members.filter(m => m.generation === '自己');
|
||||
|
||||
// 识别配偶关系
|
||||
const memberSet = new Set(gen2.map(m => m.id));
|
||||
const spouseOf = {};
|
||||
Object.keys(families).forEach(key => {
|
||||
const fam = families[key];
|
||||
if (!fam || !fam.fatherId || !fam.motherId) return;
|
||||
if (memberSet.has(fam.fatherId) && memberSet.has(fam.motherId)) {
|
||||
spouseOf[fam.fatherId] = fam.motherId;
|
||||
spouseOf[fam.motherId] = fam.fatherId;
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== 配偶关系 ===');
|
||||
Object.keys(spouseOf).forEach(id => {
|
||||
const spouseId = spouseOf[id];
|
||||
if (id < spouseId) {
|
||||
// 只打印一次
|
||||
console.log(`${map[id].name} + ${map[spouseId].name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 分离血缘成员和配偶
|
||||
const bloodMembers = [];
|
||||
const spouseMembers = new Set();
|
||||
|
||||
gen2.forEach(m => {
|
||||
const hasParents = (m.fatherId && map[m.fatherId]) || (m.motherId && map[m.motherId]);
|
||||
const isSpouseOnly = !hasParents && spouseOf[m.id]; // 没有父母但有配偶
|
||||
|
||||
if (isSpouseOnly) {
|
||||
spouseMembers.add(m.id);
|
||||
} else {
|
||||
bloodMembers.push(m);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== 血缘成员 ===');
|
||||
console.log(bloodMembers.map(m => m.name).join(', '));
|
||||
|
||||
console.log('\n=== 仅配偶身份的成员 ===');
|
||||
console.log([...spouseMembers].map(id => map[id].name).join(', '));
|
||||
|
||||
// 按血缘分组
|
||||
const siblingGroups = {};
|
||||
bloodMembers.forEach(m => {
|
||||
const key = familyKeyOfChild[m.id] || `solo:${m.id}`;
|
||||
if (!siblingGroups[key]) siblingGroups[key] = [];
|
||||
siblingGroups[key].push(m);
|
||||
});
|
||||
|
||||
console.log('\n=== 血缘兄弟姐妹分组 ===');
|
||||
Object.keys(siblingGroups).forEach(key => {
|
||||
const group = siblingGroups[key];
|
||||
const names = group.map(m => m.name).join(', ');
|
||||
console.log(`${key}: ${names}`);
|
||||
});
|
||||
|
||||
// 构建家庭单元
|
||||
function compareByBirthName(a, b) {
|
||||
if (a.birthYear !== b.birthYear) return b.birthYear - a.birthYear;
|
||||
return a.name.localeCompare(b.name, 'zh-Hans-CN');
|
||||
}
|
||||
|
||||
const familyUnits = [];
|
||||
const globalProcessed = new Set();
|
||||
|
||||
Object.keys(siblingGroups).forEach(groupKey => {
|
||||
const siblings = siblingGroups[groupKey].slice().sort(compareByBirthName);
|
||||
|
||||
const unitMembers = [];
|
||||
siblings.forEach(person => {
|
||||
if (globalProcessed.has(person.id)) return;
|
||||
globalProcessed.add(person.id);
|
||||
|
||||
const spouseId = spouseOf[person.id];
|
||||
if (spouseId && memberSet.has(spouseId)) {
|
||||
globalProcessed.add(spouseId);
|
||||
const spouse = map[spouseId];
|
||||
if (person.gender === '男') {
|
||||
unitMembers.push(person, spouse);
|
||||
} else {
|
||||
unitMembers.push(spouse, person);
|
||||
}
|
||||
} else {
|
||||
unitMembers.push(person);
|
||||
}
|
||||
});
|
||||
|
||||
if (unitMembers.length > 0) {
|
||||
familyUnits.push({ groupKey, members: unitMembers });
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n=== 家庭单元构建结果 ===');
|
||||
familyUnits.forEach((unit, idx) => {
|
||||
const names = unit.members.map(m => m.name).join(', ');
|
||||
console.log(`单元 ${idx + 1} (${unit.groupKey}): ${names}`);
|
||||
});
|
||||
|
||||
// 计算上一行的顺序
|
||||
const gen1 = members.filter(m => m.generation === '父亲');
|
||||
const prevOrderIndex = {};
|
||||
gen1.forEach((m, idx) => {
|
||||
prevOrderIndex[m.id] = idx;
|
||||
});
|
||||
|
||||
// 排序家庭单元
|
||||
function getUnitWeight(unit) {
|
||||
const key = unit.groupKey;
|
||||
if (key.startsWith('solo:')) {
|
||||
const member = unit.members.find(m => !spouseMembers.has(m.id));
|
||||
if (member) {
|
||||
const spouseId = spouseOf[member.id];
|
||||
if (spouseId && prevOrderIndex[spouseId] !== undefined) {
|
||||
return { parentIdx: prevOrderIndex[spouseId], birthYear: member.birthYear };
|
||||
}
|
||||
}
|
||||
return { parentIdx: 9999, birthYear: unit.members[0].birthYear };
|
||||
}
|
||||
|
||||
const parts = key.split('|');
|
||||
const fId = parts[0] || null;
|
||||
const mId = parts[1] || null;
|
||||
const indices = [];
|
||||
if (fId && prevOrderIndex[fId] !== undefined) indices.push(prevOrderIndex[fId]);
|
||||
if (mId && prevOrderIndex[mId] !== undefined) indices.push(prevOrderIndex[mId]);
|
||||
const parentIdx = indices.length > 0 ? Math.min(...indices) : 9999;
|
||||
|
||||
const bloodMembersInUnit = unit.members.filter(m => !spouseMembers.has(m.id));
|
||||
const birthYear = bloodMembersInUnit.length > 0 ? Math.max(...bloodMembersInUnit.map(m => m.birthYear)) : Math.max(...unit.members.map(m => m.birthYear));
|
||||
|
||||
return { parentIdx, birthYear };
|
||||
}
|
||||
|
||||
familyUnits.sort((a, b) => {
|
||||
const wa = getUnitWeight(a);
|
||||
const wb = getUnitWeight(b);
|
||||
|
||||
if (wa.parentIdx !== wb.parentIdx) return wa.parentIdx - wb.parentIdx;
|
||||
if (wa.birthYear !== wb.birthYear) return wb.birthYear - wa.birthYear;
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
console.log('\n=== 排序后的家庭单元 ===');
|
||||
familyUnits.forEach((unit, idx) => {
|
||||
const weight = getUnitWeight(unit);
|
||||
const names = unit.members.map(m => m.name).join(', ');
|
||||
console.log(`单元 ${idx + 1} (parentIdx=${weight.parentIdx}, birthYear=${weight.birthYear}): ${names}`);
|
||||
});
|
||||
|
||||
console.log('\n=== 最终排序结果(第二代)===');
|
||||
const result = [];
|
||||
familyUnits.forEach(unit => {
|
||||
result.push(...unit.members);
|
||||
});
|
||||
console.log(result.map(m => m.name).join(' → '));
|
||||
@@ -1,41 +1,82 @@
|
||||
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
|
||||
import { SELF } from 'cloudflare:test';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import worker from '../src';
|
||||
|
||||
describe('Hello World user worker', () => {
|
||||
describe('request for /message', () => {
|
||||
it('/ responds with "Hello, World!" (unit style)', async () => {
|
||||
const request = new Request<unknown, IncomingRequestCfProperties>('http://example.com/message');
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello, World!"`);
|
||||
describe('tangfamily API', () => {
|
||||
describe('POST /api/login', () => {
|
||||
it('rejects wrong visitor password', async () => {
|
||||
const res = await SELF.fetch('http://example.com/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'wrongpass', isAdmin: false }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
const data = await res.json() as { success: boolean };
|
||||
expect(data.success).toBe(false);
|
||||
});
|
||||
|
||||
it('responds with "Hello, World!" (integration style)', async () => {
|
||||
const request = new Request('http://example.com/message');
|
||||
const response = await SELF.fetch(request);
|
||||
expect(await response.text()).toMatchInlineSnapshot(`"Hello, World!"`);
|
||||
it('accepts correct visitor password', async () => {
|
||||
const res = await SELF.fetch('http://example.com/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'tangfamily', isAdmin: false }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json() as { success: boolean; token: string };
|
||||
expect(data.success).toBe(true);
|
||||
expect(typeof data.token).toBe('string');
|
||||
});
|
||||
|
||||
it('rejects wrong admin password', async () => {
|
||||
const res = await SELF.fetch('http://example.com/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'wrongpass', isAdmin: true }),
|
||||
});
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('accepts correct admin password', async () => {
|
||||
const res = await SELF.fetch('http://example.com/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'shumengya5201314', isAdmin: true }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json() as { success: boolean; isAdmin: boolean };
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.isAdmin).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('request for /random', () => {
|
||||
it('/ responds with a random UUID (unit style)', async () => {
|
||||
const request = new Request<unknown, IncomingRequestCfProperties>('http://example.com/random');
|
||||
// Create an empty context to pass to `worker.fetch()`.
|
||||
const ctx = createExecutionContext();
|
||||
const response = await worker.fetch(request, env, ctx);
|
||||
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
|
||||
await waitOnExecutionContext(ctx);
|
||||
expect(await response.text()).toMatch(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/);
|
||||
describe('GET /api/members', () => {
|
||||
it('returns 401 without token', async () => {
|
||||
const res = await SELF.fetch('http://example.com/api/members');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('responds with a random UUID (integration style)', async () => {
|
||||
const request = new Request('http://example.com/random');
|
||||
const response = await SELF.fetch(request);
|
||||
expect(await response.text()).toMatch(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/);
|
||||
it('returns member list with valid token', async () => {
|
||||
// login first
|
||||
const loginRes = await SELF.fetch('http://example.com/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'tangfamily', isAdmin: false }),
|
||||
});
|
||||
const { token } = await loginRes.json() as { token: string };
|
||||
|
||||
const res = await SELF.fetch('http://example.com/api/members', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const data = await res.json();
|
||||
expect(Array.isArray(data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown routes', () => {
|
||||
it('returns 404 for unknown API route', async () => {
|
||||
const res = await SELF.fetch('http://example.com/api/unknown');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
3
worker-configuration.d.ts
vendored
3
worker-configuration.d.ts
vendored
@@ -1,11 +1,12 @@
|
||||
/* eslint-disable */
|
||||
// Generated by Wrangler by running `wrangler types` (hash: b739a9c19cff1463949c4db47674ed86)
|
||||
// Generated by Wrangler by running `wrangler types` (hash: ffe47106a350f18a15c69d61ba9b3bae)
|
||||
// Runtime types generated with workerd@1.20260217.0 2026-02-17 global_fetch_strictly_public,nodejs_compat
|
||||
declare namespace Cloudflare {
|
||||
interface GlobalProps {
|
||||
mainModule: typeof import("./src/index");
|
||||
}
|
||||
interface Env {
|
||||
FAMILY_DATA: KVNamespace;
|
||||
}
|
||||
}
|
||||
interface Env extends Cloudflare.Env {}
|
||||
|
||||
@@ -17,28 +17,11 @@
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true
|
||||
}
|
||||
/**
|
||||
* Smart Placement
|
||||
* https://developers.cloudflare.com/workers/configuration/smart-placement/#smart-placement
|
||||
*/
|
||||
// "placement": { "mode": "smart" }
|
||||
/**
|
||||
* Bindings
|
||||
* Bindings allow your Worker to interact with resources on the Cloudflare Developer Platform, including
|
||||
* databases, object storage, AI inference, real-time communication and more.
|
||||
* https://developers.cloudflare.com/workers/runtime-apis/bindings/
|
||||
*/
|
||||
/**
|
||||
* Environment Variables
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#environment-variables
|
||||
* Note: Use secrets to store sensitive data.
|
||||
* https://developers.cloudflare.com/workers/configuration/secrets/
|
||||
*/
|
||||
// "vars": { "MY_VARIABLE": "production_value" }
|
||||
/**
|
||||
* Service Bindings (communicate between multiple Workers)
|
||||
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
|
||||
*/
|
||||
// "services": [ { "binding": "MY_SERVICE", "service": "my-service" } ]
|
||||
},
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "FAMILY_DATA",
|
||||
"id": "07e25767617e469aa297cdf79ceeb010"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user