23 Commits

Author SHA1 Message Date
fb149b4e00 publish personal install packages 20260520 2026-05-19 23:58:32 +08:00
c7ed88b2d8 use resume title for pdf download filename 2026-05-19 23:39:50 +08:00
a3569a52e7 2026-05-19-23-23-50 回滚简历PDF下载文件名 2026-05-19 23:28:47 +08:00
d77954ba94 set resume pdf filename to Chinese title 2026-05-19 23:22:24 +08:00
d2edebecda 2026-05-19-23-10-56 修改简历PDF下载文件名 2026-05-19 23:16:57 +08:00
999a1314a8 harden resume filename cache bust patch 2026-05-19 23:15:02 +08:00
602f00262b fix browser pdf buffer polyfill 2026-05-19 23:06:27 +08:00
475bab8bf6 restart app after filename asset patch 2026-05-19 23:00:08 +08:00
beb14bf834 fix reactive resume pdf download filename 2026-05-19 22:52:34 +08:00
fc88270737 fix qnap package root path 2026-05-19 00:33:17 +08:00
63867b4677 rename personal resume packages and add seed data 2026-05-19 00:22:33 +08:00
663143a30a publish reactive resume install packages 2026-05-18 23:47:24 +08:00
f77cf67b76 hide unused resume keywords section 2026-05-18 22:59:11 +08:00
1460b71915 adjust resume sidebar and text spacing 2026-05-18 22:11:18 +08:00
37131e1123 add volunteer activities and compact resume layout 2026-05-18 21:03:59 +08:00
deb999ff4d refine resume typography and spacing 2026-05-18 20:06:37 +08:00
2a2274716e fix reactive resume page loading 2026-05-18 18:02:13 +08:00
92eef39c5a fix resume builder apostrophe parsing 2026-05-18 16:39:05 +08:00
5bb693e8f6 clear reactive resume service worker cache 2026-05-18 16:25:30 +08:00
8a21a46554 fit resume to two pages 2026-05-17 09:47:39 +08:00
54e8f370c8 fix resume pagination and sidebar layout 2026-05-17 01:32:17 +08:00
2c3ce10837 update resume ordering and lab group info 2026-05-17 01:15:05 +08:00
9c65575eb3 refine resume sidebar layout and sharing 2026-05-17 00:53:14 +08:00
94 changed files with 15515 additions and 88 deletions

2
.env
View File

@@ -44,7 +44,7 @@ S3_ENDPOINT=
S3_BUCKET=
S3_FORCE_PATH_STYLE=false
REDIS_URL=
ENCRYPTION_SECRET=
ENCRYPTION_SECRET=10ddcad9814eaa5fa5bef8ba85d58a8122f3ede148bad11bc340899b5014af45
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
FLAG_DISABLE_SIGNUPS=false

View File

@@ -13,6 +13,14 @@
- `生成简历/backups/`: 每次线上 `resume` 被替换前导出的历史版本备份。
- `生成简历/visual-assets/`: 图文并茂版简历使用的轻量展示图。
- `reactive_resume_data`: Reactive Resume 本地上传文件持久化 Docker 命名卷。
- `packages/`: 三套可读可改的安装包目录。
- `dist/`: 三套安装包的 `.tar.gz` / `.zip` 发布归档和 `SHA256SUMS`
## 安装包
- `reactive-resume-clean-install-20260519`: 纯净迁移模板域名、端口、FRP、密钥均为待填写占位。
- `reactive-resume-personal-direct-20260519`: 服务器直接运行版,预置 `https://isiseg.huijutec.cn`、FRP `remotePort = 10004`,并内置当前简历与上传图片。
- `reactive-resume-personal-qnap-nas-20260519`: 威联通 QNAP NAS 直接部署版,预置 `/share/Container/Reactive_Resume_Personal`、本地端口 `3004`、FRP `10004`,并内置当前简历与上传图片。
## 启动

6
dist/SHA256SUMS vendored Normal file
View File

@@ -0,0 +1,6 @@
f9daa11eeb735e1920d822094a7caf3f7eebcccf8467755bb6f904c68a53bdbf reactive-resume-clean-install-20260519.tar.gz
18b247b33feaf9ccc4ce7516fb76ea3523c578e3099c77e7549bdfa2c68fd658 reactive-resume-personal-direct-20260519.tar.gz
d2718004a8a0592f38096f426ef307c9df99b2f090dac733fb6c0f412d2e4916 reactive-resume-personal-qnap-nas-20260519.tar.gz
54bf5114e8ca9f29ae1feb0510f738396f354174f691bf96e863b41058742a58 reactive-resume-clean-install-20260519.zip
b7cdb405f24ff7653aac9d21f3bd347884e377c8a993d8bb1b7d9d77295425d2 reactive-resume-personal-direct-20260519.zip
d847a76e3cac28f9e973e21ba7bcbac89ccc940345694b3a3483cde9eed01ee0 reactive-resume-personal-qnap-nas-20260519.zip

3
dist/SHA256SUMS-20260520 vendored Normal file
View File

@@ -0,0 +1,3 @@
fa3b2b64a9afd7af60f57cfda8431af4e171cc1cdba4a6a2b89d50000a574f54 reactive-resume-clean-install-20260520.zip
4e95c039777ae2af6a216528ebd97911f59aa8a80c7816acde7ec8424eb6e59d reactive-resume-personal-direct-20260520.zip
708209d2ea066633ebd099dfe6554e41681be114f124e2656fc1233496aad534 reactive-resume-personal-qnap-nas-20260520.zip

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,59 @@
# ===== Reactive Resume clean install environment =====
# Copy this file to .env, then replace every CHANGE_ME / YOUR_* value.
TZ=Asia/Shanghai
# Public URL behind your reverse proxy.
APP_URL=https://YOUR_DOMAIN.example.com
# Local debug binding on this Docker host.
# Use 127.0.0.1 if only frpc / local reverse proxy should reach the app.
LOCAL_BIND_IP=127.0.0.1
LOCAL_APP_PORT=CHANGE_ME_LOCAL_PORT
# PostgreSQL.
POSTGRES_DB=reactive_resume
POSTGRES_USER=reactive_resume
POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD
DATABASE_URL=postgresql://reactive_resume:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/reactive_resume
# Generate with: openssl rand -hex 32
AUTH_SECRET=CHANGE_ME_64_HEX_AUTH_SECRET
ENCRYPTION_SECRET=CHANGE_ME_64_HEX_ENCRYPTION_SECRET
# Optional email/auth/storage/AI settings can be enabled later.
BETTER_AUTH_API_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
OAUTH_PROVIDER_NAME=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_DISCOVERY_URL=
OAUTH_AUTHORIZATION_URL=
OAUTH_TOKEN_URL=
OAUTH_USER_INFO_URL=
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=
OAUTH_SCOPES=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Reactive Resume <noreply@YOUR_DOMAIN.example.com>
SMTP_SECURE=false
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_BUCKET=
S3_FORCE_PATH_STYLE=false
REDIS_URL=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
FLAG_DISABLE_SIGNUPS=false
FLAG_DISABLE_EMAIL_AUTH=false
FLAG_DISABLE_IMAGE_PROCESSING=false
FLAG_ALLOW_UNSAFE_AI_BASE_URL=false

View File

@@ -0,0 +1,38 @@
# Reactive Resume 纯净迁移安装包
这是一套不绑定具体域名、端口、FRP 服务器或密码的纯净安装包,适合迁移到新服务器或新 NAS 前作为模板使用。
## 文件说明
- `compose.yml`:通用 Linux / Docker Compose 部署版
- `compose-Nas.yml`:威联通 QNAP QTS / Container Station 部署模板
- `frpc.yaml`:通用服务器版 frpc 配置模板
- `.env.example`:通用服务器版环境变量模板
## 通用服务器部署
1. 复制环境变量文件:
```bash
cp .env.example .env
```
2. 编辑 `.env` 和 `frpc.yaml`,把所有 `CHANGE_ME` / `YOUR_*` 改成真实值。
3. 启动:
```bash
docker compose -f compose.yml up -d
```
## QNAP NAS 部署
1. 编辑 `compose-Nas.yml` 顶部注释中的路径、域名、端口、FRP 服务器等待填写项。
2. 在 QTS Container Station 中导入 `compose-Nas.yml`。
3. 启动后,确认 app 和 frpc 都为 healthy / running。
## 注意
- `AUTH_SECRET` 和 `ENCRYPTION_SECRET` 必须使用长期固定值,迁移后不要随意更换。
- `APP_URL` 必须与反向代理对外访问域名一致,例如 `https://resume.example.com`。
- FRP 的 `remotePort` 需要与公网服务器 Nginx Proxy Manager / 反向代理转发端口一致。

View File

@@ -0,0 +1,97 @@
# Reactive Resume / QNAP QTS 纯净模板。
# 使用前请填写:
# - NAS 数据目录,例如 /share/Container/Reactive_Resume
# - APP_URL例如 https://resume.example.com
# - 本地访问端口,例如 3004
# - PostgreSQL 密码、AUTH_SECRET、ENCRYPTION_SECRET
# - FRP serverAddr/serverPort/token/proxy name/remotePort
name: reactive-resume-nas
services:
reactive_resume_permissions:
image: alpine:3.20
restart: "no"
command: ["sh", "-c", "mkdir -p /app/data && chown -R 1000:1000 /app/data"]
volumes:
# 待填写:改成你的 NAS 数据目录。
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/uploads:/app/data
reactive_resume_db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: reactive_resume
POSTGRES_USER: reactive_resume
POSTGRES_PASSWORD: CHANGE_ME_POSTGRES_PASSWORD
volumes:
# 待填写:改成你的 NAS 数据目录。
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactive_resume -d reactive_resume"]
interval: 10s
timeout: 5s
retries: 10
reactive_resume_app:
image: amruthpillai/reactive-resume:latest
restart: unless-stopped
depends_on:
reactive_resume_permissions:
condition: service_completed_successfully
reactive_resume_db:
condition: service_healthy
ports:
# 待填写NAS 本地访问端口。
- "CHANGE_ME_LOCAL_APP_PORT:3000"
volumes:
# 待填写:改成你的 NAS 数据目录。
- /share/Container/CHANGE_ME_REACTIVE_RESUME/data/uploads:/app/data
environment:
TZ: Asia/Shanghai
APP_URL: https://YOUR_DOMAIN.example.com
DATABASE_URL: postgresql://reactive_resume:CHANGE_ME_POSTGRES_PASSWORD@reactive_resume_db:5432/reactive_resume
AUTH_SECRET: CHANGE_ME_64_HEX_AUTH_SECRET
ENCRYPTION_SECRET: CHANGE_ME_64_HEX_ENCRYPTION_SECRET
SMTP_FROM: Reactive Resume <noreply@YOUR_DOMAIN.example.com>
SMTP_PORT: "587"
SMTP_SECURE: "false"
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_IMAGE_PROCESSING: "false"
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "false"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 8
start_period: 30s
reactive_resume_frpc:
image: snowdreamtech/frpc:latest
restart: unless-stopped
entrypoint: ["/bin/sh"]
command:
- -c
- |
cat > /tmp/frpc.toml <<'EOF'
serverAddr = "YOUR_FRP_SERVER_IP_OR_DOMAIN"
serverPort = CHANGE_ME_FRP_SERVER_PORT
auth.method = "token"
auth.token = "CHANGE_ME_FRP_TOKEN"
transport.poolCount = 5
transport.heartbeatTimeout = -1
[[proxies]]
name = "CHANGE_ME_PROXY_NAME"
type = "tcp"
localIP = "reactive_resume_app"
localPort = 3000
remotePort = CHANGE_ME_REMOTE_PORT
EOF
exec frpc -c /tmp/frpc.toml
depends_on:
reactive_resume_app:
condition: service_healthy

View File

