feat: 生产化改造(服务端安装程序/PWA/Docker+HTTPS)并修复侧边栏点击无反应、SPA 静态托管 dotfiles 两个缺陷

- 修复 web-admin 与 desktop-client 侧边栏点击无反应:el-menu 的 index 是路径,不能当路由名使用
- 修复 Web 产物目录含点号目录(如 .cache)时 res.sendFile 因 dotfiles:ignore 返回 500
- 后端生产模式:静态托管 Web 端 + SPA 回退、helmet、限流(通用/登录)、trust proxy、
  /healthz 与 /readyz、请求耗时日志、生产环境变量强校验、首启动自动迁移与创建管理员、PID 文件
- Web 管理端 PWA:manifest + service worker + 图标,可在浏览器安装为应用
- 新增服务端 + Web 管理端 Windows 安装程序(内置 Node 运行时、启停与服务脚本、开机自启、卸载保留数据)
- 新增 Docker/Compose/Nginx 部署样例与 docs/production.md
- 新增 UI 回归测试:Web 端 Electron 驱动真实点击 7 项、客户端冒烟 11 项(含侧边栏点击与离线回退)
This commit is contained in:
LoveChengke committed 2026-09-12 00:20:21 +08:00
1 parent 6e4c7c1050
commit 5545ef6f55
46 files changed
+3020 -152

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
# ---------------------------------------------------------------------------
# 班级小助手 · Docker Compose 环境变量
# 复制为 deploy/.env 后填写:cp deploy/.env.example deploy/.env
# ---------------------------------------------------------------------------
# 服务端对外端口(宿主机)
SERVER_PORT=4000
# 应用版本(镜像标签)
APP_VERSION=0.1.0
# MySQL 配置
MYSQL_ROOT_PASSWORD=请改成强密码_root
MYSQL_DATABASE=classhelper
MYSQL_USER=classhelper
MYSQL_PASSWORD=请改成强密码_app
# JWT 签名密钥(必填,必须 ≥32 位;生成:
# docker run --rm node:22-slim node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
# 或本地:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=
# 令牌有效期
JWT_EXPIRES_IN=7d
# 允许的跨域来源;使用域名访问时建议写成具体来源,例如 https://class.example.com
CORS_ORIGIN=*
# 首次启动创建的管理员账号(部署完成后请立即登录修改密码)
INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin123
# 第 1 教学周的周一(用于"当前周次")
TERM_START_DATE=2026-08-31
# 日志级别:debug | info | warn | error
LOG_LEVEL=info
+73
View File
@@ -0,0 +1,73 @@
# 班级小助手服务端 · 生产镜像
#
# 两阶段构建:
# build —— 安装依赖并编译 shared / server / web-admin
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
#
# 构建(在仓库根目录执行):
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 .
#
# 运行(SQLite + 数据卷):
# docker run -d --name classhelper -p 4000:4000 \
# -e JWT_SECRET=<48字节随机串> \
# -v classhelper-data:/app/data \
# classhelper-server:0.1.0
#
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
# ---------------------------------------------------------------- 构建阶段
FROM node:22-slim AS build
WORKDIR /app
# 启用 pnpm(用 corepack 保证版本与仓库一致)
RUN corepack enable && corepack prepare pnpm@11.8.0 --activate
# 先只拷贝清单以提高缓存命中率
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
COPY packages/web-admin/package.json packages/web-admin/
COPY packages/desktop-client/package.json packages/desktop-client/
RUN pnpm install --frozen-lockfile --ignore-scripts
# 再拷贝源码并构建(只构建服务端与 Web 管理端,桌面客户端不参与)
COPY packages ./packages
COPY scripts ./scripts
RUN pnpm build:shared \
&& pnpm --filter @classhelper/server build \
&& pnpm --filter @classhelper/web-admin build
# ---------------------------------------------------------------- 运行时阶段
FROM node:22-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4000 \
LOG_LEVEL=info \
TRUST_PROXY=1 \
RATE_LIMIT_ENABLED=true \
STARTUP_DB_CHECK=true
# 生产依赖(清单由 scripts/dist-server.mjs 依据 packages/server/package.json 生成)
COPY deploy/package.runtime.json ./package.json
RUN npm install --omit=dev --no-audit --no-fund
# 后端产物 + 迁移文件 + Web 管理端 + 工作区共享包
COPY --from=build /app/packages/server/dist ./server/dist
COPY --from=build /app/packages/server/prisma/migrations ./server/prisma/migrations
COPY --from=build /app/packages/web-admin/dist ./web
COPY --from=build /app/packages/shared/dist ./node_modules/@classhelper/shared/dist
COPY packages/shared/package.json ./node_modules/@classhelper/shared/package.json
# 数据目录(SQLite 文件 / PID 文件),建议挂载数据卷
RUN mkdir -p /app/data /app/logs
VOLUME ["/app/data"]
EXPOSE 4000
# 容器健康检查:就绪探针(数据库不可用会返回 503)
HEALTHCHECK --interval=30s --timeout=5s --start-period=25s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:4000/readyz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server/dist/index.js"]
+93
View File
@@ -0,0 +1,93 @@
# 班级小助手 · 生产部署(MySQL + 服务端 + 可选 HTTPS 反代)
#
# 使用:
# 1) 复制环境变量模板并填写密钥:cp deploy/.env.example deploy/.env
# 2) 构建并启动:docker compose -f deploy/docker-compose.yml --env-file deploy/.env up -d --build
# 3) 访问:http://<服务器IP>:4000/
#
# 说明:数据库迁移由一次性的 migrate 服务执行(prisma migrate deploy),
# 服务端容器因此无需内置 Prisma CLI。
name: classhelper
services:
# ---------------------------------------------------------------- 数据库
db:
image: mysql:8.4
restart: unless-stopped
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
- --default-time-zone=+08:00
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?请在 deploy/.env 中设置}
MYSQL_DATABASE: ${MYSQL_DATABASE:-classhelper}
MYSQL_USER: ${MYSQL_USER:-classhelper}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?请在 deploy/.env 中设置}
volumes:
- db-data:/var/lib/mysql
healthcheck:
test: ['CMD', 'mysqladmin', 'ping', '-h', '127.0.0.1', '-p${MYSQL_ROOT_PASSWORD}']
interval: 10s
timeout: 5s
retries: 10
start_period: 40s
networks: [classhelper]
# ---------------------------------------------------------------- 数据库迁移(一次性)
migrate:
build:
context: ..
dockerfile: deploy/Dockerfile
target: build
restart: 'no'
working_dir: /app/packages/server
environment:
DATABASE_PROVIDER: mysql
DATABASE_URL: mysql://${MYSQL_USER:-classhelper}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-classhelper}
# MySQL 需要 mariadb 驱动适配器(切换数据库时按 docs/mysql.md 安装)
NODE_ENV: production
command: >
sh -c "pnpm --filter @classhelper/server add @prisma/adapter-mariadb &&
npx prisma migrate deploy"
depends_on:
db: { condition: service_healthy }
networks: [classhelper]
# ---------------------------------------------------------------- 服务端 + Web 管理端
server:
build:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0}
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 4000
DATABASE_PROVIDER: mysql
DATABASE_URL: mysql://${MYSQL_USER:-classhelper}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-classhelper}
JWT_SECRET: ${JWT_SECRET:?请在 deploy/.env 中设置(48 字节随机串)}
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-7d}
CORS_ORIGIN: ${CORS_ORIGIN:-*}
TRUST_PROXY: '1'
RATE_LIMIT_ENABLED: 'true'
STARTUP_DB_CHECK: 'true'
LOG_LEVEL: ${LOG_LEVEL:-info}
TERM_START_DATE: ${TERM_START_DATE:-}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
ports:
- '${SERVER_PORT:-4000}:4000'
depends_on:
db: { condition: service_healthy }
migrate: { condition: service_completed_successfully }
networks: [classhelper]
networks:
classhelper:
driver: bridge
volumes:
db-data:
+106
View File
@@ -0,0 +1,106 @@
# ---------------------------------------------------------------------------
# 班级小助手 · Nginx 反向代理样例(HTTPS + WebSocket)
#
# 作用:把 https://class.example.com 反向代理到本机 4000 端口的服务端,
# 同时处理 WebSocket 升级、真实 IP 透传、静态资源缓存与安全响应头。
#
# 使用:
# 1) 把本文件放到 /etc/nginx/conf.d/classhelper.conf(或 sites-available 并做软链)
# 2) 修改 server_name 与证书路径
# 3) nginx -t && systemctl reload nginx
# 4) 服务端 .env 中设置 TRUST_PROXY=1(否则限流按代理 IP 统计)
# 以及 CORS_ORIGIN=https://class.example.com
# ---------------------------------------------------------------------------
# 上游:本机运行的服务端(Windows 安装包 / Docker / 手动启动均适用)
upstream classhelper_backend {
server 127.0.0.1:4000;
keepalive 32;
}
# HTTP -> HTTPS 跳转
server {
listen 80;
listen [::]:80;
server_name class.example.com;
# Let's Encrypt 校验目录(如使用 certbot --webroot)
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name class.example.com;
# ---------------------------------------------------------------- 证书
ssl_certificate /etc/nginx/certs/class.example.com.crt;
ssl_certificate_key /etc/nginx/certs/class.example.com.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# ---------------------------------------------------------------- 基础
client_max_body_size 8m;
charset utf-8;
access_log /var/log/nginx/classhelper.access.log;
error_log /var/log/nginx/classhelper.error.log warn;
# 安全响应头(应用层已用 helmet 设置,这里作为兜底)
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Referrer-Policy no-referrer always;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
# gzip(静态资源与接口响应)
gzip on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript application/manifest+json image/svg+xml;
# ---------------------------------------------------------------- WebSocket(Socket.IO)
location /socket.io/ {
proxy_pass http://classhelper_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;
}
# ---------------------------------------------------------------- 静态资源(带哈希,长缓存)
location /assets/ {
proxy_pass http://classhelper_backend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# ---------------------------------------------------------------- 接口与页面
location / {
proxy_pass http://classhelper_backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 120s;
proxy_connect_timeout 10s;
}
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "classhelper-server-runtime",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
"dependencies": {
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0",
"@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3",
"compression": "^1.8.2",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"socket.io": "^4.8.3",
"zod": "^4.6.2"
}
}