@@ -0,0 +1,72 @@
# Reactive Resume 通用服务器纯净模板。
# 使用前请先复制 .env.example 为 .env并填写域名、端口、数据库密码和密钥。
name: reactive-resume
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- resume_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
reactive-resume:
image: amruthpillai/reactive-resume:latest
restart: unless-stopped
env_file:
- .env
ports:
# 待填写LOCAL_BIND_IP / LOCAL_APP_PORT 在 .env 中配置。
# 例127.0.0.1:3004:3000仅允许本机反代或 frpc 访问。
- "${LOCAL_BIND_IP}:${LOCAL_APP_PORT}:3000"
volumes:
- reactive_resume_data:/app/data
networks:
- resume_net
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1));",
]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
frpc:
image: fatedier/frpc:v0.68.0
restart: unless-stopped
command: ["-c", "/etc/frp/frpc.yaml"]
volumes:
# 待填写frpc.yaml 中配置 FRP 服务器、token、remotePort。
- ./frpc.yaml:/etc/frp/frpc.yaml:ro
networks:
- resume_net
depends_on:
reactive-resume:
condition: service_healthy
networks:
resume_net:
driver: bridge
volumes:
postgres_data:
reactive_resume_data:

View File

@@ -0,0 +1,22 @@
# Reactive Resume frpc 纯净模板。
# 使用前把所有 CHANGE_ME / YOUR_* 改成真实值。
serverAddr: "YOUR_FRP_SERVER_IP_OR_DOMAIN"
serverPort: CHANGE_ME_FRP_SERVER_PORT
auth:
method: "token"
token: "CHANGE_ME_FRP_TOKEN"
transport:
poolCount: 5
heartbeatTimeout: -1
proxies:
- name: "CHANGE_ME_PROXY_NAME"
type: "tcp"
# Docker Compose 内部服务名,通常不用改。
localIP: "reactive-resume"
localPort: 3000
# 待填写:公网 FRP 远程端口,需与反向代理 forward port 一致。
remotePort: CHANGE_ME_REMOTE_PORT

View File

@@ -0,0 +1,50 @@
TZ=Asia/Shanghai
APP_URL=https://isiseg.huijutec.cn
# Local debug access only: http://127.0.0.1:3004
LOCAL_BIND_IP=127.0.0.1
LOCAL_APP_PORT=3004
POSTGRES_DB=reactive_resume
POSTGRES_USER=reactive_resume
POSTGRES_PASSWORD=2ed1869944c609f070699bdf8c92194f
DATABASE_URL=postgresql://reactive_resume:2ed1869944c609f070699bdf8c92194f@postgres:5432/reactive_resume
AUTH_SECRET=9ef1720ee316f9316241bdc84f5dfad99b52f139b48880300942ee61d81b7cda
ENCRYPTION_SECRET=20851888c2a96b11f1f6fc21b4eeb70f1e8258f2d0d414be8bace65eaff289ae
BETTER_AUTH_API_KEY=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
OAUTH_PROVIDER_NAME=
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
OAUTH_DISCOVERY_URL=
OAUTH_AUTHORIZATION_URL=
OAUTH_TOKEN_URL=
OAUTH_USER_INFO_URL=
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=
OAUTH_SCOPES=
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Reactive Resume <noreply@isiseg.huijutec.cn>
SMTP_SECURE=false
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_ENDPOINT=
S3_BUCKET=
S3_FORCE_PATH_STYLE=false
REDIS_URL=
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=
FLAG_DISABLE_SIGNUPS=false
FLAG_DISABLE_EMAIL_AUTH=false
FLAG_DISABLE_IMAGE_PROCESSING=false
FLAG_ALLOW_UNSAFE_AI_BASE_URL=false

View File

@@ -0,0 +1,36 @@
# Reactive Resume 个人简历直接运行安装包
这套包已经按 `https://isiseg.huijutec.cn` 和 FRP `remotePort = 10004` 预置,可以在当前服务器上直接运行。包内已包含当前简历初始化数据、头像和作品集图片,首次启动后可直接访问公开简历。
## 启动
```bash
docker compose -f compose.yml up -d
```
启动后:
- 本机调试地址:`http://127.0.0.1:3004`
- 公网访问地址:`https://isiseg.huijutec.cn`
- 当前公开简历:`https://isiseg.huijutec.cn/audience/resume`
- FRP 映射:本地 `reactive-resume:3000` -> 公网服务器 `10004`
## 反向代理要求
公网服务器上的 Nginx Proxy Manager / 反向代理应配置:
- Domain Names`isiseg.huijutec.cn`
- Scheme`http`
- Forward Hostname / IP`82.157.255.195`
- Forward Port`10004`
- Websockets Support开启
- SSL按现有 huijutec.cn 域名策略配置
## 数据
Compose 会创建独立项目名 `reactive-resume-personal`,默认使用 Docker named volumes
- `reactive-resume-personal_postgres_data`
- `reactive-resume-personal_reactive_resume_data`
`seed/` 目录会在首次启动时导入当前用户、公开简历和上传图片。`patches/` 目录会在应用启动时自动应用当前个人版需要的 PDF 渲染、Glalie 模板排版和“按简历标题下载 PDF”补丁。后续如需迁移数据请备份这些 volumes。

View File

@@ -0,0 +1,100 @@
name: reactive-resume-personal
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- resume_net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
timeout: 5s
retries: 10
reactive-resume:
image: amruthpillai/reactive-resume:latest
restart: unless-stopped
entrypoint: ["/bin/sh", "-c"]
command:
- |
sh /opt/reactive-resume-patches/reactive-resume-runtime-patch.sh
exec node .output/server/index.mjs
env_file:
- .env
ports:
- "${LOCAL_BIND_IP}:${LOCAL_APP_PORT}:3000"
volumes:
- reactive_resume_data:/app/data
- ./patches/reactive-resume-runtime-patch.sh:/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh:ro
networks:
- resume_net
depends_on:
postgres:
condition: service_healthy
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1));",
]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
seed:
image: postgres:16-alpine
restart: "no"
user: "0:0"
entrypoint: ["/bin/sh"]
command:
- -c
- |
set -eu
mkdir -p /app/data/uploads
cp -a /seed/uploads/. /app/data/uploads/
chown -R 1000:1000 /app/data/uploads || true
psql -h postgres -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=1 -f /seed/seed.sql
env_file:
- .env
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
volumes:
- reactive_resume_data:/app/data
- ./seed:/seed:ro
networks:
- resume_net
depends_on:
postgres:
condition: service_healthy
reactive-resume:
condition: service_healthy
frpc:
image: fatedier/frpc:v0.68.0
restart: unless-stopped
command: ["-c", "/etc/frp/frpc.yaml"]
volumes:
- ./frpc.yaml:/etc/frp/frpc.yaml:ro
networks:
- resume_net
depends_on:
seed:
condition: service_completed_successfully
networks:
resume_net:
driver: bridge
volumes:
postgres_data:
reactive_resume_data:

View File

@@ -0,0 +1,17 @@
serverAddr: "82.157.255.195"
serverPort: 7000
auth:
method: "token"
token: "en.xjtu.edu.cn"
transport:
poolCount: 5
heartbeatTimeout: -1
proxies:
- name: "reactive-resume-personal"
type: "tcp"
localIP: "reactive-resume"
localPort: 3000
remotePort: 10004

View File

@@ -0,0 +1,160 @@
#!/bin/sh
set -eu
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
PUBLIC_FILENAME_FILE="/app/apps/web/.output/public/assets/file-D5WsIgJH.js"
PUBLIC_PDF_FILE="/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
CACHE_BUST_FILENAME="rr-filename-title-20260520"
CACHE_BUST_PDF="rr-glalie-layout-20260520"
node - <<'NODE'
const fs = require("fs");
const crypto = require("crypto");
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
const publicFilenameFile = "/app/apps/web/.output/public/assets/file-D5WsIgJH.js";
const publicPdfFile = "/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js";
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
const filenameCacheBust = "rr-filename-title-20260520";
const pdfCacheBust = "rr-glalie-layout-20260520";
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
if (!fs.existsSync(filePath)) return source;
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) return source;
const commaEnd = source.indexOf("\n\t},", start);
const objectEnd = source.indexOf("\n\t}", start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) return source;
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
function replaceOnce(source, from, to) {
return source.includes(to) ? source : source.replace(from, to);
}
function replaceRegexOnce(source, regex, to) {
return source.includes(to) ? source : source.replace(regex, to);
}
function patchFilenameBundle() {
let source = fs.readFileSync(publicFilenameFile, "utf8");
const replacement = 'function t(e,t){let n=(e||"resume").toString().trim()||"resume";return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",t&&n.toLowerCase().endsWith("."+t.toLowerCase())?n:`${n}${t?`.${t}`:""}`}';
if (!source.includes(replacement)) {
const start = source.indexOf("function t(");
const end = source.indexOf("function n(", start);
if (start === -1 || end === -1) throw new Error("filename function marker not found");
source = source.slice(0, start) + replacement + source.slice(end);
fs.writeFileSync(publicFilenameFile, source);
}
}
function patchSsr(source) {
source = source.replace(/\n\t\tname: "",\n\t\tdata: \{/, "\n\t\tname: resume.name,\n\t\tdata: {");
const filenameReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix || "resume").toString().trim() || "resume";\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn extension && filename.toLowerCase().endsWith(\`.\${extension.toLowerCase()}\`) ? filename : \`\${filename}\${extension ? \`.\${extension}\` : ""}\`;\n}`;
if (!source.includes(filenameReplacement)) {
const start = source.indexOf("function generateFilename(");
const end = source.indexOf("\nfunction downloadWithAnchor(", start);
if (start === -1 || end === -1) throw new Error("SSR generateFilename marker not found");
source = source.slice(0, start) + filenameReplacement + source.slice(end);
}
source = source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)")
.replace(/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/, "sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},");
source = replaceRegexOnce(
source,
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
"sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},\n\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },\n\t\t\t\titem: { rowGap: metrics.gapY(.2) },",
);
source = replaceRegexOnce(
source,
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
"sidebarColumn: {\n\t\t\t\t\tzIndex: 1,\n\t\t\t\t\tbackgroundColor: primaryTint,\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,\n\t\t\t\t\trowGap: metrics.gapY(3.0)\n\t\t\t\t},",
);
source = replaceRegexOnce(
source,
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
"mainContent: {\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical\n\t\t\t\t},",
);
return source;
}
function ensureBrowserBufferPolyfill(source) {
if (source.includes("rr-browser-buffer-polyfill")) return source;
const insertAt = source.indexOf(";") + 1;
if (insertAt <= 0 || !source.startsWith("import")) throw new Error("PDF bundle import prelude not found");
return source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
}
function patchPublicPdf(source) {
source = ensureBrowserBufferPolyfill(source);
source = source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/, "$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/[oc]\.gapY\(3\.5\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.6\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.2\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}");
source = replaceOnce(source, "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}", "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}");
source = replaceOnce(source, "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}", "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}");
return source;
}
function patchImporters() {
const assetsDir = "/app/apps/web/.output/public/assets";
const files = fs.readdirSync(assetsDir).filter((name) => name.endsWith(".js")).map((name) => `${assetsDir}/${name}`);
const touched = [];
for (const file of files) {
let source = fs.readFileSync(file, "utf8");
let next = source
.replace(/\.\/file-D5WsIgJH\.js(?:\?v=rr-filename-[A-Za-z0-9-]+)?/g, `./file-D5WsIgJH.js?v=${filenameCacheBust}`)
.replace(/\.\/pdf-document-BplbXx-0\.js(?:\?v=rr-[^"'`]+)?/g, `./pdf-document-BplbXx-0.js?v=${pdfCacheBust}`);
if (next !== source) {
fs.writeFileSync(file, next);
touched.push(file);
}
}
return touched;
}
patchFilenameBundle();
fs.writeFileSync(ssrFile, patchSsr(fs.readFileSync(ssrFile, "utf8")));
fs.writeFileSync(publicPdfFile, patchPublicPdf(fs.readFileSync(publicPdfFile, "utf8")));
const importers = patchImporters();
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/file-D5WsIgJH.js", publicFilenameFile);
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/pdf-document-BplbXx-0.js", publicPdfFile);
for (const file of importers) {
serverIndex = patchStaticManifestEntry(serverIndex, `/assets/${file.split("/").pop()}`, file);
}
fs.writeFileSync(serverIndexFile, serverIndex);
NODE
node --check "$SSR_FILE" >/dev/null
node --check "$PUBLIC_FILENAME_FILE" >/dev/null
node --check "$PUBLIC_PDF_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
# Reactive Resume 个人简历 / QNAP NAS 安装包
这套包面向威联通 QNAP QTS / Container Station已按以下参数预置
- NAS 数据目录:`/share/Container/Reactive_Resume_Personal`
- 本地端口:`3004:3000`
- 公网域名:`https://isiseg.huijutec.cn`
- 当前公开简历:`https://isiseg.huijutec.cn/audience/resume`
- FRP 服务器:`82.157.255.195:7000`
- FRP remotePort`10004`
包内已包含当前简历初始化数据、头像和作品集图片,首次启动后会由 `reactive_resume_seed` 自动导入。
## 部署
1. 将本安装包内容放到 `/share/Container/Reactive_Resume_Personal`
2. 打开 Container Station导入 `/share/Container/Reactive_Resume_Personal/compose-Nas.yml`
3. 启动项目
4. 访问 `https://isiseg.huijutec.cn/audience/resume`
发布归档中的顶层目录已经命名为 `Reactive_Resume_Personal/`,正常解压到 `/share/Container/` 下即可匹配上述路径。
## 反向代理要求
公网服务器上的 Nginx Proxy Manager / 反向代理应配置:
- Domain Names`isiseg.huijutec.cn`
- Scheme`http`
- Forward Hostname / IP`82.157.255.195`
- Forward Port`10004`
- Websockets Support开启
## 数据目录
- PostgreSQL`/share/Container/Reactive_Resume_Personal/data/postgres`
- 上传与本地存储:`/share/Container/Reactive_Resume_Personal/data/uploads`
- 初始化种子:`/share/Container/Reactive_Resume_Personal/seed`
- 运行时补丁:`/share/Container/Reactive_Resume_Personal/patches`

View File

@@ -0,0 +1,151 @@
# Reactive Resume 个人简历 / QNAP QTS 直接部署版。
# 本文件已按 /share/Container/Reactive_Resume_Personal、
# https://isiseg.huijutec.cn、192.168.31.5:3004 本地访问、
# frpc 公网映射 82.157.255.195:10004 预置。
name: reactive-resume-personal-nas
services:
reactive_resume_permissions:
image: alpine:3.20
restart: "no"
command: ["sh", "-c", "mkdir -p /app/data && chown -R 1000:1000 /app/data"]
volumes:
- /share/Container/Reactive_Resume_Personal/data/uploads:/app/data
reactive_resume_db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: reactive_resume
POSTGRES_USER: reactive_resume
POSTGRES_PASSWORD: 5b341c0ca29fefd6d648661150c00fa4
volumes:
- /share/Container/Reactive_Resume_Personal/data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reactive_resume -d reactive_resume"]
interval: 10s
timeout: 5s
retries: 10
reactive_resume_app:
image: amruthpillai/reactive-resume:latest
restart: unless-stopped
entrypoint: ["/bin/sh", "-c"]
command:
- |
sh /opt/reactive-resume-patches/reactive-resume-runtime-patch.sh
exec node .output/server/index.mjs
depends_on:
reactive_resume_permissions:
condition: service_completed_successfully
reactive_resume_db:
condition: service_healthy
ports:
- "3004:3000"
volumes:
- /share/Container/Reactive_Resume_Personal/data/uploads:/app/data
- /share/Container/Reactive_Resume_Personal/patches/reactive-resume-runtime-patch.sh:/opt/reactive-resume-patches/reactive-resume-runtime-patch.sh:ro
environment:
TZ: Asia/Shanghai
APP_URL: https://isiseg.huijutec.cn
DATABASE_URL: postgresql://reactive_resume:5b341c0ca29fefd6d648661150c00fa4@reactive_resume_db:5432/reactive_resume
AUTH_SECRET: c76b0eaf79f731e9ee95918dc69d41696aec9d1deffeabc122944898037bfab1
ENCRYPTION_SECRET: df3a460fa2f92f6e8765927a169322980e18f63a88fbcfedb090819b5afb2408
BETTER_AUTH_API_KEY: ""
GOOGLE_CLIENT_ID: ""
GOOGLE_CLIENT_SECRET: ""
GITHUB_CLIENT_ID: ""
GITHUB_CLIENT_SECRET: ""
LINKEDIN_CLIENT_ID: ""
LINKEDIN_CLIENT_SECRET: ""
OAUTH_PROVIDER_NAME: ""
OAUTH_CLIENT_ID: ""
OAUTH_CLIENT_SECRET: ""
OAUTH_DISCOVERY_URL: ""
OAUTH_AUTHORIZATION_URL: ""
OAUTH_TOKEN_URL: ""
OAUTH_USER_INFO_URL: ""
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: ""
OAUTH_SCOPES: ""
SMTP_HOST: ""
SMTP_PORT: "587"
SMTP_USER: ""
SMTP_PASS: ""
SMTP_FROM: "Reactive Resume <noreply@isiseg.huijutec.cn>"
SMTP_SECURE: "false"
S3_ACCESS_KEY_ID: ""
S3_SECRET_ACCESS_KEY: ""
S3_REGION: us-east-1
S3_ENDPOINT: ""
S3_BUCKET: ""
S3_FORCE_PATH_STYLE: "false"
REDIS_URL: ""
CLOUDFLARE_ACCOUNT_ID: ""
CLOUDFLARE_API_TOKEN: ""
FLAG_DISABLE_SIGNUPS: "false"
FLAG_DISABLE_EMAIL_AUTH: "false"
FLAG_DISABLE_IMAGE_PROCESSING: "false"
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "false"
healthcheck:
test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
interval: 30s
timeout: 10s
retries: 8
start_period: 30s
reactive_resume_seed:
image: postgres:16-alpine
restart: "no"
user: "0:0"
entrypoint: ["/bin/sh"]
command:
- -c
- |
set -eu
mkdir -p /app/data/uploads
cp -a /seed/uploads/. /app/data/uploads/
chown -R 1000:1000 /app/data/uploads || true
psql -h reactive_resume_db -U reactive_resume -d reactive_resume -v ON_ERROR_STOP=1 -f /seed/seed.sql
environment:
PGPASSWORD: 5b341c0ca29fefd6d648661150c00fa4
volumes:
- /share/Container/Reactive_Resume_Personal/data/uploads:/app/data
- /share/Container/Reactive_Resume_Personal/seed:/seed:ro
depends_on:
reactive_resume_permissions:
condition: service_completed_successfully
reactive_resume_db:
condition: service_healthy
reactive_resume_app:
condition: service_healthy
reactive_resume_frpc:
image: snowdreamtech/frpc:latest
restart: unless-stopped
entrypoint: ["/bin/sh"]
command:
- -c
- |
cat > /tmp/frpc.toml <<'EOF'
serverAddr = "82.157.255.195"
serverPort = 7000
auth.method = "token"
auth.token = "en.xjtu.edu.cn"
transport.poolCount = 5
transport.heartbeatTimeout = -1
[[proxies]]
name = "Reactive_Resume_Personal_NAS"
type = "tcp"
localIP = "reactive_resume_app"
localPort = 3000
remotePort = 10004
EOF
exec frpc -c /tmp/frpc.toml
depends_on:
reactive_resume_seed:
condition: service_completed_successfully

View File

@@ -0,0 +1,160 @@
#!/bin/sh
set -eu
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
PUBLIC_FILENAME_FILE="/app/apps/web/.output/public/assets/file-D5WsIgJH.js"
PUBLIC_PDF_FILE="/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
CACHE_BUST_FILENAME="rr-filename-title-20260520"
CACHE_BUST_PDF="rr-glalie-layout-20260520"
node - <<'NODE'
const fs = require("fs");
const crypto = require("crypto");
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
const publicFilenameFile = "/app/apps/web/.output/public/assets/file-D5WsIgJH.js";
const publicPdfFile = "/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js";
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
const filenameCacheBust = "rr-filename-title-20260520";
const pdfCacheBust = "rr-glalie-layout-20260520";
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
if (!fs.existsSync(filePath)) return source;
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) return source;
const commaEnd = source.indexOf("\n\t},", start);
const objectEnd = source.indexOf("\n\t}", start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) return source;
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
function replaceOnce(source, from, to) {
return source.includes(to) ? source : source.replace(from, to);
}
function replaceRegexOnce(source, regex, to) {
return source.includes(to) ? source : source.replace(regex, to);
}
function patchFilenameBundle() {
let source = fs.readFileSync(publicFilenameFile, "utf8");
const replacement = 'function t(e,t){let n=(e||"resume").toString().trim()||"resume";return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",t&&n.toLowerCase().endsWith("."+t.toLowerCase())?n:`${n}${t?`.${t}`:""}`}';
if (!source.includes(replacement)) {
const start = source.indexOf("function t(");
const end = source.indexOf("function n(", start);
if (start === -1 || end === -1) throw new Error("filename function marker not found");
source = source.slice(0, start) + replacement + source.slice(end);
fs.writeFileSync(publicFilenameFile, source);
}
}
function patchSsr(source) {
source = source.replace(/\n\t\tname: "",\n\t\tdata: \{/, "\n\t\tname: resume.name,\n\t\tdata: {");
const filenameReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix || "resume").toString().trim() || "resume";\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn extension && filename.toLowerCase().endsWith(\`.\${extension.toLowerCase()}\`) ? filename : \`\${filename}\${extension ? \`.\${extension}\` : ""}\`;\n}`;
if (!source.includes(filenameReplacement)) {
const start = source.indexOf("function generateFilename(");
const end = source.indexOf("\nfunction downloadWithAnchor(", start);
if (start === -1 || end === -1) throw new Error("SSR generateFilename marker not found");
source = source.slice(0, start) + filenameReplacement + source.slice(end);
}
source = source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)")
.replace(/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g, "style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(3.0) }),")
.replace(/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/, "sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},");
source = replaceRegexOnce(
source,
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
"sectionHeading: {\n\t\t\t\t\tborderBottomWidth: 1,\n\t\t\t\t\tborderBottomColor: primary,\n\t\t\t\t\tpaddingBottom: 1.3\n\t\t\t\t},\n\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },\n\t\t\t\titem: { rowGap: metrics.gapY(.2) },",
);
source = replaceRegexOnce(
source,
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
"sidebarColumn: {\n\t\t\t\t\tzIndex: 1,\n\t\t\t\t\tbackgroundColor: primaryTint,\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,\n\t\t\t\t\trowGap: metrics.gapY(3.0)\n\t\t\t\t},",
);
source = replaceRegexOnce(
source,
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
"mainContent: {\n\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,\n\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,\n\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical\n\t\t\t\t},",
);
return source;
}
function ensureBrowserBufferPolyfill(source) {
if (source.includes("rr-browser-buffer-polyfill")) return source;
const insertAt = source.indexOf(";") + 1;
if (insertAt <= 0 || !source.startsWith("import")) throw new Error("PDF bundle import prelude not found");
return source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
}
function patchPublicPdf(source) {
source = ensureBrowserBufferPolyfill(source);
source = source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/, "$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/[oc]\.gapY\(3\.5\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.6\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/[oc]\.gapY\(2\.2\)/g, (m) => `${m[0]}.gapY(3.0)`)
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}")
.replace(/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/, "sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}");
source = replaceOnce(source, "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}", "sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}");
source = replaceOnce(source, "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}", "mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}");
return source;
}
function patchImporters() {
const assetsDir = "/app/apps/web/.output/public/assets";
const files = fs.readdirSync(assetsDir).filter((name) => name.endsWith(".js")).map((name) => `${assetsDir}/${name}`);
const touched = [];
for (const file of files) {
let source = fs.readFileSync(file, "utf8");
let next = source
.replace(/\.\/file-D5WsIgJH\.js(?:\?v=rr-filename-[A-Za-z0-9-]+)?/g, `./file-D5WsIgJH.js?v=${filenameCacheBust}`)
.replace(/\.\/pdf-document-BplbXx-0\.js(?:\?v=rr-[^"'`]+)?/g, `./pdf-document-BplbXx-0.js?v=${pdfCacheBust}`);
if (next !== source) {
fs.writeFileSync(file, next);
touched.push(file);
}
}
return touched;
}
patchFilenameBundle();
fs.writeFileSync(ssrFile, patchSsr(fs.readFileSync(ssrFile, "utf8")));
fs.writeFileSync(publicPdfFile, patchPublicPdf(fs.readFileSync(publicPdfFile, "utf8")));
const importers = patchImporters();
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/file-D5WsIgJH.js", publicFilenameFile);
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/pdf-document-BplbXx-0.js", publicPdfFile);
for (const file of importers) {
serverIndex = patchStaticManifestEntry(serverIndex, `/assets/${file.split("/").pop()}`, file);
}
fs.writeFileSync(serverIndexFile, serverIndex);
NODE
node --check "$SSR_FILE" >/dev/null
node --check "$PUBLIC_FILENAME_FILE" >/dev/null
node --check "$PUBLIC_PDF_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
#!/bin/sh
set -eu
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
docker exec -u root -i "$CONTAINER" sh <<'SH'
set -eu
PUBLIC_FILE="/app/apps/web/.output/public/assets/file-D5WsIgJH.js"
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
CACHE_BUST="rr-filename-title-20260519"
cp "$PUBLIC_FILE" "$PUBLIC_FILE.bak-filename" 2>/dev/null || true
cp "$SSR_FILE" "$SSR_FILE.bak-filename" 2>/dev/null || true
cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-filename" 2>/dev/null || true
node - <<'NODE'
const fs = require('fs');
const crypto = require('crypto');
const publicFile = '/app/apps/web/.output/public/assets/file-D5WsIgJH.js';
const ssrFile = '/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs';
const serverIndexFile = '/app/apps/web/.output/server/index.mjs';
const cacheBust = 'rr-filename-title-20260519';
function makeEtag(buffer) {
const digest = crypto.createHash('sha1').update(buffer).digest('base64').replace(/=+$/g, '');
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) throw new Error(`Static manifest entry not found for ${urlPath}`);
const commaEnd = source.indexOf('\n\t},', start);
const objectEnd = source.indexOf('\n\t}', start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) throw new Error(`Static manifest entry end not found for ${urlPath}`);
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
function patchPublicImporters() {
const assetsDir = '/app/apps/web/.output/public/assets';
const files = fs
.readdirSync(assetsDir)
.filter((name) => name.endsWith('.js'))
.map((name) => `${assetsDir}/${name}`)
.filter((file) => fs.readFileSync(file, 'utf8').includes('file-D5WsIgJH.js'));
for (const file of files) {
let source = fs.readFileSync(file, 'utf8');
source = source.replace(/\.\/file-D5WsIgJH\.js(?:\?v=rr-filename-[A-Za-z0-9-]+)?/g, `./file-D5WsIgJH.js?v=${cacheBust}`);
fs.writeFileSync(file, source);
}
return files;
}
const publicFilenameFunction = 'function t(e,t){let n=(e||"resume").toString().trim()||"resume";return n=n.replace(/[\\\\/:*?"<>|]/g,"-").replace(/\\s+/g," ").replace(/\\.+$/,"").trim()||"resume",t&&n.toLowerCase().endsWith("."+t.toLowerCase())?n:`${n}${t?`.${t}`:""}`}';
let publicJs = fs.readFileSync(publicFile, 'utf8');
const publicReplacement = publicFilenameFunction;
if (!publicJs.includes(publicReplacement)) {
const start = publicJs.indexOf('function t(');
const end = publicJs.indexOf('function n(', start);
if (start === -1 || end === -1) throw new Error('Public generateFilename marker not found');
publicJs = publicJs.slice(0, start) + publicReplacement + publicJs.slice(end);
}
fs.writeFileSync(publicFile, publicJs);
let ssr = fs.readFileSync(ssrFile, 'utf8');
ssr = ssr.replace(
/\n\t\tname: "",\n\t\tdata: \{/,
'\n\t\tname: resume.name,\n\t\tdata: {',
);
const ssrReplacement = `function generateFilename(prefix, extension) {\n\tlet filename = (prefix || "resume").toString().trim() || "resume";\n\tfilename = filename.replace(/[\\\\/:*?"<>|]/g, "-").replace(/\\s+/g, " ").replace(/\\.+$/, "").trim() || "resume";\n\treturn extension && filename.toLowerCase().endsWith(\`.\${extension.toLowerCase()}\`) ? filename : \`\${filename}\${extension ? \`.\${extension}\` : ""}\`;\n}`;
if (!ssr.includes(ssrReplacement)) {
const start = ssr.indexOf('function generateFilename(');
const end = ssr.indexOf('\nfunction downloadWithAnchor(', start);
if (start === -1 || end === -1) throw new Error('SSR generateFilename marker not found');
ssr = ssr.slice(0, start) + ssrReplacement + ssr.slice(end);
}
fs.writeFileSync(ssrFile, ssr);
const importers = patchPublicImporters();
let serverIndex = fs.readFileSync(serverIndexFile, 'utf8');
serverIndex = patchStaticManifestEntry(serverIndex, '/assets/file-D5WsIgJH.js', publicFile);
for (const file of importers) {
const urlPath = `/assets/${file.split('/').pop()}`;
serverIndex = patchStaticManifestEntry(serverIndex, urlPath, file);
}
fs.writeFileSync(serverIndexFile, serverIndex);
NODE
node --check "$PUBLIC_FILE" >/dev/null
node --check "$SSR_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null
SH
# Nitro loads the static asset manifest into memory at process startup. Restart
# after patching so updated content-length/etag values are used immediately.
docker restart "$CONTAINER" >/dev/null
for _ in $(seq 1 60); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER")"
[ "$health" = "healthy" ] && exit 0
sleep 2
done
docker logs --tail 80 "$CONTAINER" >&2
exit 1

View File

@@ -0,0 +1,236 @@
#!/bin/sh
set -eu
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
docker exec -u root -i "$CONTAINER" sh <<'SH'
set -eu
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
PUBLIC_FILE="/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
CACHE_BUST="rr-glalie-layout-20260519"
test -f "$SSR_FILE.bak-glalie-layout" || cp "$SSR_FILE" "$SSR_FILE.bak-glalie-layout" 2>/dev/null || true
test -f "$PUBLIC_FILE.bak-glalie-layout" || cp "$PUBLIC_FILE" "$PUBLIC_FILE.bak-glalie-layout" 2>/dev/null || true
test -f "$SERVER_INDEX_FILE.bak-glalie-layout" || cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-glalie-layout" 2>/dev/null || true
node - <<'NODE'
const fs = require("fs");
const crypto = require("crypto");
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
const publicFile = "/app/apps/web/.output/public/assets/pdf-document-BplbXx-0.js";
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
const cacheBust = "rr-glalie-layout-20260519";
const browserBufferPolyfill = "var Buffer=globalThis.Buffer??{isBuffer:()=>false,allocUnsafe:e=>new Uint8Array(e),alloc:e=>new Uint8Array(e)};/* rr-browser-buffer-polyfill */";
function replaceOnce(source, from, to, label) {
if (source.includes(to)) return source;
if (!source.includes(from)) throw new Error(`Patch marker not found: ${label}`);
return source.replace(from, to);
}
function replaceRegexOnce(source, regex, to, label) {
if (source.includes(to)) return source;
const next = source.replace(regex, to);
if (next === source) throw new Error(`Patch marker not found: ${label}`);
return next;
}
function patchSsr(source) {
source = source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)");
source = source
.replace(
/style: composeStyles\(styles\.sidebarContent, \{ rowGap: metrics\.sectionGap \}\),/g,
"style: composeStyles(styles.sidebarContent, { rowGap: metrics.gapY(2.2) }),",
)
.replace(
/style: composeStyles\(styles\.mainContent, \{ rowGap: metrics\.sectionGap \}\),/g,
"style: composeStyles(styles.mainContent, { rowGap: metrics.gapY(2.2) }),",
);
source = source.replace(
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary(?:,\s*paddingBottom: 1(?:\.3)?)?\s*\},/,
`sectionHeading: {
\t\t\t\t\tborderBottomWidth: 1,
\t\t\t\t\tborderBottomColor: primary,
\t\t\t\t\tpaddingBottom: 1.3
\t\t\t\t},`,
);
source = replaceRegexOnce(
source,
/sectionHeading: \{\s*borderBottomWidth: 1,\s*borderBottomColor: primary,\s*paddingBottom: 1(?:\.3)?\s*\},\s*item: \{ rowGap: metrics\.gapY\(\.125\) \},/,
`sectionHeading: {
\t\t\t\t\tborderBottomWidth: 1,
\t\t\t\t\tborderBottomColor: primary,
\t\t\t\t\tpaddingBottom: 1.3
\t\t\t\t},
\t\t\t\tsectionItems: { paddingTop: metrics.gapY(.55) },
\t\t\t\titem: { rowGap: metrics.gapY(.2) },`,
"SSR Glalie section item spacing",
);
source = replaceRegexOnce(
source,
/sidebarColumn: \{\s*zIndex: 1,\s*backgroundColor: primaryTint,\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical,\s*)?rowGap: (?:metrics\.sectionGap|metrics\.gapY\([^)]+\))\s*\},/,
`sidebarColumn: {
\t\t\t\t\tzIndex: 1,
\t\t\t\t\tbackgroundColor: primaryTint,
\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,
\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,
\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical,
\t\t\t\t\trowGap: metrics.gapY(3.0)
\t\t\t\t},`,
"SSR Glalie sidebar bottom padding",
);
source = replaceRegexOnce(
source,
/mainContent: \{\s*paddingHorizontal: metrics\.page\.paddingHorizontal,\s*paddingTop: metrics\.page\.paddingVertical,\s*(?:paddingBottom: metrics\.page\.paddingVertical\s*)?\},/,
`mainContent: {
\t\t\t\t\tpaddingHorizontal: metrics.page.paddingHorizontal,
\t\t\t\t\tpaddingTop: metrics.page.paddingVertical,
\t\t\t\t\tpaddingBottom: metrics.page.paddingVertical
\t\t\t\t},`,
"SSR Glalie main bottom padding",
);
return source
.replace(/const sideMargin = bodyLineHeight \* \.(?:2|08);/, "const sideMargin = bodyLineHeight * .08;")
.replace(/metrics\.gapY\(3\.5\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.6\)/g, "metrics.gapY(3.0)")
.replace(/metrics\.gapY\(2\.2\)/g, "metrics.gapY(3.0)");
}
function ensureBrowserBufferPolyfill(source) {
if (source.includes("rr-browser-buffer-polyfill")) return source;
const insertAt = source.indexOf(";") + 1;
if (insertAt <= 0 || !source.startsWith("import")) {
throw new Error("public PDF bundle import prelude not found");
}
return source.slice(0, insertAt) + browserBufferPolyfill + source.slice(insertAt);
}
function patchPublic(source) {
source = ensureBrowserBufferPolyfill(source);
source = source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/,
"$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/o\.gapY\(3\.5\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(3\.5\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.6\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.6\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.2\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.2\)/g, "c.gapY(3.0)");
source = source
.replace(/style:\$\(a\.sidebarContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.sidebarContent,{rowGap:o.gapY(3.0)})")
.replace(/style:\$\(a\.mainContent,\{rowGap:o\.sectionGap\}\)/g, "style:$(a.mainContent,{rowGap:o.gapY(3.0)})");
source = source.replace(
/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a(?:,paddingBottom:1(?:\.3)?)?\}/,
"sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3}",
);
source = source.replace(
/sectionHeading:\{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1(?:\.3)?\},item:\{rowGap:([a-zA-Z_$][\w$]*)\.gapY\(\.125\)\}/,
"sectionHeading:{borderBottomWidth:1,borderBottomColor:a,paddingBottom:1.3},sectionItems:{paddingTop:$1.gapY(.55)},item:{rowGap:$1.gapY(.2)}",
);
source = replaceOnce(
source,
"sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,rowGap:c.sectionGap}",
"sidebarColumn:{zIndex:1,backgroundColor:o,paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical,rowGap:c.gapY(3.0)}",
"public Glalie sidebar bottom padding",
);
source = replaceOnce(
source,
"mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical}",
"mainContent:{paddingHorizontal:c.page.paddingHorizontal,paddingTop:c.page.paddingVertical,paddingBottom:c.page.paddingVertical}",
"public Glalie main bottom padding",
);
return source
.replace(/([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\*\.(?:2|08);return\{paragraph:\{marginTop:\1,marginBottom:\1\},listItem:\{marginTop:\1,marginBottom:\1\}\}/,
"$1=$2*.08;return{paragraph:{marginTop:$1,marginBottom:$1},listItem:{marginTop:$1,marginBottom:$1}}")
.replace(/o\.gapY\(3\.5\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(3\.5\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.6\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.6\)/g, "c.gapY(3.0)")
.replace(/o\.gapY\(2\.2\)/g, "o.gapY(3.0)")
.replace(/c\.gapY\(2\.2\)/g, "c.gapY(3.0)");
}
function patchImporters() {
const assetsDir = "/app/apps/web/.output/public/assets";
const files = fs
.readdirSync(assetsDir)
.filter((name) => name.endsWith(".js"))
.map((name) => `${assetsDir}/${name}`)
.filter((file) => fs.readFileSync(file, "utf8").includes("pdf-document-BplbXx-0.js"));
for (const file of files) {
let source = fs.readFileSync(file, "utf8");
source = source.replace(/\.\/pdf-document-BplbXx-0\.js(?:\?v=rr-[^"'`]+)?/g, `./pdf-document-BplbXx-0.js?v=${cacheBust}`);
fs.writeFileSync(file, source);
}
return files;
}
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) throw new Error(`Static manifest entry not found for ${urlPath}`);
const commaEnd = source.indexOf("\n\t},", start);
const objectEnd = source.indexOf("\n\t}", start);
const end = commaEnd === -1 ? objectEnd : Math.min(commaEnd, objectEnd);
if (end === -1) throw new Error(`Static manifest entry end not found for ${urlPath}`);
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
fs.writeFileSync(ssrFile, patchSsr(fs.readFileSync(ssrFile, "utf8")));
fs.writeFileSync(publicFile, patchPublic(fs.readFileSync(publicFile, "utf8")));
const importers = patchImporters();
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
serverIndex = patchStaticManifestEntry(serverIndex, "/assets/pdf-document-BplbXx-0.js", publicFile);
for (const file of importers) {
const urlPath = `/assets/${file.split("/").pop()}`;
serverIndex = patchStaticManifestEntry(serverIndex, urlPath, file);
}
fs.writeFileSync(serverIndexFile, serverIndex);
NODE
node --check "$SSR_FILE" >/dev/null
node --check "$PUBLIC_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null
SH
# Nitro loads the static asset manifest into memory at process startup. Restart
# after patching so updated content-length/etag values are used immediately.
docker restart "$CONTAINER" >/dev/null
for _ in $(seq 1 60); do
health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$CONTAINER")"
[ "$health" = "healthy" ] && exit 0
sleep 2
done
docker logs --tail 80 "$CONTAINER" >&2
exit 1

View File

@@ -0,0 +1,161 @@
#!/bin/sh
set -eu
CONTAINER="${1:-reactive-resume-reactive-resume-1}"
docker exec -u root -i "$CONTAINER" sh <<'SH'
set -eu
SSR_FILE="/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs"
SW_FILE="/app/apps/web/.output/public/sw.js"
SERVER_INDEX_FILE="/app/apps/web/.output/server/index.mjs"
SSR_RENDERER_FILE="/app/apps/web/.output/server/_chunks/ssr-renderer.mjs"
test -f "$SSR_FILE.bak-sw-cache" || cp "$SSR_FILE" "$SSR_FILE.bak-sw-cache" 2>/dev/null || true
test -f "$SW_FILE.bak-sw-cache" || cp "$SW_FILE" "$SW_FILE.bak-sw-cache" 2>/dev/null || true
test -f "$SERVER_INDEX_FILE.bak-sw-cache" || cp "$SERVER_INDEX_FILE" "$SERVER_INDEX_FILE.bak-sw-cache" 2>/dev/null || true
test -f "$SSR_RENDERER_FILE.bak-sw-cache" || cp "$SSR_RENDERER_FILE" "$SSR_RENDERER_FILE.bak-sw-cache" 2>/dev/null || true
node - <<'NODE'
const fs = require("fs");
const crypto = require("crypto");
const ssrFile = "/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs";
const swFile = "/app/apps/web/.output/public/sw.js";
const serverIndexFile = "/app/apps/web/.output/server/index.mjs";
const ssrRendererFile = "/app/apps/web/.output/server/_chunks/ssr-renderer.mjs";
const registrationScript = `
\t(() => {
\t\tif (!("serviceWorker" in navigator)) return;
\t\twindow.addEventListener("load", () => {
\t\t\tconst clearReactiveResumeCaches = async () => {
\t\t\t\tif ("caches" in window) {
\t\t\t\t\tconst keys = await caches.keys();
\t\t\t\t\tawait Promise.all(keys.map((key) => caches.delete(key)));
\t\t\t\t}
\t\t\t\tif (navigator.serviceWorker.getRegistrations) {
\t\t\t\t\tconst registrations = await navigator.serviceWorker.getRegistrations();
\t\t\t\t\tawait Promise.all(registrations.map((registration) => registration.unregister()));
\t\t\t\t}
\t\t\t};
\t\t\tclearReactiveResumeCaches().catch(console.error);
\t\t});
\t})();
`;
let ssr = fs.readFileSync(ssrFile, "utf8");
const start = "var pwaServiceWorkerRegistrationScript = `";
const end = "`;\nvar src_default =";
const startIndex = ssr.indexOf(start);
if (startIndex === -1) {
throw new Error("Service worker registration script start marker not found");
}
const endIndex = ssr.indexOf(end, startIndex + start.length);
if (endIndex === -1) {
throw new Error("Service worker registration script end marker not found");
}
ssr =
ssr.slice(0, startIndex) +
start +
registrationScript +
ssr.slice(endIndex);
fs.writeFileSync(ssrFile, ssr);
const sw = `self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map((key) => caches.delete(key)));
await self.registration.unregister();
await self.clients.claim();
const clients = await self.clients.matchAll({
type: "window",
includeUncontrolled: true,
});
for (const client of clients) {
client.postMessage({ type: "RR_SW_CACHE_CLEARED" });
}
})());
});
self.addEventListener("fetch", () => {});
`;
fs.writeFileSync(swFile, sw);
function makeEtag(buffer) {
const digest = crypto.createHash("sha1").update(buffer).digest("base64").replace(/=+$/g, "");
return `"${buffer.length.toString(16)}-${digest}"`;
}
function patchStaticManifestEntry(source, urlPath, filePath) {
const buffer = fs.readFileSync(filePath);
const startMarker = `"${urlPath}": {`;
const start = source.indexOf(startMarker);
if (start === -1) {
throw new Error(`Static manifest entry not found for ${urlPath}`);
}
const end = source.indexOf("\n\t},", start);
if (end === -1) {
throw new Error(`Static manifest entry end not found for ${urlPath}`);
}
let entry = source.slice(start, end);
entry = entry
.replace(/"etag": "(?:\\.|[^"\\])*"/, `"etag": ${JSON.stringify(makeEtag(buffer))}`)
.replace(/"mtime": "(?:\\.|[^"\\])*"/, `"mtime": ${JSON.stringify(new Date().toISOString())}`)
.replace(/"size": \d+/, `"size": ${buffer.length}`);
return source.slice(0, start) + entry + source.slice(end);
}
let serverIndex = fs.readFileSync(serverIndexFile, "utf8");
serverIndex = patchStaticManifestEntry(serverIndex, "/sw.js", swFile);
fs.writeFileSync(serverIndexFile, serverIndex);
let ssrRenderer = fs.readFileSync(ssrRendererFile, "utf8");
const ssrRendererOriginal = `function ssrRenderer({ req }) {
\treturn fetchViteEnv("ssr", req);
}`;
const ssrRendererPatched = `async function ssrRenderer(event) {
\tconst response = await fetchViteEnv("ssr", event.req);
\tconst headers = new Headers(response.headers);
\tconst accept = event.req.headers.get("accept") || "";
\tif (accept.includes("text/html")) {
\t\theaders.set("Cache-Control", "no-store, max-age=0");
\t\theaders.set("Pragma", "no-cache");
\t\theaders.set("Expires", "0");
\t}
\treturn new Response(response.body, {
\t\tstatus: response.status,
\t\tstatusText: response.statusText,
\t\theaders,
\t});
}`;
if (!ssrRenderer.includes(ssrRendererPatched)) {
if (!ssrRenderer.includes(ssrRendererOriginal)) {
throw new Error("SSR renderer marker not found");
}
ssrRenderer = ssrRenderer.replace(ssrRendererOriginal, ssrRendererPatched);
fs.writeFileSync(ssrRendererFile, ssrRenderer);
}
NODE
node --check "$SSR_FILE" >/dev/null
node --check "$SW_FILE" >/dev/null
node --check "$SERVER_INDEX_FILE" >/dev/null
node --check "$SSR_RENDERER_FILE" >/dev/null
SH

View File

@@ -0,0 +1,45 @@
# 实现方案-2026-05-19-23-10-56
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-23-10-56.md`
## 修改目标
将 Reactive Resume PDF 下载文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`
## 涉及路径
- `scripts/patch-reactive-resume-filename.sh`
- `工程分析/工程整体分析.md`
- `工程分析/需求分析-2026-05-19-23-10-56.md`
- `工程分析/实现方案-2026-05-19-23-10-56.md`
- `工程分析/测试方案-2026-05-19-23-10-56.md`
- `工程分析/经验记录.md`
## 技术路线
1. 修改补丁脚本中的 shell 变量 `PDF_FILENAME`
2. 修改脚本中 Node 补丁逻辑的 `pdfFilename` 常量。
3. 更新 `CACHE_BUST`,并让静态资源 import 替换逻辑兼容旧 cache bust。
4.`sh -n` 检查脚本语法。
5. 执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`
6. 验证容器健康、文件内新文件名、HTTP 静态资源新文件名。
7. 提交本次脚本和文档变更。
## 兼容性与回滚方案
- 回滚时可把 `PDF_FILENAME``pdfFilename``CACHE_BUST` 改回旧值,再重新执行脚本。
- 若补丁失败,容器内已有 `.bak-filename` 备份可用于人工恢复。
- 本次不改数据库、简历 JSON 和 Docker Compose 配置。
## 预计文件变更
- 更新:`scripts/patch-reactive-resume-filename.sh`
- 新增/更新:`工程分析/` 本次文档与经验记录。
## 提交与部署策略
- 显式暂存本次相关文件,避免混入无关变更。
- commit message 使用:`2026-05-19-23-10-56 修改简历PDF下载文件名`
- 部署通过运行补丁脚本完成,脚本内部会重启 Reactive Resume 容器。

View File

@@ -0,0 +1,44 @@
# 实现方案-2026-05-19-23-23-50
## 实现方案文档路径
`工程分析/实现方案-2026-05-19-23-23-50.md`
## 修改目标
回滚上一条错误指令,将 PDF 下载文件名恢复为 `ZhiboWang-Resume.pdf`
## 涉及路径
- `scripts/patch-reactive-resume-filename.sh`
- `工程分析/需求分析-2026-05-19-23-23-50.md`
- `工程分析/实现方案-2026-05-19-23-23-50.md`
- `工程分析/测试方案-2026-05-19-23-23-50.md`
- `工程分析/经验记录.md`
## 技术路线
1. 将脚本中的 `PDF_FILENAME` 恢复为 `ZhiboWang-Resume.pdf`
2. 将 Node 补丁逻辑中的 `pdfFilename` 恢复为 `ZhiboWang-Resume.pdf`
3.`CACHE_BUST``cacheBust` 恢复为 `rr-filename-20260519`
4. 执行 `sh -n` 做脚本语法检查。
5. 重新执行补丁脚本,更新运行中容器并等待 healthy。
6. 验证 HTTP 静态资源中包含旧文件名且不再包含中文文件名。
7. 创建回滚 commit并尝试推送 Gitea。
## 兼容性与回滚方案
- 本次不改数据库、简历 JSON、Compose 或上传文件。
- 如用户后续给出新的正确文件名,可再次改脚本变量并执行补丁脚本。
- 当前选择新增回滚提交,保留历史可追溯性。
## 预计文件变更
- 更新:`scripts/patch-reactive-resume-filename.sh`
- 新增:本次 `需求分析``实现方案``测试方案`
- 更新:`工程分析/经验记录.md`
## 提交与部署策略
- commit message 使用:`2026-05-19-23-23-50 回滚简历PDF下载文件名`
- 部署通过执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1` 完成。

View File

@@ -0,0 +1,32 @@
# 工程整体分析
更新时间2026-05-19-23-10-56
## 项目定位
本项目用于部署和维护个人 Reactive Resume 服务,服务公网访问地址为 `https://me.huijutec.cn`,公开简历入口为 `/audience/resume`
## 当前结构
- `README.md`:部署、访问、安装包说明。
- `compose.yml`:本机 Docker Compose 部署,包含 Postgres、Reactive Resume、frpc。
- `compose-Nas.yml`QNAP/NAS 部署版本。
- `scripts/patch-reactive-resume-filename.sh`:对运行中 Reactive Resume 容器打补丁,控制 PDF 下载文件名,并修正静态资源 cache bust 与 Nitro manifest。
- `scripts/patch-reactive-resume-glalie-layout.sh`:简历布局补丁。
- `scripts/patch-reactive-resume-service-worker-cache.sh`Service Worker 缓存补丁。
- `packages/`:安装包目录。
- `dist/`:发布归档。
- `生成简历/`:简历源数据、备份、生成 PDF 和展示素材。
## 当前运行环境
- Docker 容器 `reactive-resume-reactive-resume-1` 运行 Reactive Resume映射 `127.0.0.1:3003 -> 3000`
- 容器 `reactive-resume-frpc-1` 负责公网映射。
- `origin` 远端为 Gitea HTTP 地址:`http://192.168.31.5:5002/admin/Reactive_Resume.git`
## 维护注意事项
- 下载文件名由容器内打补丁脚本控制,不是普通业务源码直接控制。
- 修改 PDF 文件名后需要更新 cache bust防止浏览器继续使用旧静态资源。
- 补丁脚本会修改容器内 `/app/apps/web/.output` 文件并重启容器,执行后必须等待健康检查通过。
- Gitea HTTP 远端可能缺少交互式凭据,推送失败时应保留本地 commit不要在命令行写入账号密码。

View File

@@ -0,0 +1,44 @@
# 测试方案-2026-05-19-23-10-56
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-23-10-56.md`
## 静态检查
- 执行 `sh -n scripts/patch-reactive-resume-filename.sh`
## 部署验证
- 执行 `scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`
- 确认容器 `reactive-resume-reactive-resume-1` 恢复 healthy。
## 业务验证
- 在容器内搜索 `王志博-医工智能外科-简历.pdf`
- 通过 `curl http://127.0.0.1:3003/assets/file-D5WsIgJH.js` 验证静态资源已包含新文件名。
- 通过 `curl http://127.0.0.1:3003/api/health` 验证服务健康。
## Git/Gitea 备份验证
- 本地创建包含时间戳和简要描述的 commit。
- 尝试 `git push origin main`
- 若 Gitea HTTP 凭据不可用,记录失败原因并保留本地 commit。
## 风险与回归关注点
- 浏览器缓存可能保留旧静态资源,因此本次必须更新 cache bust。
- Reactive Resume 镜像升级后构建文件哈希可能变化,脚本内固定文件路径需要重新定位。
## 实际执行结果
- `sh -n scripts/patch-reactive-resume-filename.sh`:通过。
- `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`:通过。
- 容器健康状态:`reactive-resume-reactive-resume-1``healthy`
- 容器内验证:`/app/apps/web/.output/public/assets/file-D5WsIgJH.js``/app/apps/web/.output/server/_ssr/pdf-document-COfeOLVC.mjs` 均包含 `王志博-医工智能外科-简历.pdf`
- HTTP 静态资源验证:`curl http://127.0.0.1:3003/assets/file-D5WsIgJH.js` 可检索到 `王志博-医工智能外科-简历.pdf`
- Cache bust 验证:业务页面 import 已指向 `file-D5WsIgJH.js?v=rr-filename-20260519-cn`
- 服务健康验证:`curl http://127.0.0.1:3003/api/health` 返回 `status: healthy`
- 公开简历页验证:`curl -I http://127.0.0.1:3003/audience/resume` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建提交信息为 `2026-05-19-23-10-56 修改简历PDF下载文件名`
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行写入账号密码。

View File

@@ -0,0 +1,48 @@
# 测试方案-2026-05-19-23-23-50
## 测试方案文档路径
`工程分析/测试方案-2026-05-19-23-23-50.md`
## 静态检查
- 执行 `sh -n scripts/patch-reactive-resume-filename.sh`
- 搜索脚本确认包含 `ZhiboWang-Resume.pdf`
## 部署验证
- 执行 `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`
- 确认容器 `reactive-resume-reactive-resume-1``healthy`
## 业务验证
- 验证容器内 public JS 与 SSR 文件包含 `ZhiboWang-Resume.pdf`
- 验证 HTTP 静态资源 `/assets/file-D5WsIgJH.js` 包含 `ZhiboWang-Resume.pdf`
- 验证 HTTP 静态资源不再包含 `王志博-医工智能外科简历.pdf``王志博-医工智能外科-简历.pdf`
- 验证 `http://127.0.0.1:3003/api/health` 返回 healthy。
- 验证 `http://127.0.0.1:3003/audience/resume` 返回 200。
## Git/Gitea 备份验证
- 创建本地回滚 commit。
- 尝试 `git push origin main`
- 若 HTTP 凭据不可用,记录失败原因。
## 风险与回归关注点
- 浏览器缓存可能短暂保留旧中文 cache bust脚本恢复 `rr-filename-20260519` 后应重新更新 importer 与 manifest。
## 实际执行结果
- `sh -n scripts/patch-reactive-resume-filename.sh`:通过。
- 脚本搜索:`PDF_FILENAME``pdfFilename` 均恢复为 `ZhiboWang-Resume.pdf``CACHE_BUST``cacheBust` 均恢复为 `rr-filename-20260519`
- `./scripts/patch-reactive-resume-filename.sh reactive-resume-reactive-resume-1`:通过。
- 容器状态:`reactive-resume-reactive-resume-1``healthy`
- 容器内验证public JS 与 SSR 文件均包含 `ZhiboWang-Resume.pdf`
- 容器内中文残留验证public JS 与 SSR 文件未检出 `王志博-医工智能外科`
- HTTP 静态资源验证:`/assets/file-D5WsIgJH.js` 返回内容包含 `ZhiboWang-Resume.pdf`
- Cache bust 验证:业务页面 import 已恢复为 `file-D5WsIgJH.js?v=rr-filename-20260519`
- 服务健康验证:`curl http://127.0.0.1:3003/api/health` 返回 `status: healthy`
- 公开简历页验证:`curl -I http://127.0.0.1:3003/audience/resume` 返回 `HTTP/1.1 200 OK`
- Git 本地备份 commit已创建提交信息为 `2026-05-19-23-23-50 回滚简历PDF下载文件名`
- Gitea 远端推送:执行 `git push origin main` 时失败,原因是 HTTP 远端 `http://192.168.31.5:5002` 无法读取用户名;未在命令行写入账号密码。

View File

@@ -0,0 +1,57 @@
# 经验记录
本文件用于记录个人简历构建项目修改过程中的关键问题与解决方案。每条经验使用四段式结构。
## 2026-05-19-23-10-56 Reactive Resume PDF 下载文件名修改
A. 具体问题
用户希望浏览器下载 PDF 时的文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`,当前 DICOM 项目中没有该文件名,需要定位真实项目。
B. 产生问题原因
该下载文件名不是 DICOM 项目功能,而是个人简历构建项目中的 Reactive Resume 容器补丁脚本硬编码。Reactive Resume 的前端 public JS 和 SSR 文件都需要同步修改,且浏览器可能缓存旧静态资源。
C. 解决问题方案
`/home/wkmgc/Desktop/个人材料编写/个人简历构建` 中定位到 `scripts/patch-reactive-resume-filename.sh`,将 shell 与 Node 补丁逻辑中的 PDF 文件名统一改为中文文件名;同时把 cache bust 更新为 `rr-filename-20260519-cn`,并让 import 替换逻辑兼容旧 cache bust。执行脚本后容器重启并恢复 healthy。
D. 后续如何避免问题
涉及线上下载文件名时,应先全局搜索目标文件名并确认真实项目;修改容器补丁脚本后必须立即运行脚本、验证容器内 public/SSR 文件、验证 HTTP 静态资源和服务健康,避免只改脚本未生效。
## 2026-05-19-23-10-56 Reactive Resume Gitea 推送凭据缺失
A. 具体问题
本次本地备份 commit 已创建,但执行 `git push origin main` 时失败Git 提示无法读取 `http://192.168.31.5:5002` 的用户名。
B. 产生问题原因
当前 `origin` 使用 HTTP Gitea 地址,执行环境没有交互式凭据输入,也没有已配置的凭据助手。
C. 解决问题方案
保留本地 commit不把账号密码写入命令、文档或 Git remote。将推送失败写入测试方案和经验记录后续由用户配置安全凭据或改为 SSH remote 后再推送。
D. 后续如何避免问题
Gitea 推送前先检查认证方式。优先使用 SSH 或安全凭据助手;如果 HTTP remote 需要 token应通过安全环境变量或凭据管理器提供不要直接拼进 URL。
## 2026-05-19-23-23-50 回滚错误的 PDF 下载文件名指令
A. 具体问题
用户说明上一条修改 PDF 下载文件名的指令写错了,需要撤销中文文件名变更。
B. 产生问题原因
上一条需求将 Reactive Resume 的 PDF 下载文件名从 `ZhiboWang-Resume.pdf` 改为中文文件名;之后最新提交又把中文文件名调整为不带短横线版本。只回滚最新提交会仍然保留中文文件名,不能真正回到原始状态。
C. 解决问题方案
以原始文件名 `ZhiboWang-Resume.pdf` 为目标,直接恢复 `scripts/patch-reactive-resume-filename.sh` 中 shell 与 Node 两处文件名常量,并恢复 cache bust 为 `rr-filename-20260519`。随后重新执行补丁脚本,让运行中容器 public JS 与 SSR 文件同步恢复。
D. 后续如何避免问题
用户要求回滚时,先确认要回到哪一个历史状态,而不是只撤销最近一个 commit。涉及线上容器补丁时Git 文件回滚后必须重新执行补丁脚本并验证 HTTP 静态资源。

View File

@@ -0,0 +1,42 @@
# 需求分析-2026-05-19-23-10-56
## 开始时间
2026-05-19-23-10-56
## 原始需求摘要
用户要求将下载的 PDF 文件名从 `ZhiboWang-Resume.pdf` 改为 `王志博-医工智能外科-简历.pdf`
## 业务目标
- 让用户从 Reactive Resume 网页下载 PDF 时,浏览器保存文件名显示为中文简历名称。
- 保持现有简历内容、部署地址和其他导出格式不变。
## 输入与输出
- 输入:当前运行的 Reactive Resume 服务和既有补丁脚本。
- 输出:更新后的补丁脚本、运行中容器内已生效的新 PDF 下载文件名。
## 影响范围
- `scripts/patch-reactive-resume-filename.sh`
- 运行中 Docker 容器 `reactive-resume-reactive-resume-1` 的静态资源与 SSR 文件。
- 新增本次工程分析文档与经验记录。
## 关键约束
- 当前 DICOM 项目内没有该下载文件名,真实目标位于 `/home/wkmgc/Desktop/个人材料编写/个人简历构建`
- 修改后需要重新执行补丁脚本并等待容器健康。
- 不能把 Gitea 凭据写入脚本、remote URL 或命令历史。
## 风险点
- 若只改脚本不执行,线上下载文件名不会立即变化。
- 若不更新 cache bust浏览器可能继续使用旧 JS 静态资源。
- 若补丁脚本匹配不到新的构建文件名,容器补丁可能失败。
## 默认假设
- 用户说的“下载的 PDF”指当前 Reactive Resume 公开简历的 PDF 下载文件名。
- 只改 PDF 文件名,不改简历标题、数据库内容或其他导出格式。

View File

@@ -0,0 +1,41 @@
# 需求分析-2026-05-19-23-23-50
## 开始时间
2026-05-19-23-23-50
## 原始需求摘要
用户说明上一条“修改 PDF 下载文件名”的指令写错了,要求回滚。
## 业务目标
- 将 Reactive Resume PDF 下载文件名恢复到改名前的 `ZhiboWang-Resume.pdf`
- 同步恢复运行中容器的 public JS 与 SSR 下载文件名逻辑。
- 保留本次回滚记录,避免后续误以为中文文件名仍是目标状态。
## 输入与输出
- 输入:当前最新脚本、当前 Docker 容器、相关 commit `d2edebe``d77954b`
- 输出:脚本恢复旧文件名、运行中服务恢复旧下载名、回滚分析与经验记录。
## 影响范围
- `scripts/patch-reactive-resume-filename.sh`
- 运行中容器 `reactive-resume-reactive-resume-1`
- `工程分析/` 本次回滚文档与经验记录
## 关键约束
- 使用非破坏性新增提交完成回滚,不使用 `git reset --hard`
- 不删除历史审计文档,新增回滚文档说明原因。
- Gitea HTTP 远端可能仍缺少凭据,推送失败需要记录。
## 风险点
- 如果只改 Git 脚本,不重新执行脚本,线上容器仍会保持中文文件名。
- 如果只回滚最新 `d77954b`,文件名会退到 `王志博-医工智能外科-简历.pdf`,仍然不是原始文件名。
## 默认假设
- “回滚一下”指回到用户提出中文文件名前的状态,即 `ZhiboWang-Resume.pdf`

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
"email": "zub572701190@stu.xjtu.edu.cn",
"phone": "+86 139-4611-2059",
"website": {
"url": "",
"label": ""
"url": "https://me.huijutec.cn/audience/resume",
"label": "me.huijutec.cn/audience/resume"
},
"headline": "AI 医工交叉博士|智能外科与微创手术导航|多模态大模型与临床转化",
"location": "陕西西安|西安交通大学",
@@ -50,15 +50,15 @@
},
"metadata": {
"page": {
"gapX": 4.4,
"gapY": 2.45,
"gapX": 3.8,
"gapY": 0.56,
"format": "a4",
"locale": "zh-CN",
"marginX": 8.2,
"marginY": 7.2,
"marginX": 8.4,
"marginY": 8.8,
"hideIcons": false
},
"notes": "Design direction: refined clinical-tech academic profile. Glalie two-column layout, wider A4 margins, CJK-first typography, navy-blue medical technology accent, and two-page hierarchy for personal achievements and translational impact.",
"notes": "Design direction: refined clinical-tech academic profile. Glalie two-column layout with CJK-safe line height, balanced list rhythm, moderate section spacing, a continued second-page sidebar, and split volunteer/social activity evidence.",
"design": {
"level": {
"icon": "star",
@@ -71,38 +71,46 @@
}
},
"layout": {
"sidebarWidth": 29.5,
"pages": [
{
"fullWidth": false,
"main": [
"summary",
"awards",
"education",
"projects",
"experience",
"1014b66d-3de1-4bf4-903c-93e7f07c8f81",
"54972f49-3c83-4912-b429-dc659a02eda9",
"36ba1eaf-0984-4863-9b4b-691374ee27aa",
"certifications",
"752ddba0-3400-4e33-8f54-49fc3a4b57b9",
"559335bd-99c4-44c2-97b6-420b011415f7"
"1014b66d-3de1-4bf4-903c-93e7f07c8f81"
],
"sidebar": [
"f4fa59b9-34cf-41b0-b324-f07f07934fc2",
"skills",
"languages",
"802bfd76-af47-4c5d-8b04-f89a38932dcd",
"volunteer"
]
"volunteer",
"4a9c2385-2f79-4d50-99cf-6d2aaef0b6c6"
],
"fullWidth": false
},
{
"main": [
"experience",
"54972f49-3c83-4912-b429-dc659a02eda9",
"752ddba0-3400-4e33-8f54-49fc3a4b57b9",
"certifications",
"36ba1eaf-0984-4863-9b4b-691374ee27aa",
"559335bd-99c4-44c2-97b6-420b011415f7"
],
"sidebar": [],
"fullWidth": false
}
]
],
"sidebarWidth": 29.2
},
"template": "glalie",
"typography": {
"body": {
"fontSize": 6.95,
"fontSize": 6.9,
"fontFamily": "Noto Sans SC",
"lineHeight": 1.22,
"lineHeight": 1.34,
"fontWeights": [
"400",
"500",
@@ -110,9 +118,9 @@
]
},
"heading": {
"fontSize": 9.25,
"fontSize": 9.15,
"fontFamily": "Noto Serif SC",
"lineHeight": 1.18,
"lineHeight": 1.28,
"fontWeights": [
"600",
"700"
@@ -367,7 +375,7 @@
"id": "17beb594-5513-4788-9db8-77cde270a476",
"name": "多设备兼容的术中影像记录分析系统",
"hidden": false,
"period": "2025",
"period": "2025 - 至今",
"website": {
"url": "",
"label": "",
@@ -379,7 +387,7 @@
"id": "64fe9ea6-7efc-47f8-bed0-2fa883211ef4",
"name": "微创化手术智能导航平台建设",
"hidden": false,
"period": "2024 - 2026",
"period": "2024 - 至今",
"website": {
"url": "",
"label": "",
@@ -420,7 +428,7 @@
"inlineLink": false
},
"location": "西安",
"description": "<ul><li>研究方向医工交叉、AI 腔镜外科导航、术中影像记录分析、多模态大模型与图文报告生成</li><li>现任医工学博士党支部宣传委员、未来技术学院 B2275 班学习委员</li></ul>"
"description": "<ul><li><u><strong>课题组:</strong>西安交通大学外科梦工场吕毅教授课题组;<strong>导师:</strong>吴荣谦教授</u></li><li>研究方向医工交叉、AI 腔镜外科导航、术中影像记录分析、多模态大模型与图文报告生成</li><li>现任医工学博士党支部宣传委员、未来技术学院 B2275 班学习委员</li></ul>"
},
{
"id": "17863b1a-cfbc-4990-b870-ff3485048b40",
@@ -428,7 +436,7 @@
"grade": "",
"degree": "本科",
"hidden": false,
"period": "2018.08 - 2022.07",
"period": "2018.09 - 2022.07",
"school": "西安交通大学人工智能学院",
"website": {
"url": "",
@@ -465,7 +473,7 @@
"id": "469cc8d5-b041-4f02-9ce5-83961cc00727",
"icon": "stethoscope",
"name": "医工交叉",
"hidden": false,
"hidden": true,
"keywords": [
"AI + 临床",
"智能外科"
@@ -476,7 +484,7 @@
"id": "43ab8051-a0e5-422e-b2b1-90c6a30311da",
"icon": "rocket",
"name": "成果转化",
"hidden": false,
"hidden": true,
"keywords": [
"创业资助",
"专利转化",
@@ -488,7 +496,7 @@
"id": "4721af36-ae2a-47f4-9852-4d71e7f5e44a",
"icon": "database",
"name": "数据要素",
"hidden": false,
"hidden": true,
"keywords": [
"术中影像",
"质控",
@@ -498,7 +506,7 @@
}
],
"title": "关键词",
"hidden": false,
"hidden": true,
"columns": 1
},
"languages": {
@@ -520,54 +528,54 @@
{
"id": "50c41f06-d048-48d6-a5b0-c67521104562",
"hidden": false,
"period": "2022 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>宣传委员</p>",
"location": "",
"description": "<p>2022 - 至今<br/>宣传委员</p>",
"organization": "未来技术学院医工学博士党支部"
},
{
"id": "bbc0e308-86db-4894-a136-24b80555d25c",
"hidden": false,
"period": "2024",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>学习部负责人;第十五次研究生代表大会代表</p>",
"location": "",
"description": "<p>2024<br/>学习部负责人;第十五次研究生代表大会代表</p>",
"organization": "未来技术学院学生会 / 研究生会"
},
{
"id": "72a41ed7-2ce6-4c36-a524-aac20b6a7bef",
"hidden": false,
"period": "2026",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>第一次党员代表大会代表</p>",
"location": "",
"description": "<p>2026<br/>第一次党员代表大会代表</p>",
"organization": "未来技术学院党员代表大会"
},
{
"id": "0e5bd75f-e635-44c7-8379-43fdc2dc45b8",
"hidden": false,
"period": "2022 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "西安交通大学",
"description": "<p>学习委员</p>",
"organization": "未来技术学院 B2275 班"
"location": "",
"description": "<p>2022 - 至今<br/>学习委员</p>",
"organization": "未来技术学院 B2275 班班委"
}
],
"title": "校内任职",
@@ -589,7 +597,7 @@
},
"location": "",
"position": "主要工科参与人",
"description": "<p>基于多模态磁导航技术的困难气道插管系统的研究49 万元在研</p>"
"description": "<p>基于多模态磁导航技术的困难气道插管系统的研究49 万元在研</p>"
},
{
"id": "0b95f627-8395-4242-9f60-8fa0f1bb491c",
@@ -603,8 +611,8 @@
"inlineLink": true
},
"location": "",
"position": "在研",
"description": "<p>微创化手术智能导航平台建设</p>"
"position": "主要工科参与人(第三序位)",
"description": "<p>微创化手术智能导航平台建设70万元在研</p>"
},
{
"id": "47a5d869-535f-42e2-8ac7-bd927853b973",
@@ -619,7 +627,7 @@
},
"location": "",
"position": "主持",
"description": "<p>磁定位辅助多模态融合微创手术组织自动配准系统3 万元结题</p>"
"description": "<p>磁定位辅助多模态融合微创手术组织自动配准系统3 万元结题</p>"
},
{
"id": "affc3464-2cad-4ea4-8399-162bd2c2e2eb",
@@ -633,8 +641,8 @@
"inlineLink": false
},
"location": "",
"position": "结题",
"description": "<p>微创手术导航训练虚拟平台建设研究</p>"
"position": "主要工科参与人(第三序位)",
"description": "<p>微创手术导航训练虚拟平台建设研究20万元结题</p>"
},
{
"id": "eb837f99-e47f-4ec9-9c8b-0fb879302889",
@@ -648,8 +656,8 @@
"inlineLink": false
},
"location": "",
"position": "参与第二位,结题",
"description": "<p>微创化手术智能导航平台建设3 万元</p>"
"position": "主要工科参与人(第三序位)",
"description": "<p>微创化手术智能导航平台建设3 万元;结题</p>"
}
],
"title": "科研与获批项目",
@@ -1041,112 +1049,109 @@
{
"id": "802bfd76-af47-4c5d-8b04-f89a38932dcd",
"type": "volunteer",
"title": "学术组织任职",
"hidden": false,
"columns": 1,
"items": [
{
"id": "76390bf9-49d5-4fbc-98ab-6691477e982d",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中医药信息学会中西医外科智能诊疗分会",
"description": "<p>理事</p>"
"description": "<p>2024 - 至今<br/>理事</p>",
"organization": "中医药信息学会中西医外科智能诊疗分会"
},
{
"id": "c2291187-b7c3-48a2-820a-40e06836072b",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中国抗癌协会",
"description": "<p>青年理事</p>"
"description": "<p>2024 - 至今<br/>青年理事</p>",
"organization": "中国抗癌协会"
},
{
"id": "1f873e55-fde9-4b85-ab6f-d151d5b15d53",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中国医药教育协会数字医疗专业委员会",
"description": "<p>委员</p>"
"description": "<p>2024 - 至今<br/>数字医疗专业委员会委员</p>",
"organization": "中国医药教育协会"
},
{
"id": "e1bf3d6a-6018-4c3e-9330-3e7c3a2d89a1",
"hidden": false,
"period": "2024 - 至今",
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"organization": "中国生物医学工程学会",
"description": "<p>学生会员</p>"
"description": "<p>2024 - 至今<br/>学生会员</p>",
"organization": "中国生物医学工程学会"
}
]
],
"title": "学术组织任职",
"hidden": false,
"columns": 1
},
{
"id": "1014b66d-3de1-4bf4-903c-93e7f07c8f81",
"type": "summary",
"title": "论文与会议",
"hidden": false,
"columns": 1,
"items": [
{
"id": "c16accb2-c907-44bf-aa0c-89e0085c6a50",
"hidden": false,
"content": "<p><strong>期刊论文</strong></p><ul><li><strong>Frontiers in Oncology, 2025</strong> — Development of an AI-driven digital assistance system for real-time safety evaluation and quality control in laparoscopic liver surgery共同第一作者</li><li><strong>中华肝脏外科手术学电子杂志, 2023/2024</strong> — 智能化辅助图像实时去雾技术在腹腔镜胆囊切除术中的应用研究(共同一作作者)</li><li><strong>中华肝脏外科手术学电子杂志, 2023</strong> — 增强现实、虚拟现实与混合现实在腔镜肝脏外科中的应用进展(共同一作作者)</li></ul><p><strong>ACS Clinical Congress 2025</strong></p><ul><li><strong>Oral</strong> — Biomarker Risk Scoring Algorithm And Preoperative Stratification In Patients With Lung Cancer</li><li><strong>Oral</strong> — Differences In Perioperative Complications Of Laparoscopic Cholecystectomy Under Voice Risk Alert System (V-RAS)-assisted Monitoring: Results From 48 Hospitals In Western China</li><li><strong>Oral</strong> — Intraoperative Smart Molecular Imaging And Recognition To Enhance Surgeons' Ability To Identify Lymph Node Metastasis In Thyroid Surgery</li><li><strong>Oral</strong> — Comparison Of Robot-assisted And Conventional Laparoscopic Hepatectomy For Perioperative Outcomes: A Clinical Randomized Controlled Trial (RCT)</li><li><strong>ePoster</strong> — Cost-effectiveness Of Endoscopic Magnetic Traction Technology Compared To Laparoscopic Heller Myotomy For The Treatment Of Achalasia</li></ul><p><strong>ACS / CMAIC / FIS 2023</strong></p><ul><li><strong>ePoster</strong> — Application Of Computer Intelligent Surgical Confidential Assistant In Laparoscopic Liver Resection</li><li><strong>ePoster</strong> — Intelligent Surgical Confidential Assistant Helps Precise Magnetic Assisted Vascular Anastomosis</li><li><strong>Poster</strong> — Intelligent Surgery Enters the Blind Spot of Lumpectomy Liver Resection</li><li><strong>Poster</strong> — Intelligent digital fogging technology shows great potential in laparoscopic hepatectomy surgery</li><li><strong>Poster</strong> — Prospects for intelligent surgical machine assistants in precision liver segment resection</li><li><strong>Poster</strong> — Application of Orthogonal Decomposition in Surgical Image Segmentation - for Unsupervised Adaptability in Intraoperative Surgical Image Recognition Navigation</li><li><strong>Poster</strong> — Intraoperative Image Detection and Clearing System Based on Generative Adversarial Network</li></ul>"
"content": "<p><strong>期刊论文</strong></p><ul><li><p><strong>Frontiers in Oncology, 2025</strong> — Development of an AI-driven digital assistance system for real-time safety evaluation and quality control in laparoscopic liver surgery共同第一作者</p></li><li><p><strong>中华肝脏外科手术学电子杂志, 2023</strong> — 智能化辅助图像实时去雾技术在腹腔镜胆囊切除术中的应用研究(共同一作作者)</p></li><li><p><strong>中华肝脏外科手术学电子杂志, 2023</strong> — 增强现实、虚拟现实与混合现实在腔镜肝脏外科中的应用进展(共同一作作者)</p></li></ul><p><strong>ACS Clinical Congress 2025</strong></p><ul><li><p><strong>Oral</strong> — Biomarker Risk Scoring Algorithm And Preoperative Stratification In Patients With Lung Cancer</p></li><li><p><strong>Oral</strong> — Differences In Perioperative Complications Of Laparoscopic Cholecystectomy Under Voice Risk Alert System (V-RAS)-assisted Monitoring: Results From 48 Hospitals In Western China</p></li><li><p><strong>Oral</strong> — Intraoperative Smart Molecular Imaging And Recognition To Enhance Surgeons Ability To Identify Lymph Node Metastasis In Thyroid Surgery</p></li><li><p><strong>Oral</strong> — Comparison Of Robot-assisted And Conventional Laparoscopic Hepatectomy For Perioperative Outcomes: A Clinical Randomized Controlled Trial (RCT)</p></li><li><p><strong>ePoster</strong> — Cost-effectiveness Of Endoscopic Magnetic Traction Technology Compared To Laparoscopic Heller Myotomy For The Treatment Of Achalasia</p></li></ul><p></p><p><strong>ACS / CMAIC / FIS 2023</strong></p><ul><li><strong>ePoster</strong> — Application Of Computer Intelligent Surgical Confidential Assistant In Laparoscopic Liver Resection</li><li><strong>ePoster</strong> — Intelligent Surgical Confidential Assistant Helps Precise Magnetic Assisted Vascular Anastomosis</li><li><strong>Poster</strong> — Intelligent Surgery Enters the Blind Spot of Lumpectomy Liver Resection</li><li><strong>Poster</strong> — Intelligent digital fogging technology shows great potential in laparoscopic hepatectomy surgery</li><li><strong>Poster</strong> — Prospects for intelligent surgical machine assistants in precision liver segment resection</li><li><strong>Poster</strong> — Application of Orthogonal Decomposition in Surgical Image Segmentation - for Unsupervised Adaptability in Intraoperative Surgical Image Recognition Navigation</li><li><strong>Poster</strong> — Intraoperative Image Detection and Clearing System Based on Generative Adversarial Network</li></ul>"
}
]
],
"title": "论文与会议",
"hidden": false,
"columns": 1
},
{
"id": "54972f49-3c83-4912-b429-dc659a02eda9",
"type": "summary",
"title": "创新创业与成果转化",
"hidden": false,
"columns": 1,
"items": [
{
"id": "8ed9e356-fdbf-4c6b-bf15-4033f40b85f5",
"hidden": false,
"content": "<ul><li><strong>专利转化</strong>2026 年相关专利完成 <strong>50 万元</strong>技术许可 / 转化公示</li><li><strong>创业实践</strong>2023 年依托大学生创新创业实践创立公司,获 <strong>30 万元</strong>创业资助</li><li><strong>典型案例</strong>2025 年“多设备兼容的术中影像记录分析系统”入选陕西首批 <strong>30 个</strong>“数据要素×”典型案例</li></ul>"
"content": "<ul><li><p><strong>专利转化</strong>2026 年相关专利完成 <strong>50 万元</strong>技术许可 / 转化公示</p></li><li><p><strong>创业实践</strong>2023 年依托大学生创新创业实践创立公司,获 <strong>30 万元</strong>创业资助</p></li><li><p><strong>典型案例</strong>2025 年“多设备兼容的术中影像记录分析系统”入选陕西<strong>首批“数据要素×”典型案例</strong></p></li></ul><p></p>"
}
]
],
"title": "创新创业与成果转化",
"hidden": false,
"columns": 1
},
{
"id": "559335bd-99c4-44c2-97b6-420b011415f7",
"type": "summary",
"title": "媒体报道",
"hidden": false,
"columns": 1,
"items": [
{
"id": "83f3ac59-c07d-4270-b786-9234eaf1da92",
"hidden": false,
"content": "<ul><li><strong>新华社</strong>《西安交通大学:扎根西部传薪火 服务国家育英才》2026报道人与项目实践</li><li><strong>中国教育报</strong>《产学研“抱团”闯出创新路》2025关注创新港产学研协同实践</li><li><strong>陕西省科学技术厅</strong>转载陕西网《后端深度融合科学研究突破“围墙之困”》2025</li></ul>"
"content": "<ul><li><p><strong>新华社</strong>《西安交通大学:扎根西部传薪火 服务国家育英才》2026130周年校庆专栏其中报道人与项目实践</p></li><li><p><strong>中国教育报</strong>《产学研“抱团”闯出创新路》2025关注创新港产学研协同实践,其中报道本人与项目实践</p></li><li><p><strong>陕西省科学技术厅</strong>转载《后端深度融合科学研究突破“围墙之困”》2025关注复合型创新创业人才培养</p></li></ul><p></p>"
}
]
],
"title": "社会影响",
"hidden": false,
"columns": 1
},
{
"id": "752ddba0-3400-4e33-8f54-49fc3a4b57b9",
"type": "certifications",
"title": "编写著作",
"hidden": false,
"columns": 1,
"items": [
{
"id": "b71f628c-04ea-4d2b-9452-35f4dc0e0054",
@@ -1161,7 +1166,85 @@
},
"description": ""
}
]
],
"title": "编写著作",
"hidden": false,
"columns": 1
},
{
"id": "f4fa59b9-34cf-41b0-b324-f07f07934fc2",
"type": "summary",
"items": [
{
"id": "dff431c1-739e-48f9-a835-6c8b1965c5a3",
"hidden": false,
"content": "<p><strong>西安交通大学外科梦工场</strong><br/>吕毅教授课题组<br/><strong>导师:</strong>吴荣谦教授</p>"
}
],
"title": "所在课题组",
"hidden": false,
"columns": 1
},
{
"id": "4a9c2385-2f79-4d50-99cf-6d2aaef0b6c6",
"type": "volunteer",
"items": [
{
"id": "fc2f36e5-6737-4c77-a6e7-9d9a4f9f7250",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2024<br/>科技副校长聘任 / 中学宣讲与科普服务</p>",
"organization": "西安市高陵区第四中学教育集团"
},
{
"id": "ce1f0d77-245c-4145-9b30-0ff274b31834",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2022.12.30<br/>全血 400ml</p>",
"organization": "无偿献血"
},
{
"id": "a8b80562-00fc-42bb-ad5a-d8a0cd10bbbf",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2023.06.03<br/>成分血 2 治疗量</p>",
"organization": "无偿献血"
},
{
"id": "215ce4ab-7703-4d6e-9396-513c64877ead",
"hidden": false,
"period": "",
"website": {
"url": "",
"label": "",
"inlineLink": false
},
"location": "",
"description": "<p>2024.10.12<br/>全血 200ml</p>",
"organization": "无偿献血"
}
],
"title": "志愿及社会活动",
"hidden": false,
"columns": 1
}
]
}