Compare commits
@@ -0,0 +1,21 @@
|
||||
.git
|
||||
.agents
|
||||
.codex
|
||||
node_modules
|
||||
tests
|
||||
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.docker.example
|
||||
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
*.log
|
||||
npm-debug.log*
|
||||
Dockerfile*
|
||||
compose*.yml
|
||||
compose*.yaml
|
||||
README.md
|
||||
LICENSE
|
||||
@@ -0,0 +1,27 @@
|
||||
# 首次启动前请复制为 .env.docker,并替换下面三个值。
|
||||
# 两项密钥必须至少 32 个字符、彼此独立,部署后不得随意更换。
|
||||
TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||
DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
|
||||
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
INITIAL_ADMIN_PASSWORD=replace-with-a-strong-admin-password
|
||||
INITIAL_ADMIN_DISPLAY_NAME=系统管理员
|
||||
|
||||
# 下列公开站点信息可按需修改。
|
||||
PUBLIC_SITE_NAME=海州市教育考试中心
|
||||
PUBLIC_SITE_CODE=HZ-EDU-032
|
||||
PUBLIC_SITE_PHONE=0518-8602 3158
|
||||
PUBLIC_SITE_ADDRESS=江苏省连云港市海州区文教路 18 号
|
||||
PUBLIC_SITE_EMAIL=
|
||||
PUBLIC_SITE_HERO_EYEBROW=HAIZHOU EXAMINATION SERVICE
|
||||
PUBLIC_SITE_HERO_TITLE=一个报名号,
|
||||
PUBLIC_SITE_HERO_HIGHLIGHT=贯穿每一次考试。
|
||||
PUBLIC_SITE_HERO_DESCRIPTION=使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。
|
||||
PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
|
||||
|
||||
# 可选 Redis;容器外的 Redis 不应填写 127.0.0.1。
|
||||
# 普通接口缓存使用 DB 0,认证状态会自动使用独立 DB 1。
|
||||
# REDIS_URL=redis://redis-host:6379/0
|
||||
# REDIS_SESSION_DB=1
|
||||
# 也可让认证状态使用另一台 Redis(此时 URL 中可指定自己的逻辑 DB)。
|
||||
# REDIS_SESSION_URL=rediss://session-redis-host:6379/1
|
||||
+14
-1
@@ -5,12 +5,18 @@ SQLITE_PATH=./data/exam.sqlite
|
||||
HOST=127.0.0.1
|
||||
PORT=4173
|
||||
|
||||
# 可选 Redis 公开接口缓存;未设置 REDIS_URL 时自动禁用并直接读取数据库。
|
||||
# 可选 Redis。普通接口缓存使用 DB 0;配置 REDIS_URL 后,登录状态默认自动使用独立的 DB 1。
|
||||
# 未设置任何 Redis 地址时,接口缓存和认证状态均使用本机内存模式。
|
||||
# REDIS_URL=redis://127.0.0.1:6379/0
|
||||
# REDIS_CACHE_PREFIX=exam-information
|
||||
# REDIS_CACHE_TTL_SECONDS=60
|
||||
# REDIS_RESULTS_CACHE_TTL_SECONDS=86400
|
||||
# REDIS_CONNECT_TIMEOUT_MS=1500
|
||||
# REDIS_SESSION_DB=1
|
||||
# REDIS_SESSION_PREFIX=exam-information:auth
|
||||
# AUTH_SESSION_TTL_SECONDS=28800
|
||||
# 如需让认证状态使用另一台 Redis,可设置独立地址;URL 中可直接指定逻辑 DB。
|
||||
# REDIS_SESSION_URL=rediss://session-redis.example.com:6379/1
|
||||
|
||||
# 仅在首次创建空数据库时使用。部署前务必修改初始密码。
|
||||
INITIAL_ADMIN_USERNAME=admin
|
||||
@@ -20,6 +26,9 @@ INITIAL_ADMIN_DISPLAY_NAME=系统管理员
|
||||
# TOTP 密钥加密主密钥。生产环境必填且至少 32 个字符;修改后已绑定的 TOTP 将无法解密。
|
||||
# TOTP_ENCRYPTION_KEY=replace-with-a-random-secret-of-at-least-32-characters
|
||||
|
||||
# 成绩单与录取通知书防伪码签名密钥。生产环境必须独立设置并长期稳定保存。
|
||||
# DOCUMENT_VERIFICATION_SECRET=replace-with-an-independent-random-secret-of-at-least-32-characters
|
||||
|
||||
# 公开首页文案与联系方式(修改后重启应用生效)
|
||||
PUBLIC_SITE_NAME=海州市教育考试中心
|
||||
PUBLIC_SITE_CODE=HZ-EDU-032
|
||||
@@ -45,3 +54,7 @@ PUBLIC_SITE_FOOTER_NOTICE=本平台展示数据仅用于系统演示
|
||||
|
||||
# 也可以用单个连接地址替代全部 MYSQL_* 连接参数:
|
||||
# DATABASE_URL=mysql://exam_app:password@127.0.0.1:3306/exam_information
|
||||
# 全量数据库状态快照的最长复用时间(毫秒)。应用内写入会立即失效;MySQL 外部直写最长延迟此时间可见。
|
||||
DATABASE_STATE_CACHE_TTL_MS=30000
|
||||
# Redis 未配置或暂不可用时,本机响应缓存的最大条目数。
|
||||
LOCAL_CACHE_MAX_ENTRIES=200
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
name: Build and publish Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Test, build and publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
releases: write
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: https://github.com/actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: https://github.com/docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: https://github.com/docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Gitea Container Registry
|
||||
uses: https://github.com/docker/login-action@v3
|
||||
with:
|
||||
registry: git.biss.click
|
||||
username: ${{ vars.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Generate image tags and labels
|
||||
id: metadata
|
||||
uses: https://github.com/docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
docker.io/${{ vars.DOCKERHUB_IMAGE }}
|
||||
git.biss.click/biss/exam-information-system
|
||||
flavor: latest=false
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ gitea.ref == 'refs/heads/master' }}
|
||||
type=sha,format=short,prefix=sha-
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push image
|
||||
uses: https://github.com/docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
cache-from: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=docker.io/${{ vars.DOCKERHUB_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Detect release type
|
||||
id: release_type
|
||||
if: gitea.ref_type == 'tag'
|
||||
shell: bash
|
||||
run: |
|
||||
case "$GITHUB_REF_NAME" in
|
||||
*-*) prerelease=true ;;
|
||||
*) prerelease=false ;;
|
||||
esac
|
||||
echo "prerelease=$prerelease" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Gitea release
|
||||
if: gitea.ref_type == 'tag'
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
tag_name: ${{ gitea.ref_name }}
|
||||
name: ${{ gitea.ref_name }}
|
||||
prerelease: ${{ steps.release_type.outputs.prerelease }}
|
||||
body: |
|
||||
Docker 镜像已发布:
|
||||
|
||||
```text
|
||||
docker pull docker.io/${{ vars.DOCKERHUB_IMAGE }}:${{ steps.metadata.outputs.version }}
|
||||
docker pull git.biss.click/biss/exam-information-system:${{ steps.metadata.outputs.version }}
|
||||
```
|
||||
@@ -5,4 +5,5 @@ data/*.sqlite-shm
|
||||
data/*.sqlite-wal
|
||||
node_modules/
|
||||
.env
|
||||
.env.docker
|
||||
*.log
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
COPY --chown=node:node . .
|
||||
|
||||
ENV NODE_ENV=production \
|
||||
HOST=0.0.0.0 \
|
||||
PORT=4173 \
|
||||
DATABASE_CLIENT=sqlite \
|
||||
SQLITE_PATH=/app/data/exam.sqlite
|
||||
|
||||
EXPOSE 4173
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
USER node
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '4173') + '/api/public/home').then(response => { if (!response.ok) process.exit(1); }).catch(() => process.exit(1));"]
|
||||
|
||||
CMD ["node", "server.mjs"]
|
||||
@@ -23,6 +23,8 @@
|
||||
- 查看报名审核、应缴金额、缴费状态及班级负责人确认记录
|
||||
- 准考证生成状态、开放时间与下载
|
||||
- 已发布成绩查询,并可按科目提交成绩复议、查看审批进度与结论
|
||||
- 下载带 HMAC 防伪查询码和二维码的 PDF 成绩单
|
||||
- 查看正式录取结果、录取通知书编号,并下载招生学校自定义样式的 PDF 录取通知书
|
||||
- 通知公告中心
|
||||
|
||||
### 管理后台
|
||||
@@ -71,6 +73,7 @@
|
||||
- 服务端角色权限校验
|
||||
- SQLite / MySQL 8.4 双数据库持久化
|
||||
- 可选 Redis 公开接口缓存,支持写后版本失效、热点请求合并和故障回源
|
||||
- Redis 登录状态存储,和普通接口缓存使用不同逻辑数据库,支持多实例共享会话
|
||||
- 规范关系模型、外键、唯一约束和业务索引
|
||||
- 业务写入与审计日志使用原子事务提交
|
||||
- 关键管理操作审计日志
|
||||
@@ -78,6 +81,7 @@
|
||||
- 审批实例、当前责任人、转交和监督操作全程留痕
|
||||
- 桌面端与移动端响应式布局
|
||||
- Excel 文件使用 `exceljs` 生成和解析,并限制上传文件大小
|
||||
- 成绩单与录取通知书以 PDF 下载,使用服务端 HMAC 防伪码支持公开验真
|
||||
|
||||
## 运行
|
||||
|
||||
@@ -92,9 +96,76 @@ npm start
|
||||
|
||||
账户可在“账户安全”中启用 TOTP 二次验证。生产环境必须设置至少 32 个字符的 `TOTP_ENCRYPTION_KEY`;该值用于加密 TOTP 密钥并保护恢复码哈希,部署后必须稳定保存,不能随意更换。本地开发未设置时会使用仅适合开发的稳定派生值。
|
||||
|
||||
生产环境还必须单独设置至少 32 个字符的 `DOCUMENT_VERIFICATION_SECRET`。系统用它为成绩单和录取通知书生成 HMAC 防伪查询码;更换该值会使此前下载文书的查询码失效,因此应独立生成、稳定保存且不得与 TOTP 密钥共用。
|
||||
|
||||
本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构,但不会导入学校、考生、考试或报名测试数据。首次建库只写入系统基础配置和一个超级管理员;账号、密码和显示名可通过 `INITIAL_ADMIN_USERNAME`、`INITIAL_ADMIN_PASSWORD`、`INITIAL_ADMIN_DISPLAY_NAME` 设置。当前数据库结构版本为 v17;v16 数据库会自动增加 TOTP 字段,低于 v15 的开发库会提示重建。
|
||||
|
||||
需要清空并重建空业务库时运行 `npm run reset-db`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。它会生成 4 所学校、360 名批量考生及 360 条不同状态的报名数据,并明确不生成考场编排计划和准考证。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||
### Docker
|
||||
|
||||
项目根目录包含生产镜像和 Docker Compose 配置。默认使用 SQLite,数据库保存在命名卷 `exam-information-data` 中,因此重建容器不会丢失数据。
|
||||
|
||||
先创建容器环境文件,并将其中的 TOTP 主密钥、文书防伪签名密钥和初始管理员密码替换为相互独立的安全随机值:
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.docker.example .env.docker
|
||||
```
|
||||
|
||||
然后构建并启动:
|
||||
|
||||
```powershell
|
||||
docker compose up --build --detach
|
||||
```
|
||||
|
||||
启动完成后访问 <http://127.0.0.1:4173>。查看状态和日志可运行:
|
||||
|
||||
```powershell
|
||||
docker compose ps
|
||||
docker compose logs --follow app
|
||||
```
|
||||
|
||||
停止服务使用 `docker compose down`;该命令会保留数据库卷。只有明确需要删除全部 SQLite 数据时才使用 `docker compose down --volumes`。
|
||||
|
||||
也可以只构建镜像:
|
||||
|
||||
```powershell
|
||||
docker build --tag hengzhun-exam-system:local .
|
||||
```
|
||||
|
||||
镜像默认监听 `0.0.0.0:4173`,以非 root 用户运行,并通过 `/api/public/home` 执行健康检查。需要连接 MySQL 或 Redis 时,用运行环境变量覆盖 `DATABASE_CLIENT`、`DATABASE_URL`/`MYSQL_*`、`REDIS_URL`;认证状态默认使用同一 Redis 服务的独立 DB 1,也可以通过 `REDIS_SESSION_DB` 或 `REDIS_SESSION_URL` 单独配置。此时 SQLite 数据卷可以移除。
|
||||
|
||||
#### Gitea Actions 自动发布到 Docker Hub 与 Gitea 软件包
|
||||
|
||||
工作流位于 `.gitea/workflows/docker-publish.yml`。它会先安装依赖并运行测试,然后构建 `linux/amd64`、`linux/arm64` 双架构镜像,并同时推送到 Docker Hub 与 `git.biss.click/biss/exam-information-system`。
|
||||
|
||||
使用前需要完成以下配置:
|
||||
|
||||
1. 在 Docker Hub 创建目标仓库,并创建具有该仓库 Read & Write 权限的访问令牌。
|
||||
2. 在 Gitea 仓库的 Actions Variables 中添加 `DOCKERHUB_IMAGE`,值为不带 registry 和 tag 的完整镜像名,例如 `yourname/exam-information-system`。
|
||||
3. 在 Gitea 仓库的 Actions Secrets 中添加 `DOCKERHUB_USERNAME` 和 `DOCKERHUB_TOKEN`。前者填写 Docker Hub 用户名,后者填写访问令牌,不要填写账户密码。
|
||||
4. 使用对 `biss` 组织拥有软件包写权限的 Gitea 账号,在“设置 → 应用 → 生成新令牌”中创建具有 Package Read & Write 权限的个人访问令牌。在仓库 Actions Variables 中添加 `REGISTRY_USERNAME`,值为该令牌所属的用户名;在 Actions Secrets 中添加 `REGISTRY_TOKEN`,值为个人访问令牌。自定义 Secret 不能使用 Gitea 保留的 `GITEA_` 前缀,也不能用工作流内置的 `GITEA_TOKEN` 代替该软件包令牌。
|
||||
5. 确保 Gitea Actions 与仓库的软件包注册表已启用,并且 `ubuntu-latest` Runner 能访问 Docker daemon、GitHub、Docker Hub 和 `git.biss.click`。双架构构建还需要 Runner 允许 QEMU 注册步骤运行。
|
||||
|
||||
推送到 `master` 后会发布 `latest` 和 `sha-<短提交号>`;推送形如 `v1.2.3` 的 Git 标签后会发布 `1.2.3`、`1.2` 和对应的提交标签,并在镜像推送成功后自动创建同名正式 Gitea Release。例如:
|
||||
|
||||
```powershell
|
||||
git tag v1.2.3
|
||||
git push origin v1.2.3
|
||||
```
|
||||
|
||||
带连字符预发布后缀的语义化版本标签会自动创建 Gitea Pre-release,例如:
|
||||
|
||||
```powershell
|
||||
git tag v1.3.0-rc.1
|
||||
git push origin v1.3.0-rc.1
|
||||
```
|
||||
|
||||
该版本的镜像标签为 `1.3.0-rc.1`,不会覆盖稳定版的 `1.3` 或 `latest` 标签。
|
||||
|
||||
工作流也支持从 Gitea Actions 页面手动运行。构建缓存保存为同一 Docker Hub 仓库中的 `buildcache` 标签,以加快后续构建。Release 使用工作流内置的 `GITEA_TOKEN` 创建,无需添加额外 Secret;仓库或组织“Actions → General”中的任务令牌最大权限必须允许 Releases Write。
|
||||
|
||||
首次成功推送后,容器镜像会出现在 `biss` 所有者的软件包列表。Gitea 的软件包归属于用户或组织,不会天然归属于某个仓库;打开该软件包的设置页面,将它关联到 `Exam-Information-System`,即可让它显示在此仓库的“软件包”页。之后可使用 `docker pull git.biss.click/biss/exam-information-system:latest` 拉取。
|
||||
|
||||
需要清空并重建空业务库时运行 `npm run reset-db`;该命令与 `npm run initialize-system` 使用同一套初始化流程,会读取项目根目录的 `.env`,并根据 `DATABASE_CLIENT` 选择 SQLite 或 MySQL。也可通过 `npm run reset-db -- --sqlite` 或 `npm run reset-db -- --mysql` 显式选择数据库;MySQL 中存在无法识别为样例数据的业务记录时仍会拒绝覆盖,只有确认目标可清空后才能追加 `--force`。需要测试数据时再手动运行 `npm run seed-test-data`;导入脚本会生成 5 所学校、1200 名批量考生及对应的不同状态报名数据。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs <CSV路径> src/data/china-regions.mjs`。
|
||||
|
||||
## 数据库配置
|
||||
|
||||
@@ -161,17 +232,23 @@ npm start
|
||||
|
||||
### Redis 缓存(可选)
|
||||
|
||||
配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 在启动或运行期间不可用时,接口会自动回源数据库,不影响登录、报名和管理功能。
|
||||
配置 `REDIS_URL` 后,应用会缓存公开首页、已发布公告详情和每名考生的已发布成绩查询。公开数据默认 TTL 为 60 秒,成绩默认 TTL 为 24 小时;成绩录入/发布、批量导入、考试归档和成绩复议会自动使成绩缓存失效,超级管理员也可以在成绩管理中心手动刷新全部成绩缓存。Redis 未配置或暂时不可用时,应用会自动切换到有界本机缓存(默认最多 200 条),避免热点接口反复回源;写入后的失效语义保持不变。
|
||||
|
||||
同一个 `REDIS_URL` 还会启用 Redis 认证状态存储:登录 Session、TOTP 登录挑战和 TOTP 绑定临时状态全部写入 Redis。普通接口缓存按 `REDIS_URL` 使用 DB 0 时,认证状态默认自动选择独立 DB 1;可通过 `REDIS_SESSION_DB` 修改逻辑 DB,或通过 `REDIS_SESSION_URL` 指向另一台 Redis。应用会拒绝让认证状态与普通缓存使用同一 Redis 端点的同一逻辑 DB。认证 Redis 配置后连接失败会阻止服务启动,避免多实例之间悄悄退回本机状态而出现随机掉线。完全未配置 Redis 时,认证状态仍使用本机内存,服务重启会要求重新登录。
|
||||
|
||||
数据库关系表会按写入版本复用进程内只读快照,避免每个请求重复扫描并转换全部业务表。应用自身写入会立即使快照失效;SQLite 还通过 `PRAGMA data_version` 检测其他连接的提交,MySQL 外部直写默认最多延迟 30 秒可见。可通过 `DATABASE_STATE_CACHE_TTL_MS` 调整 MySQL 快照时间,通过 `LOCAL_CACHE_MAX_ENTRIES` 控制本机接口缓存上限。
|
||||
|
||||
```powershell
|
||||
$env:REDIS_URL = 'redis://127.0.0.1:6379/0'
|
||||
$env:REDIS_CACHE_PREFIX = 'exam-information'
|
||||
$env:REDIS_CACHE_TTL_SECONDS = '60'
|
||||
$env:REDIS_RESULTS_CACHE_TTL_SECONDS = '86400'
|
||||
$env:REDIS_SESSION_DB = '1'
|
||||
$env:AUTH_SESSION_TTL_SECONDS = '28800'
|
||||
npm start
|
||||
```
|
||||
|
||||
生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。
|
||||
生产环境可使用 `redis://` 或启用 TLS 的 `rediss://` 连接地址,并通过 `REDIS_CONNECT_TIMEOUT_MS` 调整启动连接超时。若 Redis Cluster 不支持非 0 逻辑 DB,请用 `REDIS_SESSION_URL` 为认证状态配置独立 Redis 实例。
|
||||
|
||||
### 导入服务器 MySQL 测试数据
|
||||
|
||||
@@ -191,6 +268,27 @@ npm run seed-test-data:mysql -- --force
|
||||
|
||||
强制模式会删除该 MySQL 数据库内现有应用数据并在同一事务中写入样例数据,但不会删除数据库或数据表。导入完成后再重新启动应用,避免导入期间出现并发写入或保留旧登录会话。不要对生产业务库执行此命令。本地需要明确使用 SQLite 时可运行 `npm run seed-test-data:sqlite`。
|
||||
|
||||
## 中考志愿填报与招生录取
|
||||
|
||||
系统可按考试单独启用志愿填报,未启用的考试不会出现志愿入口。完整流程如下:
|
||||
|
||||
1. 超级管理员设置填报时间、普通志愿数、最多提交次数和当前阶段;考生只有在当次成绩全部发布后才能填报,达到提交上限后自动锁定。
|
||||
2. 招生学校账号以结构化表单上传本校普通生、特长生与生源校指标分配计划,超级管理员审核后生效;超级管理员也可代上传并直接审核。
|
||||
3. 生源校学校管理员按考试逐人确认指标分配资格;本校资料已完善的在册考生全部确认后,系统自动公开有无资格及对应特长类型。超级管理员和班级管理员均不能代确认。
|
||||
4. 每名考生有一个专用指标分配志愿栏,只有确认有资格且招生校对本校分配了对应指标时可选;其余均为普通志愿。志愿只能由考生本人保存或修改,班级、校级管理员无权查看,超级管理员只读可见。
|
||||
5. 超级管理员结束填报并执行投档。系统按总成绩降序逐个检索志愿,严格区分指标计划池与普通计划池,并遵循“分数优先、遵循志愿”。
|
||||
6. 投档材料只发送到对应招生学校,包含必要考生资料与当次成绩,不包含考生其余志愿。学校可接收或填写特殊理由申请退档,退档由超级管理员统一审核。
|
||||
7. 超级管理员签发正式录取后,系统按“招生学校代码 + 考试代码 + 校内独立流水号”生成稳定的录取通知书编号,并开启招生学校报到工作台。
|
||||
8. 招生学校可逐人暂存 Y/N/P 报到状态,也可导出带下拉校验的 Excel、修改后导入,或扫描录取通知书二维码核验并登记;完整报到情况提交前不会进入审批。
|
||||
9. 学校提交报到情况后可选择不补录或申请补录。超级管理员审批所有学校决定后,系统按缺额进入下一轮补录或结束录取;计划录取率和实际报到率在双方工作台实时显示。
|
||||
10. 审批通过的报到情况会自动进入公开通知,包含计划数、正式录取数、已报到数、缺额和学校说明;无补录时标题不会出现“补录”。录取结束后继续自动发布脱敏录取名单及按学校、类别统计的录取分数线。
|
||||
|
||||
公开公示固定包含报名号、姓名、考生总成绩和录取学校;证件号、手机号等重要身份信息只提供脱敏值。考生档案中的特长资格按“体育 / 艺术”大类与对应小类登记,志愿页面先按学校代码选择招生校,再仅显示符合本人资格的该校类别。
|
||||
|
||||
学校统一在“学校管理”中维护,并可分别标记为生源校、招生校或同时具备两类职责。每场考试报名都包含独立于科目的 `feature_score`(特征分),默认 0,由超级管理员登记;招生学校可设计本校录取通知书的标题、正文、落款和配色,通知书不再添加“录取专用章”。管理后台以一级业务域分组,并把招生录取拆为录取设置、招生账户、招生计划、报到与补录、投档监督等二级菜单。
|
||||
|
||||
数据结构版本为 v20,`admission_records` 关系表新增指标资格、资格公示和分数线公告记录,并支持 SQLite / MySQL 自动迁移。新角色值为 `admission_school`。
|
||||
|
||||
## 手动测试数据账号
|
||||
|
||||
测试数据脚本会提供以下账号;其中初始超级管理员也可能由正常首次建库创建,并可通过环境变量改名、改密,其余校级、班级和考生账号不会在正常启动时创建:
|
||||
@@ -248,7 +346,8 @@ src/data/base.mjs 空业务库与系统基础配置
|
||||
src/data/seed.mjs 手动测试数据生成器
|
||||
scripts/import-test-data.mjs 独立测试数据导入脚本
|
||||
src/http/responses.mjs JSON、文件与请求体处理
|
||||
src/security/session.mjs Cookie 会话与当前用户
|
||||
src/security/auth-state.mjs Redis / 本机会话与 TOTP 临时状态
|
||||
src/security/session.mjs Cookie 解析与当前用户
|
||||
src/security/authorization.mjs 管理层级、权限和数据范围
|
||||
src/routes/public.routes.mjs 公开 API
|
||||
src/routes/auth.routes.mjs 登录、注册与改密 API
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
image: exam-information-system:local
|
||||
init: true
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env.docker
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 4173
|
||||
DATABASE_CLIENT: sqlite
|
||||
SQLITE_PATH: /app/data/exam.sqlite
|
||||
ports:
|
||||
- "4173:4173"
|
||||
volumes:
|
||||
- exam-information-data:/app/data
|
||||
|
||||
volumes:
|
||||
exam-information-data:
|
||||
+114
-17
@@ -3,6 +3,7 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs';
|
||||
import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs';
|
||||
import { createMysqlAdapter } from './src/database/mysql-adapter.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from './src/database/version.mjs';
|
||||
|
||||
export const relationalTables = [
|
||||
'schema_metadata',
|
||||
@@ -35,6 +36,7 @@ export const relationalTables = [
|
||||
'workflow_steps',
|
||||
'workflow_instances',
|
||||
'workflow_actions',
|
||||
'admission_records',
|
||||
'audit_logs'
|
||||
];
|
||||
|
||||
@@ -44,7 +46,7 @@ function validateState(state, source = '数据库') {
|
||||
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
|
||||
'admissionNumberRules', 'arrangementPlans',
|
||||
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
|
||||
'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
|
||||
'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs'
|
||||
];
|
||||
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
|
||||
throw new Error(`${source}中的应用数据格式无效`);
|
||||
@@ -59,7 +61,7 @@ export function buildSeedOperations(state) {
|
||||
const nullable = value => value == null || value === '' ? null : value;
|
||||
|
||||
add(
|
||||
'UPDATE schema_metadata SET schema_version = 17, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
|
||||
`UPDATE schema_metadata SET schema_version = ${CURRENT_SCHEMA_VERSION}, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1`,
|
||||
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
|
||||
state.meta?.createdAt || new Date().toISOString()
|
||||
);
|
||||
@@ -70,8 +72,9 @@ export function buildSeedOperations(state) {
|
||||
|
||||
for (const school of state.schools) {
|
||||
add(
|
||||
'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
|
||||
school.id, school.name, school.code, nullable(school.address), school.active === false ? 0 : 1
|
||||
'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
school.id, school.name, school.code, nullable(school.address), school.isSourceSchool === false ? 0 : 1,
|
||||
school.isAdmissionSchool === false ? 0 : 1, school.active === false ? 0 : 1
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,8 +103,9 @@ export function buildSeedOperations(state) {
|
||||
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
||||
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
||||
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
|
||||
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
guardian_phone, specialty_category, specialty_type, specialty_types, specialty_certificate, policy_eligibility,
|
||||
profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone,
|
||||
nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
|
||||
nullable(profile.classId), nullable(profile.provinceCode), nullable(profile.provinceName),
|
||||
@@ -109,6 +113,8 @@ export function buildSeedOperations(state) {
|
||||
nullable(profile.address),
|
||||
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate),
|
||||
nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone),
|
||||
nullable(profile.specialtyCategory), nullable(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []),
|
||||
nullable(profile.specialtyCertificate), nullable(profile.policyEligibility),
|
||||
profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote),
|
||||
nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt
|
||||
);
|
||||
@@ -150,12 +156,12 @@ export function buildSeedOperations(state) {
|
||||
add(
|
||||
`INSERT INTO registrations (
|
||||
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
|
||||
registration_number, number_rule_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
registration_number, number_rule_id, feature_score
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
registration.id, registration.userId, registration.examId, registration.status,
|
||||
registration.paymentStatus, nullable(registration.paidAt), nullable(registration.paidBy), registration.createdAt,
|
||||
nullable(registration.reviewedAt), nullable(registration.reviewNote),
|
||||
nullable(registration.registrationNumber), nullable(registration.numberRuleId)
|
||||
nullable(registration.registrationNumber), nullable(registration.numberRuleId), Number(registration.featureScore || 0)
|
||||
);
|
||||
for (const subjectId of registration.subjectIds) {
|
||||
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
|
||||
@@ -335,6 +341,16 @@ export function buildSeedOperations(state) {
|
||||
);
|
||||
}
|
||||
|
||||
for (const record of state.admissionRecords) {
|
||||
add(
|
||||
`INSERT INTO admission_records (
|
||||
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.id, record.kind, record.examId, nullable(record.userId), nullable(record.schoolId), record.status,
|
||||
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
for (const log of state.auditLogs) {
|
||||
add(
|
||||
'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)',
|
||||
@@ -346,6 +362,10 @@ export function buildSeedOperations(state) {
|
||||
}
|
||||
|
||||
function stateFromRows(rows) {
|
||||
const parseJson = (value, fallback) => {
|
||||
if (value != null && typeof value === 'object') return value;
|
||||
try { return JSON.parse(value || JSON.stringify(fallback)); } catch { return fallback; }
|
||||
};
|
||||
const subjectsByExam = new Map();
|
||||
for (const row of rows.subjects) {
|
||||
const fullScore = Number(row.full_score ?? 150);
|
||||
@@ -451,6 +471,8 @@ function stateFromRows(rows) {
|
||||
name: row.name,
|
||||
code: row.code,
|
||||
address: row.address || '',
|
||||
isSourceSchool: row.is_source_school == null ? true : Boolean(row.is_source_school),
|
||||
isAdmissionSchool: row.is_admission_school == null ? true : Boolean(row.is_admission_school),
|
||||
active: Boolean(row.active)
|
||||
})),
|
||||
classes: rows.classes.map(row => ({
|
||||
@@ -507,6 +529,11 @@ function stateFromRows(rows) {
|
||||
postalCode: row.postal_code || '',
|
||||
guardianName: row.guardian_name || '',
|
||||
guardianPhone: row.guardian_phone || '',
|
||||
specialtyCategory: row.specialty_category || '',
|
||||
specialtyType: row.specialty_type || '',
|
||||
specialtyTypes: parseJson(row.specialty_types, []),
|
||||
specialtyCertificate: row.specialty_certificate || '',
|
||||
policyEligibility: row.policy_eligibility || '',
|
||||
profileCompleted: Boolean(row.profile_completed),
|
||||
status: row.status,
|
||||
reviewNote: row.review_note || '',
|
||||
@@ -560,6 +587,7 @@ function stateFromRows(rows) {
|
||||
reviewNote: row.review_note || '',
|
||||
registrationNumber: row.registration_number || '',
|
||||
numberRuleId: row.number_rule_id || null,
|
||||
featureScore: Number(row.feature_score || 0),
|
||||
admitCard: admitCards.get(row.id) || null
|
||||
})),
|
||||
results: rows.results.map(row => ({
|
||||
@@ -738,6 +766,17 @@ function stateFromRows(rows) {
|
||||
toAssigneeId: row.to_assignee_id,
|
||||
createdAt: row.created_at
|
||||
})),
|
||||
admissionRecords: rows.admissionRecords.map(row => ({
|
||||
id: row.id,
|
||||
kind: row.kind,
|
||||
examId: row.exam_id,
|
||||
userId: row.user_id || null,
|
||||
schoolId: row.school_id || null,
|
||||
status: row.status,
|
||||
payload: parseJson(row.payload_json, {}),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
})),
|
||||
auditLogs: rows.auditLogs.map(row => ({
|
||||
id: row.id,
|
||||
actorId: row.actor_id,
|
||||
@@ -779,6 +818,7 @@ function readSqliteRows(connection) {
|
||||
workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(),
|
||||
workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(),
|
||||
workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(),
|
||||
admissionRecords: connection.prepare('SELECT * FROM admission_records ORDER BY created_at, id').all(),
|
||||
auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all()
|
||||
};
|
||||
}
|
||||
@@ -815,6 +855,7 @@ async function readMysqlRows(connection) {
|
||||
workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'),
|
||||
workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'),
|
||||
workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'),
|
||||
admissionRecords: await query('SELECT * FROM admission_records ORDER BY created_at, id'),
|
||||
auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC')
|
||||
};
|
||||
}
|
||||
@@ -925,6 +966,7 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
|
||||
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
|
||||
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
|
||||
specialty_category = ?, specialty_type = ?, specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?,
|
||||
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
|
||||
@@ -933,7 +975,9 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
optional(profile.address), optional(profile.schoolId),
|
||||
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
|
||||
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
|
||||
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
|
||||
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), optional(profile.specialtyCategory),
|
||||
optional(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []),
|
||||
optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
|
||||
optional(profile.reviewerId), profile.updatedAt, profile.id
|
||||
),
|
||||
operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId)
|
||||
@@ -978,12 +1022,12 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
const operations = [operation(
|
||||
`INSERT INTO registrations (
|
||||
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
|
||||
registration_number, number_rule_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
registration_number, number_rule_id, feature_score
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
registration.id, registration.userId, registration.examId, registration.status,
|
||||
registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.createdAt,
|
||||
optional(registration.reviewedAt), optional(registration.reviewNote),
|
||||
optional(registration.registrationNumber), optional(registration.numberRuleId)
|
||||
optional(registration.registrationNumber), optional(registration.numberRuleId), Number(registration.featureScore || 0)
|
||||
)];
|
||||
for (const subjectId of registration.subjectIds) {
|
||||
operations.push(operation(
|
||||
@@ -1186,15 +1230,68 @@ function createRepository({ client, location, read, transaction, close }) {
|
||||
auditOperation(log)
|
||||
]);
|
||||
},
|
||||
async createAdmissionSchoolAccount(user, log) {
|
||||
await transaction([
|
||||
operation(
|
||||
`INSERT INTO users (
|
||||
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
|
||||
) VALUES (?, ?, ?, 'admission_school', NULL, ?, NULL, ?, ?, ?)`,
|
||||
user.id, user.username, user.passwordHash, user.schoolId, user.active === false ? 0 : 1, user.displayName, user.createdAt
|
||||
),
|
||||
auditOperation(log)
|
||||
]);
|
||||
},
|
||||
async updateFeatureScore(registration, log) {
|
||||
await transaction([
|
||||
operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id),
|
||||
auditOperation(log)
|
||||
]);
|
||||
},
|
||||
async updateFeatureScores(entries) {
|
||||
const operations = [];
|
||||
for (const { registration, log } of entries) {
|
||||
operations.push(operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id));
|
||||
operations.push(auditOperation(log));
|
||||
}
|
||||
await transaction(operations);
|
||||
},
|
||||
async saveAdmissionRecord(record, log = null) {
|
||||
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
|
||||
`INSERT INTO admission_records (
|
||||
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
|
||||
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
|
||||
)];
|
||||
if (log) operations.push(auditOperation(log));
|
||||
await transaction(operations);
|
||||
},
|
||||
async saveAdmissionRecords(records, log = null) {
|
||||
const operations = [];
|
||||
for (const record of records) {
|
||||
operations.push(operation('DELETE FROM admission_records WHERE id = ?', record.id));
|
||||
operations.push(operation(
|
||||
`INSERT INTO admission_records (
|
||||
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
|
||||
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
|
||||
));
|
||||
}
|
||||
if (log) operations.push(auditOperation(log));
|
||||
await transaction(operations);
|
||||
},
|
||||
async saveSchool(school, isNew, log) {
|
||||
const change = isNew
|
||||
? operation(
|
||||
'INSERT INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
|
||||
school.id, school.name, school.code, optional(school.address), school.active ? 1 : 0
|
||||
'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
school.id, school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0,
|
||||
school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0
|
||||
)
|
||||
: operation(
|
||||
'UPDATE schools SET name = ?, code = ?, address = ?, active = ? WHERE id = ?',
|
||||
school.name, school.code, optional(school.address), school.active ? 1 : 0, school.id
|
||||
'UPDATE schools SET name = ?, code = ?, address = ?, is_source_school = ?, is_admission_school = ?, active = ? WHERE id = ?',
|
||||
school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0,
|
||||
school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0, school.id
|
||||
);
|
||||
await transaction([change, auditOperation(log)]);
|
||||
},
|
||||
|
||||
@@ -85,7 +85,8 @@ const resourceSpecs = {
|
||||
results: {
|
||||
title: '考试成绩台账', sheet: '成绩',
|
||||
columns: [
|
||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['candidateName', '姓名(只读参考)', 16, ''],
|
||||
['candidateNumber', '报名号*', 26, '2026-HZ01-X-0001'], ['cardNumber', '准考证号(只读参考)', 24, ''],
|
||||
['candidateName', '姓名(只读参考)', 16, ''], ['schoolName', '学校(只读参考)', 24, ''], ['className', '班级(只读参考)', 20, ''],
|
||||
['examCode', '考试代码*', 20, 'EX-2026-AUT'], ['examName', '考试名称(只读参考)', 28, ''],
|
||||
['subjectName', '科目*', 16, '语文'], ['fullScore', '科目满分(只读参考)', 18, ''],
|
||||
['passRule', '单科及格规则(只读参考)', 26, ''], ['passScore', '实际及格分(只读参考)', 20, ''],
|
||||
@@ -108,6 +109,63 @@ const resourceSpecs = {
|
||||
['examRoomCode', '考试考场序号', 16, ''], ['roomName', '考场通用名称', 20, ''], ['roomCode', '物理场地代码', 16, ''],
|
||||
['building', '楼栋', 16, ''], ['floor', '楼层', 12, ''], ['seat', '座位号', 12, '']
|
||||
]
|
||||
},
|
||||
admitted_candidates: {
|
||||
title: '录取考生信息表', sheet: '录取考生',
|
||||
columns: [
|
||||
['candidateNumber', '报名号', 26, ''], ['name', '姓名', 14, ''], ['gender', '性别', 10, ''],
|
||||
['idNumber', '证件号码', 24, ''], ['phone', '手机号', 18, ''], ['email', '邮箱', 24, ''],
|
||||
['birthDate', '出生日期', 14, ''], ['ethnicity', '民族', 12, ''], ['nativePlace', '籍贯', 18, ''],
|
||||
['sourceSchoolCode', '生源学校代码', 16, ''], ['sourceSchool', '生源学校', 26, ''], ['className', '班级', 18, ''],
|
||||
['address', '家庭住址', 36, ''], ['guardianName', '监护人', 14, ''], ['guardianPhone', '监护人电话', 18, ''],
|
||||
['specialty', '特长生资格', 20, ''], ['specialtyCertificate', '特长证明编号', 20, ''], ['policyEligibility', '政策资格说明', 24, ''],
|
||||
['featureScore', '特征分', 12, ''], ['subjectScores', '各科成绩', 42, ''], ['totalScore', '考生总成绩', 14, ''],
|
||||
['admittedSchool', '录取学校', 26, ''], ['categoryName', '录取类别', 20, ''], ['preferenceOrder', '志愿序号', 12, '']
|
||||
],
|
||||
numberColumns: ['featureScore', 'totalScore', 'preferenceOrder'],
|
||||
numberFormats: { featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||
},
|
||||
admission_preferences: {
|
||||
title: '考生志愿填报实时台账', sheet: '志愿填报情况',
|
||||
columns: [
|
||||
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'],
|
||||
['round', '填报轮次', 12, 1], ['fillStatus', '填报状态', 16, '已填报'], ['lockStatus', '锁定状态', 16, '未锁定'],
|
||||
['submissionCount', '已提交次数', 14, 1], ['maxSubmissions', '提交次数上限', 16, 3],
|
||||
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
|
||||
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
|
||||
['specialty', '特长生资格', 22, '普通生'], ['indicatorStatus', '指标分配资格', 18, '有资格'],
|
||||
['preferenceOrder', '志愿顺序', 14, 1], ['preferenceType', '志愿类型', 14, '普通志愿'],
|
||||
['targetSchoolCode', '志愿学校代码', 18, 'AD01'], ['targetSchoolName', '志愿学校', 28, '海州市高级中学'],
|
||||
['categoryName', '招生类别', 20, '普通生'], ['submittedAt', '最近提交时间', 24, '2026-07-22 10:18']
|
||||
],
|
||||
numberColumns: ['round', 'submissionCount', 'maxSubmissions', 'preferenceOrder'],
|
||||
numberFormats: { round: '0', submissionCount: '0', maxSubmissions: '0', preferenceOrder: '0' }
|
||||
},
|
||||
admission_placements: {
|
||||
title: '招生录取情况台账', sheet: '录取情况',
|
||||
columns: [
|
||||
['examCode', '考试代码', 20, 'EX-2026-ZK'], ['examName', '考试名称', 30, '初中学业水平考试'], ['round', '录取轮次', 12, 1],
|
||||
['candidateNumber', '报名号', 26, '2026-HZ01-F-0001'], ['candidateName', '姓名', 14, '张同学'],
|
||||
['sourceSchoolCode', '生源学校代码', 18, 'HZ01'], ['sourceSchoolName', '生源学校', 26, '海州市第一中学'], ['className', '班级', 18, '九年级一班'],
|
||||
['specialty', '特长生资格', 22, '普通生'], ['culturalScore', '文化课总分', 14, 560], ['featureScore', '特征分', 12, 0], ['totalScore', '投档总分', 14, 560],
|
||||
['preferenceOrder', '命中志愿序号', 16, 1], ['admissionSchoolCode', '招生学校代码', 18, 'AD01'], ['admissionSchoolName', '招生学校', 28, '海州市高级中学'],
|
||||
['categoryName', '招生类别', 20, '普通生'], ['quotaBucket', '计划类型', 18, '普通计划'], ['admissionStatus', '录取状态', 18, '正式录取'],
|
||||
['reportingStatus', '报到状态', 16, '已报到'], ['noticeNumber', '录取通知书编号', 34, 'AD01-EX-2026-ZK-000001'],
|
||||
['withdrawalReason', '退档或放弃原因', 34, ''], ['updatedAt', '状态更新时间', 24, '2026-07-22 10:18']
|
||||
],
|
||||
numberColumns: ['round', 'culturalScore', 'featureScore', 'totalScore', 'preferenceOrder'],
|
||||
numberFormats: { round: '0', culturalScore: '0.00', featureScore: '0.00', totalScore: '0.00', preferenceOrder: '0' }
|
||||
},
|
||||
admission_reporting: {
|
||||
title: '录取考生报到状态维护表', sheet: '考生报到',
|
||||
columns: [
|
||||
['noticeNumber', '录取通知书编号*', 34, 'AD01-EX-2026-ZK-000001'],
|
||||
['candidateNumber', '报名号*', 26, '2026-HZ01-F-0001'], ['name', '姓名(只读)', 14, '张同学'],
|
||||
['examCode', '考试代码(只读)', 20, 'EX-2026-ZK'], ['schoolCode', '招生学校代码(只读)', 18, 'AD01'],
|
||||
['categoryName', '录取类别(只读)', 20, '普通生'],
|
||||
['reportingStatusCode', '报到状态码*(Y/N/P)', 22, 'P'], ['reportingNote', '报到备注', 36, '']
|
||||
],
|
||||
validations: { reportingStatusCode: ['Y', 'N', 'P'] }
|
||||
}
|
||||
};
|
||||
|
||||
@@ -184,7 +242,7 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
|
||||
header.alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
const outputRows = rows.length ? rows : template ? [Object.fromEntries(spec.columns.map(([key, , , example]) => [key, example]))] : [];
|
||||
for (const item of outputRows) {
|
||||
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, item[key] ?? ''])));
|
||||
const row = sheet.addRow(Object.fromEntries(spec.columns.map(([key]) => [key, resource === 'admission_reporting' && key === 'reportingNote' && !item[key] ? null : item[key] ?? ''])));
|
||||
row.height = 23;
|
||||
row.font = { name: '微软雅黑', size: 10, color: { argb: 'FF243B4A' } };
|
||||
row.alignment = { vertical: 'middle' };
|
||||
@@ -192,6 +250,14 @@ export async function buildWorkbook(resource, rows = [], { template = false, sub
|
||||
cell.border = { bottom: { style: 'hair', color: { argb: 'FFD8E2E7' } } };
|
||||
});
|
||||
}
|
||||
if (resource === 'admission_reporting') {
|
||||
const statusColumn = spec.columns.findIndex(([key]) => key === 'reportingStatusCode') + 1;
|
||||
const noteColumn = spec.columns.findIndex(([key]) => key === 'reportingNote') + 1;
|
||||
for (let row = 3; row <= Math.max(202, sheet.rowCount); row += 1) {
|
||||
for (const column of [statusColumn, noteColumn]) sheet.getCell(row, column).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFFFF3CD' } };
|
||||
}
|
||||
sheet.getCell('A1').value = `${spec.title}|仅修改黄色列;Y=已报到,N=未报到,P=待确认`;
|
||||
}
|
||||
sheet.autoFilter = { from: { row: 2, column: 1 }, to: { row: Math.max(2, sheet.rowCount), column: lastColumn } };
|
||||
for (const [key, values] of Object.entries(spec.validations || {})) {
|
||||
const col = spec.columns.findIndex(([columnKey]) => columnKey === key) + 1;
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
<meta name="theme-color" content="#132451" />
|
||||
<meta name="description" content="衡准考试信息管理系统——考试通知、考生报名、准考证与成绩查询一站式服务。" />
|
||||
<title>衡准 · 考试信息管理系统</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<link rel="stylesheet" href="/styles.css?v=20260722" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" aria-live="polite">
|
||||
@@ -21,6 +21,6 @@
|
||||
<span class="toast-icon">✓</span>
|
||||
<div><strong>操作成功</strong><small>更改已保存</small></div>
|
||||
</div>
|
||||
<script type="module" src="/app.js"></script>
|
||||
<script type="module" src="/app.js?v=20260722"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+16
-16
@@ -1787,12 +1787,12 @@
|
||||
]
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
@@ -2543,18 +2543,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
||||
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/mdast-util-from-markdown": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||
@@ -3843,6 +3831,18 @@
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sanitize-html/node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server.mjs",
|
||||
"test": "node tests/cache.test.mjs && node tests/system.test.mjs",
|
||||
"test": "node tests/client-auth.test.mjs && node tests/api-dedup.test.mjs && node tests/cache.test.mjs && node tests/auth-state.test.mjs && node tests/state-cache.test.mjs && node tests/document-verification.test.mjs && node tests/admission.test.mjs && node tests/seed.test.mjs && node tests/system.test.mjs",
|
||||
"test:cache": "node tests/cache.test.mjs",
|
||||
"reset-db": "node scripts/reset-dev-database.mjs",
|
||||
"seed-test-data": "node scripts/import-test-data.mjs",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { loadEnvFile } from 'node:process';
|
||||
import { buildSeedOperations, createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const envPath = join(root, '.env');
|
||||
@@ -73,7 +74,7 @@ const mysqlBusinessTables = [
|
||||
'schools', 'school_classes', 'candidate_profiles', 'notices', 'exams', 'exam_subjects',
|
||||
'registrations', 'registration_subjects', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects',
|
||||
'results', 'test_centers', 'test_rooms', 'center_change_requests', 'center_change_rooms',
|
||||
'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'audit_logs'
|
||||
'candidate_account_batches', 'candidate_account_batch_items', 'workflow_instances', 'workflow_actions', 'admission_records', 'audit_logs'
|
||||
];
|
||||
|
||||
async function prepareMysql() {
|
||||
@@ -101,8 +102,8 @@ async function prepareMysql() {
|
||||
}
|
||||
|
||||
const [metadataRows] = await connection.query('SELECT schema_version FROM schema_metadata WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.schema_version) !== 16) {
|
||||
throw new Error(`MySQL 数据库结构版本不是 v16(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||
if (Number(metadataRows[0]?.schema_version) !== CURRENT_SCHEMA_VERSION) {
|
||||
throw new Error(`MySQL 数据库结构版本不是 v${CURRENT_SCHEMA_VERSION}(当前 ${metadataRows[0]?.schema_version ?? '未知'}),请先完成结构初始化`);
|
||||
}
|
||||
|
||||
const [examPartitionRows] = await connection.query(
|
||||
@@ -127,8 +128,8 @@ async function prepareMysql() {
|
||||
let recognizedTestData = false;
|
||||
if (initializeEmpty && nonEmpty.length) {
|
||||
const [[bulkUsers]] = await connection.query("SELECT COUNT(*) AS count FROM users WHERE id LIKE 'usr_bulk_%'");
|
||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7')");
|
||||
recognizedTestData = Number(bulkUsers.count) >= 300 && Number(testSchools.count) === 4;
|
||||
const [[testSchools]] = await connection.query("SELECT COUNT(*) AS count FROM schools WHERE id IN ('school_hz1', 'school_hz3', 'school_hz5', 'school_hz7', 'school_hz9')");
|
||||
recognizedTestData = Number(bulkUsers.count) >= 1100 && Number(testSchools.count) === 5;
|
||||
}
|
||||
if (nonEmpty.length && !force && !recognizedTestData) {
|
||||
const forceCommand = initializeEmpty
|
||||
@@ -193,8 +194,9 @@ if (initializeEmpty) {
|
||||
console.log(`${state.users.length} initial administrator, ${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
} else {
|
||||
console.log(`Imported test data into ${location}${mysqlAlreadyImported ? ' (transactional replace)' : ''}`);
|
||||
console.log(`${state.schools.length} schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
console.log(`${state.schools.filter(item => item.isSourceSchool).length} source schools, ${state.schools.filter(item => item.isAdmissionSchool).length} admission schools, ${state.candidateProfiles.length} candidates, ${state.registrations.length} registrations`);
|
||||
console.log(`pending ${pending}, rejected ${rejected}, approved/unpaid ${unpaid}, approved/paid ${paid}`);
|
||||
console.log(`${state.results.length} published subject scores, ${state.admissionRecords.filter(item => item.kind === 'preference' && Number(item.payload?.round || 1) === 1).length} first-round preferences`);
|
||||
console.log(`arrangement plans ${state.arrangementPlans.length}, admit cards ${state.registrations.filter(item => item.admitCard).length}`);
|
||||
console.log('all predefined test account passwords: 12345678');
|
||||
}
|
||||
|
||||
@@ -1,35 +1,4 @@
|
||||
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { createDatabase } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const databasePath = join(root, 'data', 'exam.sqlite');
|
||||
const relativePath = relative(root, databasePath);
|
||||
if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) throw new Error('拒绝重建工作区以外的数据库');
|
||||
|
||||
for (const path of [databasePath, `${databasePath}-shm`, `${databasePath}-wal`]) await rm(path, { force: true });
|
||||
|
||||
function hashPassword(password, salt = randomBytes(16).toString('hex')) {
|
||||
const hash = pbkdf2Sync(password, salt, 120000, 32, 'sha256').toString('hex');
|
||||
return `${salt}:${hash}`;
|
||||
}
|
||||
|
||||
process.env.DATABASE_CLIENT = 'sqlite';
|
||||
process.env.SQLITE_PATH = databasePath;
|
||||
const database = await createDatabase({
|
||||
root,
|
||||
seed: () => createBaseDatabase({
|
||||
nowIso: () => new Date().toISOString(),
|
||||
hashPassword,
|
||||
initialAdmin: {
|
||||
username: process.env.INITIAL_ADMIN_USERNAME,
|
||||
password: process.env.INITIAL_ADMIN_PASSWORD,
|
||||
displayName: process.env.INITIAL_ADMIN_DISPLAY_NAME
|
||||
}
|
||||
})
|
||||
});
|
||||
const state = await database.read();
|
||||
await database.close();
|
||||
console.log(`Rebuilt empty ${databasePath} (schema ${state.meta.version}, ${state.candidateProfiles.length} candidates)`);
|
||||
// Keep the historical reset-db entry point, but run the same guarded initializer used
|
||||
// by initialize-system so SQLite, MySQL, .env loading and schema checks stay in sync.
|
||||
if (!process.argv.slice(2).includes('--empty')) process.argv.push('--empty');
|
||||
await import('./import-test-data.mjs');
|
||||
|
||||
+127
-42
@@ -8,18 +8,23 @@ import { createDatabase } from './database.mjs';
|
||||
import { buildCenterMaterialsWorkbook, buildWorkbook, hasExcelResource, parseWorkbook } from './excel.mjs';
|
||||
import { createAdminRoutes } from './src/routes/admin.routes.mjs';
|
||||
import { createCandidateRoutes } from './src/routes/candidate.routes.mjs';
|
||||
import { createAdmissionRoutes } from './src/routes/admission.routes.mjs';
|
||||
import { createAuthRoutes } from './src/routes/auth.routes.mjs';
|
||||
import { createPublicRoutes } from './src/routes/public.routes.mjs';
|
||||
import { adminLevelNames, adminScopeLabel, createPermissionGuard, hasPermission, permissionsByLevel, profileInScope, registrationInScope } from './src/security/authorization.mjs';
|
||||
import { createSessionManager } from './src/security/session.mjs';
|
||||
import { createAuthStateStore } from './src/security/auth-state.mjs';
|
||||
import { readBodyBuffer, readJson, sendError, sendJson, sendWorkbook } from './src/http/responses.mjs';
|
||||
import { createBaseDatabase } from './src/data/base.mjs';
|
||||
import { resolveRegion } from './src/data/region-service.mjs';
|
||||
import { createRedisCache, withCacheInvalidation } from './src/cache/redis-cache.mjs';
|
||||
import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from './src/security/document-verification.mjs';
|
||||
import { admissionRecords, assignAdmissionNoticeNumbers } from './src/services/volunteer-admission.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
const envPath = join(root, '.env');
|
||||
if (existsSync(envPath)) loadEnvFile(envPath);
|
||||
const documentVerificationSecret = resolveDocumentVerificationSecret();
|
||||
|
||||
const port = Number(process.env.PORT || 4173);
|
||||
const host = process.env.HOST || '127.0.0.1';
|
||||
@@ -37,7 +42,6 @@ const publicSiteConfig = Object.freeze({
|
||||
heroDescription: process.env.PUBLIC_SITE_HERO_DESCRIPTION || '使用学校下发的报名号登录,完成密码更新和个人信息核验后,即可办理所有考试事项。',
|
||||
footerNotice: process.env.PUBLIC_SITE_FOOTER_NOTICE || ''
|
||||
});
|
||||
const sessions = new Map();
|
||||
const staticFiles = new Set([
|
||||
'/index.html',
|
||||
'/styles.css',
|
||||
@@ -45,11 +49,16 @@ const staticFiles = new Set([
|
||||
'/src/client/api.mjs',
|
||||
'/src/client/admin-views.mjs',
|
||||
'/src/client/candidate-views.mjs',
|
||||
'/src/client/admission-views.mjs',
|
||||
'/src/client/admission-plan-editor.mjs',
|
||||
'/src/client/public-views.mjs',
|
||||
'/src/client/state.mjs',
|
||||
'/src/client/ui.mjs',
|
||||
'/src/client/table-state.mjs',
|
||||
'/src/client/pdf-export.mjs',
|
||||
'/src/client/region-select.mjs',
|
||||
'/src/data/china-regions.mjs'
|
||||
'/src/data/china-regions.mjs',
|
||||
'/src/data/specialty-types.mjs'
|
||||
]);
|
||||
const vendorStaticFiles = new Map([
|
||||
['/vendor/ckeditor5/ckeditor5.js', join(root, 'node_modules', 'ckeditor5', 'dist', 'browser', 'ckeditor5.js')],
|
||||
@@ -96,7 +105,14 @@ const initializeDatabase = () => createBaseDatabase({
|
||||
});
|
||||
const persistentDatabase = await createDatabase({ root, seed: initializeDatabase });
|
||||
const cache = await createRedisCache();
|
||||
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateExam', 'archiveExam']);
|
||||
let authState;
|
||||
try {
|
||||
authState = await createAuthStateStore();
|
||||
} catch (error) {
|
||||
await Promise.allSettled([persistentDatabase.close(), cache.close()]);
|
||||
throw error;
|
||||
}
|
||||
const resultCacheWriteMethods = new Set(['saveResult', 'saveResults', 'updateFeatureScore', 'updateFeatureScores', 'updateExam', 'archiveExam']);
|
||||
const database = withCacheInvalidation(persistentDatabase, cache, (method, args) => {
|
||||
const namespaces = ['public'];
|
||||
const instance = args[0];
|
||||
@@ -108,8 +124,11 @@ const database = withCacheInvalidation(persistentDatabase, cache, (method, args)
|
||||
return namespaces;
|
||||
});
|
||||
const readDb = () => database.read();
|
||||
const documentNumberDb = await readDb();
|
||||
const missingNoticeNumbers = admissionRecords(documentNumberDb, 'placement').filter(item => item.status === 'final' && !item.payload?.noticeNumber);
|
||||
if (missingNoticeNumbers.length) await database.saveAdmissionRecords(assignAdmissionNoticeNumbers(documentNumberDb, missingNoticeNumbers));
|
||||
|
||||
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ sessions, readDb, sendError });
|
||||
const { parseCookies, currentUser, safeUser, requireUser } = createSessionManager({ authState, readDb, sendError });
|
||||
const requirePermission = createPermissionGuard(sendError);
|
||||
|
||||
function adminsForStep(db, adminLevel, profile) {
|
||||
@@ -353,12 +372,46 @@ function gradeForRank(rank, cohortSize) {
|
||||
return 'D';
|
||||
}
|
||||
|
||||
const resultIndexCache = new WeakMap();
|
||||
const examSummaryCache = new WeakMap();
|
||||
|
||||
function resultIndexes(db) {
|
||||
let indexes = resultIndexCache.get(db);
|
||||
if (indexes) return indexes;
|
||||
const publishedScoresBySubject = new Map();
|
||||
const publishedByRegistration = new Map();
|
||||
for (const result of db.results) {
|
||||
if (!result.published) continue;
|
||||
if (!publishedScoresBySubject.has(result.subjectId)) publishedScoresBySubject.set(result.subjectId, []);
|
||||
publishedScoresBySubject.get(result.subjectId).push(Number(result.score));
|
||||
if (!publishedByRegistration.has(result.registrationId)) publishedByRegistration.set(result.registrationId, []);
|
||||
publishedByRegistration.get(result.registrationId).push(result);
|
||||
}
|
||||
for (const scores of publishedScoresBySubject.values()) scores.sort((left, right) => left - right);
|
||||
indexes = { publishedScoresBySubject, publishedByRegistration };
|
||||
resultIndexCache.set(db, indexes);
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function countScoresGreaterThan(sortedScores, score) {
|
||||
let low = 0;
|
||||
let high = sortedScores.length;
|
||||
while (low < high) {
|
||||
const middle = Math.floor((low + high) / 2);
|
||||
if (sortedScores[middle] <= score) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return sortedScores.length - low;
|
||||
}
|
||||
|
||||
function resultRankInfo(db, result, scoreOverride = result?.score) {
|
||||
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
|
||||
const score = Number(scoreOverride);
|
||||
const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published);
|
||||
const cohortSize = peers.length + 1;
|
||||
const rank = 1 + peers.filter(item => Number(item.score) > score).length;
|
||||
const scores = resultIndexes(db).publishedScoresBySubject.get(result.subjectId) || [];
|
||||
const cohortSize = scores.length + (result.published ? 0 : 1);
|
||||
let greater = countScoresGreaterThan(scores, score);
|
||||
if (result.published && Number(result.score) > score) greater -= 1;
|
||||
const rank = 1 + Math.max(0, greater);
|
||||
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
|
||||
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
|
||||
}
|
||||
@@ -375,15 +428,43 @@ function subjectPassEvaluation(db, result, subject, scoreOverride = result?.scor
|
||||
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
|
||||
}
|
||||
|
||||
function examSummaryIndexes(db) {
|
||||
let indexes = examSummaryCache.get(db);
|
||||
if (indexes) return indexes;
|
||||
const examById = new Map(db.exams.map(exam => [exam.id, exam]));
|
||||
const publishedByRegistration = resultIndexes(db).publishedByRegistration;
|
||||
const baseByRegistration = new Map();
|
||||
const totalsByCohort = new Map();
|
||||
for (const registration of db.registrations) {
|
||||
if (registration.status !== 'approved') continue;
|
||||
const exam = examById.get(registration.examId);
|
||||
if (!exam) continue;
|
||||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||||
const published = publishedByRegistration.get(registration.id) || [];
|
||||
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
||||
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
||||
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
||||
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||||
const cohortKey = `${exam.id}\u0000${subjectKey}`;
|
||||
const base = { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey };
|
||||
baseByRegistration.set(registration.id, base);
|
||||
if (complete) {
|
||||
if (!totalsByCohort.has(cohortKey)) totalsByCohort.set(cohortKey, []);
|
||||
totalsByCohort.get(cohortKey).push(total);
|
||||
}
|
||||
}
|
||||
for (const totals of totalsByCohort.values()) totals.sort((left, right) => left - right);
|
||||
indexes = { baseByRegistration, totalsByCohort };
|
||||
examSummaryCache.set(db, indexes);
|
||||
return indexes;
|
||||
}
|
||||
|
||||
function examResultSummary(db, registration) {
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (!exam) return null;
|
||||
const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id));
|
||||
const published = db.results.filter(result => result.registrationId === registration.id && result.published);
|
||||
const resultsBySubject = new Map(published.map(result => [result.subjectId, result]));
|
||||
const complete = subjects.length > 0 && subjects.every(subject => resultsBySubject.has(subject.id));
|
||||
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
|
||||
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
|
||||
const summaryIndexes = examSummaryIndexes(db);
|
||||
const base = summaryIndexes.baseByRegistration.get(registration.id);
|
||||
if (!base) return null;
|
||||
const { exam, subjects, published, resultsBySubject, complete, total, fullScore, cohortKey } = base;
|
||||
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
|
||||
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
|
||||
const value = Number(exam.passValue ?? 60);
|
||||
@@ -398,17 +479,9 @@ function examResultSummary(db, registration) {
|
||||
});
|
||||
if (complete && policy === 'none') qualified = null;
|
||||
if (complete && policy === 'rank_percent') {
|
||||
const subjectKey = [...registration.subjectIds].sort().join('|');
|
||||
const totals = db.registrations
|
||||
.filter(item => item.examId === exam.id && item.status === 'approved' && [...item.subjectIds].sort().join('|') === subjectKey)
|
||||
.map(item => {
|
||||
const itemResults = db.results.filter(result => result.registrationId === item.id && result.published);
|
||||
if (!item.subjectIds.every(id => itemResults.some(result => result.subjectId === id))) return null;
|
||||
return itemResults.filter(result => item.subjectIds.includes(result.subjectId)).reduce((sum, result) => sum + Number(result.score), 0);
|
||||
})
|
||||
.filter(item => item != null);
|
||||
const totals = summaryIndexes.totalsByCohort.get(cohortKey) || [];
|
||||
cohortSize = totals.length;
|
||||
rank = 1 + totals.filter(item => item > total).length;
|
||||
rank = 1 + countScoresGreaterThan(totals, total);
|
||||
qualified = rank <= Math.max(1, Math.ceil(cohortSize * value / 100));
|
||||
}
|
||||
|
||||
@@ -421,6 +494,7 @@ function examResultSummary(db, registration) {
|
||||
complete,
|
||||
publishedSubjects: published.length,
|
||||
subjectCount: subjects.length,
|
||||
featureScore: Number(registration.featureScore || 0),
|
||||
total,
|
||||
fullScore,
|
||||
scoreRatio: Number(scoreRatio.toFixed(2)),
|
||||
@@ -457,7 +531,7 @@ function logAction(db, user, action, detail) {
|
||||
const excelResourceNames = {
|
||||
classes: '班级台账', class_admins: '班级管理员', account_quotas: '报名号班级配额',
|
||||
account_results: '报名号下发结果', candidates: '考生资料', payments: '考试缴费名单', centers: '考点考场档案', results: '成绩台账',
|
||||
admit_cards: '准考证信息台账'
|
||||
admit_cards: '准考证信息台账', admitted_candidates: '录取考生信息'
|
||||
};
|
||||
|
||||
function admissionRowsForRegistrations(db, registrations) {
|
||||
@@ -553,23 +627,26 @@ function excelRowsForResource(db, user, resource, searchParams) {
|
||||
});
|
||||
if (resource === 'results') {
|
||||
const examId = cleanText(searchParams.get('examId'), 64);
|
||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
const scopedRegistrations = db.registrations.filter(item => item.status === 'approved' && registrationInScope(db, user, item) && (!examId || item.examId === examId));
|
||||
return scopedRegistrations.flatMap(registration => {
|
||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||
const account = db.users.find(item => item.id === registration?.userId);
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||
const evaluation = subjectPassEvaluation(db, result, subject);
|
||||
const rank = resultRankInfo(db, result);
|
||||
return {
|
||||
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '',
|
||||
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
|
||||
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score,
|
||||
rank: rank.rank, rankPercent: rank.rankPercent,
|
||||
qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线',
|
||||
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || ''
|
||||
};
|
||||
const schoolClass = db.classes.find(item => item.id === profile?.classId);
|
||||
return (exam?.subjects || []).filter(subject => registration.subjectIds.includes(subject.id)).map(subject => {
|
||||
const result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id);
|
||||
const evaluation = result ? subjectPassEvaluation(db, result, subject) : null;
|
||||
const rank = result ? resultRankInfo(db, result) : null;
|
||||
return {
|
||||
candidateNumber: account?.candidateNumber || registration.registrationNumber || '', cardNumber: registration.admitCard?.number || '',
|
||||
candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
|
||||
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
|
||||
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation?.passScore ?? '', score: result?.score ?? '',
|
||||
rank: rank?.rank ?? '', rankPercent: rank?.rankPercent ?? '',
|
||||
qualified: !result || evaluation?.qualified == null ? '' : evaluation.qualified ? '达线' : '未达线',
|
||||
grade: result ? (result.published ? rank?.grade || '' : '待发布') : '', published: result?.published ? '发布' : '不发布', updatedAt: result?.updatedAt || result?.publishedAt || ''
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
if (resource === 'admit_cards') {
|
||||
@@ -840,6 +917,10 @@ const routeContext = {
|
||||
database,
|
||||
cache,
|
||||
resultsCacheTtlSeconds: process.env.REDIS_RESULTS_CACHE_TTL_SECONDS || 86400,
|
||||
documentVerificationSecret,
|
||||
scoreReportCode,
|
||||
admissionNoticeCode,
|
||||
safeCodeEqual,
|
||||
readDb,
|
||||
publicSiteConfig,
|
||||
sendJson,
|
||||
@@ -892,7 +973,7 @@ const routeContext = {
|
||||
randomBytes,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
authState,
|
||||
buildWorkbook,
|
||||
buildCenterMaterialsWorkbook,
|
||||
hasExcelResource,
|
||||
@@ -904,6 +985,7 @@ const routeContext = {
|
||||
const handlePublic = createPublicRoutes(routeContext);
|
||||
const handleAuth = createAuthRoutes(routeContext);
|
||||
const handleCandidate = createCandidateRoutes(routeContext);
|
||||
const handleAdmission = createAdmissionRoutes(routeContext);
|
||||
const handleAdmin = createAdminRoutes(routeContext);
|
||||
|
||||
async function serveStatic(response, pathname) {
|
||||
@@ -929,6 +1011,8 @@ const server = createServer(async (request, response) => {
|
||||
if (authHandled !== false) return;
|
||||
const candidateHandled = await handleCandidate(request, response, pathname);
|
||||
if (candidateHandled !== false) return;
|
||||
const admissionHandled = await handleAdmission(request, response, pathname);
|
||||
if (admissionHandled !== false) return;
|
||||
const adminHandled = await handleAdmin(request, response, pathname);
|
||||
if (adminHandled !== false) return;
|
||||
if (await serveStatic(response, pathname)) return;
|
||||
@@ -943,12 +1027,13 @@ server.listen(port, host, () => {
|
||||
console.log(`衡准考试信息管理系统:http://${host}:${port}`);
|
||||
console.log(`数据库:${database.client}(${database.location})`);
|
||||
console.log(`Redis 缓存:${cache.status === 'ready' ? '已连接' : cache.status === 'disabled' ? '未配置' : '不可用,已回源数据库'}`);
|
||||
console.log(`登录状态:${authState.status === 'ready' ? `Redis DB ${authState.database}` : '本机内存(Redis 未配置)'}`);
|
||||
});
|
||||
|
||||
async function shutdown(signal) {
|
||||
console.log(`收到 ${signal},正在关闭服务...`);
|
||||
server.close(async () => {
|
||||
await Promise.allSettled([database.close(), cache.close()]);
|
||||
await Promise.allSettled([database.close(), cache.close(), authState.close()]);
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
Vendored
+55
-10
@@ -5,26 +5,69 @@ function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
|
||||
}
|
||||
|
||||
function disabledCache(status = 'disabled') {
|
||||
function disabledCache(status = 'disabled', { ttlSeconds = 60, maxEntries = 200 } = {}) {
|
||||
const values = new Map();
|
||||
const pending = new Map();
|
||||
const generations = new Map();
|
||||
|
||||
const cacheKey = (namespace, key) => `${namespace}:${key}`;
|
||||
const generation = namespace => generations.get(namespace) || 0;
|
||||
const prune = () => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of values) if (entry.expiresAt <= now) values.delete(key);
|
||||
while (values.size > maxEntries) values.delete(values.keys().next().value);
|
||||
};
|
||||
|
||||
return {
|
||||
enabled: false,
|
||||
status,
|
||||
async remember(_namespace, _key, loader) {
|
||||
return loader();
|
||||
async remember(namespace, key, loader, options = {}) {
|
||||
const fullKey = cacheKey(namespace, key);
|
||||
const cached = values.get(fullKey);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.value;
|
||||
if (cached) values.delete(fullKey);
|
||||
const startedGeneration = generation(namespace);
|
||||
const active = pending.get(fullKey);
|
||||
if (active?.generation === startedGeneration) return active.promise;
|
||||
|
||||
const loading = Promise.resolve(loader()).then(value => {
|
||||
if (generation(namespace) === startedGeneration) {
|
||||
const lifetime = positiveInteger(options.ttlSeconds, ttlSeconds, 86400);
|
||||
values.set(fullKey, { value, expiresAt: Date.now() + lifetime * 1000 });
|
||||
prune();
|
||||
}
|
||||
return value;
|
||||
}).finally(() => {
|
||||
if (pending.get(fullKey)?.promise === loading) pending.delete(fullKey);
|
||||
});
|
||||
pending.set(fullKey, { generation: startedGeneration, promise: loading });
|
||||
return loading;
|
||||
},
|
||||
async invalidate() { return false; },
|
||||
async close() {}
|
||||
async invalidate(namespace) {
|
||||
generations.set(namespace, generation(namespace) + 1);
|
||||
const prefix = `${namespace}:`;
|
||||
for (const key of values.keys()) if (key.startsWith(prefix)) values.delete(key);
|
||||
// Preserve the public meaning of this return value: no Redis namespace
|
||||
// was refreshed, even though the local fallback was invalidated.
|
||||
return false;
|
||||
},
|
||||
async close() {
|
||||
values.clear();
|
||||
pending.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function createRedisCache({ env = process.env, logger = console, clientFactory = createClient } = {}) {
|
||||
const url = String(env.REDIS_URL || '').trim();
|
||||
if (!url) return disabledCache();
|
||||
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
|
||||
const localMaxEntries = positiveInteger(env.LOCAL_CACHE_MAX_ENTRIES, 200, 5000);
|
||||
if (!url) return disabledCache('disabled', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
|
||||
const fallback = disabledCache('unavailable', { ttlSeconds: defaultTtlSeconds, maxEntries: localMaxEntries });
|
||||
|
||||
const prefix = String(env.REDIS_CACHE_PREFIX || 'exam-information')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information';
|
||||
const defaultTtlSeconds = positiveInteger(env.REDIS_CACHE_TTL_SECONDS, 60, 86400);
|
||||
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
|
||||
const pending = new Map();
|
||||
let warningReported = false;
|
||||
@@ -54,7 +97,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
} catch (error) {
|
||||
warn(error);
|
||||
if (client.isOpen) client.destroy();
|
||||
return disabledCache('unavailable');
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const versionKey = namespace => `${prefix}:namespace:${namespace}`;
|
||||
@@ -75,7 +118,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
return client.isReady ? 'ready' : 'unavailable';
|
||||
},
|
||||
async remember(namespace, key, loader, { ttlSeconds = defaultTtlSeconds } = {}) {
|
||||
if (!client.isReady) return loader();
|
||||
if (!client.isReady) return fallback.remember(namespace, key, loader, { ttlSeconds });
|
||||
try {
|
||||
const version = await namespaceVersion(namespace);
|
||||
const cacheKey = `${prefix}:${namespace}:${version}:${key}`;
|
||||
@@ -99,10 +142,11 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
return loading;
|
||||
} catch (error) {
|
||||
warn(error);
|
||||
return loader();
|
||||
return fallback.remember(namespace, key, loader, { ttlSeconds });
|
||||
}
|
||||
},
|
||||
async invalidate(namespace) {
|
||||
await fallback.invalidate(namespace);
|
||||
if (!client.isReady) return false;
|
||||
try {
|
||||
await client.incr(versionKey(namespace));
|
||||
@@ -113,6 +157,7 @@ export async function createRedisCache({ env = process.env, logger = console, cl
|
||||
}
|
||||
},
|
||||
async close() {
|
||||
await fallback.close();
|
||||
if (client.isOpen) await client.quit();
|
||||
}
|
||||
};
|
||||
|
||||
+232
-43
File diff suppressed because one or more lines are too long
@@ -0,0 +1,16 @@
|
||||
import { specialtyCatalog } from '../data/specialty-types.mjs';
|
||||
|
||||
export function indicatorAllocationEditor(h, sourceSchools = [], allocation = {}) {
|
||||
return `<div class="indicator-allocation-row"><label><span>生源学校</span><select name="indicatorSchool"><option value="">请选择生源校</option>${sourceSchools.map(school => `<option value="${h(school.id)}" ${school.id === allocation.sourceSchoolId ? 'selected' : ''}>${h(school.code)} · ${h(school.name)}</option>`).join('')}</select></label><label><span>分配名额</span><input name="indicatorQuota" type="number" min="1" step="1" value="${h(allocation.quota || '')}" placeholder="人数"></label><button type="button" class="row-action" data-action="remove-indicator-allocation">移除</button></div>`;
|
||||
}
|
||||
|
||||
export function admissionCategoryEditor(h, sourceSchools = [], category = {}) {
|
||||
const specialty = Boolean(category.specialtyCategory);
|
||||
const selectedCategory = specialtyCatalog.find(item => item.code === category.specialtyCategory);
|
||||
return `<article class="admission-category-editor"><header><div><span>招生类别</span><strong>${h(category.name || '新类别')}</strong></div><button type="button" data-action="remove-admission-category">移除类别</button></header><div class="admission-category-fields"><label><span>类别名称 *</span><input name="categoryName" required maxlength="80" value="${h(category.name || '')}" placeholder="例如:普通生、艺术特长生"></label><label><span>计划人数 *</span><input name="categoryQuota" required type="number" min="1" step="1" value="${h(category.quota || '')}" placeholder="人数"></label><label><span>类别性质</span><select name="categoryKind" data-action="plan-category-kind"><option value="general" ${specialty ? '' : 'selected'}>普通 / 政策类</option><option value="specialty" ${specialty ? 'selected' : ''}>特长生</option></select></label><div class="specialty-plan-fields ${specialty ? '' : 'hidden'}" data-plan-specialty><label><span>特长大类 *</span><select name="categorySpecialtyCategory" data-action="specialty-category" ${specialty ? '' : 'disabled'}><option value="">请选择大类</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyCategory ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长小类 *</span><select name="categorySpecialtyType" data-specialty-type ${specialty && selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === category.specialtyType ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label></div></div><section class="indicator-allocation-editor"><div class="indicator-allocation-head"><div><strong>指标分配</strong><small>可把本类别计划的一部分定向分配给生源校,合计不得超过计划人数。</small></div><button type="button" class="row-action" data-action="add-indicator-allocation">添加生源校指标</button></div><div data-indicator-allocations>${(category.indicatorAllocations || []).map(item => indicatorAllocationEditor(h, sourceSchools, item)).join('')}</div></section></article>`;
|
||||
}
|
||||
|
||||
export function admissionCategoriesEditor(h, sourceSchools = [], categories = []) {
|
||||
const initial = categories.length ? categories : [{ name: '普通生', quota: '', indicatorAllocations: [] }];
|
||||
return `<section class="admission-categories-builder"><div class="admission-builder-head"><div><strong>招生类别与计划</strong><small>逐项设置类别、资格范围和生源校指标。</small></div><button type="button" class="row-action primary" data-action="add-admission-category">添加招生类别</button></div><div data-admission-categories>${initial.map(category => admissionCategoryEditor(h, sourceSchools, category)).join('')}</div></section>`;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export function createAdmissionViews(context) {
|
||||
const { state, app, h, formatDate, badge, icons, api, renderError, requireLogin, brand } = context;
|
||||
const nav = [['dashboard','工作台','home','总览'],['plans','招生计划','exam','招生业务'],['placements','投档审核','check','招生业务'],['reporting','考生报到','users','招生业务'],['notice-template','通知书模板','ticket','文书中心']];
|
||||
|
||||
function shell(page, content, title, description) {
|
||||
const groups = [...new Set(nav.map(item => item[3]))];
|
||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">招生学校 · ${h(state.pageData?.school?.name || '')}</p><nav class="portal-nav-groups">${groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(item => item[3] === group).map(([id,label,icon]) => `<button class="${page === id ? 'active' : ''}" data-route="admission_school/${id}"><span>${icons[icon]}</span>${label}</button>`).join('')}</section>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>仅本校招生数据</strong><small>考生志愿不可见、不可修改</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar">${icons.menu}</button><div><span>招生学校</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user"><span class="user-avatar">${h((state.user?.displayName || '招').slice(0,1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>招生学校账号</small></span><button class="logout-button" data-action="logout">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">SCHOOL ADMISSION</p><h1>${h(title)}</h1><p>${h(description)}</p></div></div>${content}</section></main></div>`;
|
||||
}
|
||||
|
||||
async function renderAdmission(page) {
|
||||
if (state.user?.role !== 'admission_school') return requireLogin();
|
||||
if (!nav.some(item => item[0] === page)) page = 'dashboard';
|
||||
const meta = { dashboard:['招生工作台','查看本校计划完成率、报到进度与待办事项。'], plans:['本校招生计划','上传本年度普通生、特长生计划及指标分配,提交后由超级管理员审核。'], placements:['投档考生审核','查看投档考生资料和本场成绩;无特殊理由不得申请退档。'], reporting:['考生报到','暂存报到状态,支持 Excel 批量维护和通知书二维码核验。'], 'notice-template':['录取通知书模板','设计本校录取通知书的标题、正文、落款与主色,正式录取后由考生下载。'] };
|
||||
app.innerHTML = shell(page, '<div class="loading-panel"><i></i><span>正在读取数据</span></div>', ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'dashboard' ? 'context' : page;
|
||||
const data = await api(`/api/admission/${endpoint}`); state.pageData = data;
|
||||
const content = page === 'dashboard' ? dashboard(data) : page === 'plans' ? plans(data) : page === 'placements' ? placements(data) : page === 'reporting' ? reporting(data) : noticeTemplate(data);
|
||||
app.innerHTML = shell(page, content, ...meta[page]);
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
function dashboard(data) {
|
||||
const progress = data.plans || [];
|
||||
return `<section class="admission-command-banner school"><div><span>ADMISSION OFFICE</span><h2>${h(data.school.name)}</h2><p>学校只接收超级管理员正式投档的数据,不可查看考生完整志愿表。</p></div></section>${progress.length ? `<section class="admission-progress-grid">${progress.map(plan => `<article><header><span>${h(plan.examName)}</span><strong>${h(plan.progress.admissionRate)}%</strong></header><div class="progress-meter"><i style="width:${Math.min(100, plan.progress.admissionRate)}%"></i></div><p>计划 ${h(plan.progress.totalQuota)} 人 · 正式录取 ${h(plan.progress.finalCount)} 人 · 已报到 ${h(plan.progress.reportedCount)} 人</p><small>实际报到完成率 ${h(plan.progress.reportingRate)}%</small></article>`).join('')}</section>` : ''}<div class="admin-dashboard-grid"><section class="panel admin-todos"><div class="panel-title"><h2>本校工作入口</h2><span>${data.exams.length} 场启用志愿</span></div><button data-route="admission_school/plans"><i>计</i><span><strong>上传招生计划</strong><small>普通生、特长生与指标分配</small></span>${icons.arrow}</button><button data-route="admission_school/placements"><i>审</i><span><strong>审核投档考生</strong><small>接收或提交特殊退档理由</small></span>${icons.arrow}</button><button data-route="admission_school/reporting"><i>到</i><span><strong>登记考生报到</strong><small>暂存、Excel 导入或扫描通知书二维码</small></span>${icons.arrow}</button></section>${data.notifications?.length ? `<section class="panel compact-notices"><div class="panel-title"><h2>系统自动通知</h2><span>${data.notifications.length} 条</span></div>${data.notifications.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span>${h(notice.title)}</span></button>`).join('')}</section>` : ''}</div>`;
|
||||
}
|
||||
|
||||
function reporting(data) {
|
||||
if (!data.batches?.length) return `<section class="panel empty-state"><h2>暂无报到批次</h2><p>超级管理员签发正式录取通知书并开启报到后,本页会生成报到台账。</p></section>`;
|
||||
const statusLabels = { draft: '暂存中', submitted: '报到已提交', pending_approval: '补录决定待审批', approved: '已审批并公示', rejected: '审批退回', not_started: '尚未开始' };
|
||||
return data.batches.map(batch => {
|
||||
const key = `reporting-${batch.exam.id}-${batch.round}`;
|
||||
const page = paged(batch.rows, key, 20);
|
||||
const editable = ['draft', 'rejected'].includes(batch.status);
|
||||
const importSummary = state.reportingImportSummaries?.[batch.exam.id];
|
||||
const rowHtml = page.items.map(item => `<tr>${editable ? `<td class="selection-cell"><input type="checkbox" data-reporting-select value="${h(item.placementId)}" aria-label="选择 ${h(item.name)}"></td>` : ''}<td><strong>${h(item.name)}</strong><small class="mono">${h(item.candidateNumber)}</small></td><td><strong class="mono">${h(item.noticeNumber)}</strong><small>${h(item.categoryName)}</small></td><td><select name="status" data-reporting-status data-placement-id="${h(item.placementId)}" ${editable ? '' : 'disabled'}><option value="pending" ${item.status === 'pending' ? 'selected' : ''}>P · 待确认</option><option value="reported" ${item.status === 'reported' ? 'selected' : ''}>Y · 已报到</option><option value="not_reported" ${item.status === 'not_reported' ? 'selected' : ''}>N · 未报到</option></select></td><td><input name="note" data-reporting-note data-placement-id="${h(item.placementId)}" value="${h(item.note)}" placeholder="选填报到备注" ${editable ? '' : 'disabled'}></td></tr>`).join('');
|
||||
const actions = editable ? `<div class="reporting-actions"><button type="submit" class="ghost-button">暂存当前页</button><button type="button" class="solid-button" data-action="submit-admission-reporting" data-exam-id="${h(batch.exam.id)}">提交全部报到情况</button></div>` : batch.status === 'submitted' ? `<form class="reporting-decision" data-form="admission-reporting-decision"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><div><strong>报到情况已提交</strong><p>请根据实际报到完成率决定是否申请补录;决定需超级管理员审批。</p></div><label><span>学校决定</span><select name="supplement"><option value="false">不进行补录</option><option value="true" ${batch.progress.reportingGap ? '' : 'disabled'}>申请补录 ${h(batch.progress.reportingGap)} 人</option></select></label><label><span>决定说明</span><input name="decisionNote" placeholder="填写补录原因或不补录说明"></label><button class="solid-button" type="submit">提交超级管理员审批</button></form>` : `<div class="reporting-readonly-note"><strong>${h(statusLabels[batch.status] || batch.status)}</strong><p>${h(batch.approvalNote || batch.decisionNote || '等待下一步处理')}</p></div>`;
|
||||
const bulkTools = editable ? `<div class="reporting-bulk-bar" data-reporting-bulk data-table-id="${h(key)}"><label class="bulk-check"><input type="checkbox" data-reporting-select-all data-table-id="${h(key)}"><span>全选本页</span></label><strong data-reporting-selected-count>已选 0 人</strong><label><span>统一状态</span><select data-reporting-bulk-status><option value="reported">Y · 确认报到</option><option value="not_reported">N · 确认未报到</option><option value="pending">P · 待确认</option></select></label><label class="bulk-note"><span>统一备注(留空则保留原备注)</span><input data-reporting-bulk-note placeholder="例如:现场核验通过"></label><button type="button" class="ghost-button" data-action="bulk-reporting-apply">应用到所选</button></div>` : '';
|
||||
const ledger = `<div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索考生、报名号、通知书编号或类别"></label><div class="filter-pills"><button type="button" class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="reported">已报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="not_reported">未报到</button><button type="button" data-action="status-filter" data-target="${h(key)}" data-status="pending">待确认</button></div></div>${bulkTools}<div class="table-scroll"><table id="${h(key)}"><thead><tr>${editable ? '<th class="selection-cell">选择</th>' : ''}<th>考生</th><th>通知书 / 类别</th><th>报到状态码</th><th>备注</th></tr></thead><tbody>${rowHtml || `<tr><td colspan="${editable ? '5' : '4'}" class="empty-state">本轮没有正式录取考生</td></tr>`}</tbody></table></div>${pagination(page)}`;
|
||||
const ledgerBlock = editable ? `<form data-form="admission-reporting-draft" data-exam-id="${h(batch.exam.id)}">${ledger}${actions}</form>` : `<div class="reporting-ledger-readonly">${ledger}</div>${actions}`;
|
||||
return `<section class="reporting-workbench"><header><div><span>${h(batch.exam.code)} · 第 ${h(batch.round)} 轮</span><h2>${h(batch.exam.name)}</h2><p>计划 ${h(batch.progress.totalQuota)} 人,正式录取 ${h(batch.progress.finalCount)} 人,已报到 ${h(batch.progress.reportedCount)} 人。</p></div><div class="reporting-rate"><strong>${h(batch.progress.reportingRate)}%</strong><span>计划报到完成率</span></div></header><div class="reporting-stat-strip"><span>正式录取 <b>${h(batch.progress.finalCount)}</b></span><span>已报到 <b>${h(batch.progress.reportedCount)}</b></span><span>未报到 <b>${h(batch.progress.notReportedCount)}</b></span><span>计划缺额 <b>${h(batch.progress.reportingGap)}</b></span><em>${h(statusLabels[batch.status] || batch.status)}</em></div>${editable ? `<section class="reporting-tools"><div class="reporting-excel-tool"><div><strong>Excel 批量维护</strong><small>黄色列填写 Y、N 或 P,导入后只暂存,不会直接提交。</small></div><div class="tool-buttons"><button class="ghost-button" data-action="download-admission-reporting" data-exam-id="${h(batch.exam.id)}">导出 Excel</button><label class="solid-button">导入暂存<input type="file" accept=".xlsx" data-admission-reporting-file data-exam-id="${h(batch.exam.id)}" hidden></label></div>${importSummary ? `<div class="reporting-import-summary ${importSummary.changedCount ? 'changed' : 'unchanged'}"><strong>${importSummary.changedCount ? `最近导入已更新 ${h(importSummary.changedCount)} 人` : '最近导入没有产生变化'}</strong><span>读取 ${h(importSummary.count)} 行 · 未变化 ${h(importSummary.unchangedCount)} 行</span>${importSummary.changes?.length ? `<small>${importSummary.changes.slice(0, 3).map(item => `${h(item.name)}:${h(item.fromCode)} → ${h(item.toCode)}`).join(';')}</small>` : '<small>Excel 内容与当前暂存状态一致。</small>'}</div>` : ''}</div><div class="reporting-scan-tool"><div><strong>通知书二维码核验</strong><small>打开实时相机扫描;识别后先核对考生,再点击暂存。</small></div><button type="button" class="camera-button" data-action="open-reporting-camera" data-exam-id="${h(batch.exam.id)}">${icons.camera || ''}<span>打开相机扫码</span></button><form data-form="admission-reporting-scan-preview"><input type="hidden" name="examId" value="${h(batch.exam.id)}"><input name="code" placeholder="也可粘贴 AN 防伪码或二维码链接" required><button class="ghost-button" type="submit">核验</button></form></div></section>` : ''}${ledgerBlock}</section>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function paged(items, key, defaultPageSize = 50) {
|
||||
items = filterTableItems(state, items, key);
|
||||
const current = state.tablePages[key] || {};
|
||||
const pageSize = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : defaultPageSize;
|
||||
const total = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const page = Math.min(Math.max(1, Number(current.page) || 1), totalPages);
|
||||
state.tablePages[key] = { page, pageSize };
|
||||
return { items: items.slice((page - 1) * pageSize, page * pageSize), page, pageSize, total, totalPages, key };
|
||||
}
|
||||
|
||||
function noticeTemplate(data) {
|
||||
const template = data.template || {};
|
||||
return `<section class="notice-template-studio" data-notice-template><form class="panel notice-template-form" data-form="admission-notice-template"><input type="hidden" name="examId" value="${h(data.exams?.[0]?.id || '')}"><div class="panel-title"><div><h2>模板设计</h2><p>正文支持变量:{{考生姓名}}、{{考试名称}}、{{录取学校}}、{{录取类别}}</p></div><span>${data.updatedAt ? `更新于 ${formatDate(data.updatedAt, true)}` : '使用默认模板'}</span></div><div class="field-row"><label><span>英文眉题</span><input name="eyebrow" maxlength="60" value="${h(template.eyebrow || 'ADMISSION NOTICE')}"></label><label><span>中文主标题</span><input name="title" maxlength="80" value="${h(template.title || '录 取 通 知 书')}" required></label></div><label><span>通知书正文 *</span><textarea name="body" rows="9" maxlength="1600" required>${h(template.body || '')}</textarea></label><label><span>页脚说明</span><textarea name="footer" rows="3" maxlength="300">${h(template.footer || '')}</textarea></label><div class="template-color-row"><label><span>学校主色</span><input name="primaryColor" type="color" value="${h(template.primaryColor || '#8d2028')}"></label><label><span>强调色</span><input name="accentColor" type="color" value="${h(template.accentColor || '#c9a45b')}"></label></div><button class="solid-button" type="submit">保存并启用模板</button></form><article class="notice-template-preview" style="--template-primary:${h(template.primaryColor || '#8d2028')};--template-accent:${h(template.accentColor || '#c9a45b')}"><div class="template-frame"><small data-template-preview="eyebrow">${h(template.eyebrow || 'ADMISSION NOTICE')}</small><h2 data-template-preview="title">${h(template.title || '录 取 通 知 书')}</h2><h3>${h(data.school?.name)}</h3><div class="template-notice-number">通知书编号:AD01-EX-2026-ZK-000001</div><strong>张同学:</strong><p data-template-preview="body">${h((template.body || '').replaceAll('{{考生姓名}}','张同学').replaceAll('{{考试名称}}','示例考试').replaceAll('{{录取学校}}',data.school?.name || '本校').replaceAll('{{录取类别}}','普通生'))}</p><footer><span data-template-preview="footer">${h(template.footer || '')}</span><b>${h(data.school?.name)}</b></footer><div class="template-qr-placeholder">防伪二维码</div></div><p>右侧为 A4 通知书预览;正式下载件会自动写入通知书编号、防伪查询码与二维码。</p></article></section>`;
|
||||
}
|
||||
|
||||
function pagination(meta) {
|
||||
if (!meta || meta.total <= meta.pageSize) return '';
|
||||
const start = (meta.page - 1) * meta.pageSize + 1;
|
||||
const end = Math.min(meta.total, meta.page * meta.pageSize);
|
||||
const pages = [...new Set([1, meta.page - 1, meta.page, meta.page + 1, meta.totalPages])].filter(page => page >= 1 && page <= meta.totalPages);
|
||||
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}—${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button>${pages.map((page, index) => `${index && page - pages[index - 1] > 1 ? '<i>…</i>' : ''}<button type="button" class="${page === meta.page ? 'active' : ''}" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${page}">${page}</button>`).join('')}<button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
|
||||
}
|
||||
|
||||
function plans(data) {
|
||||
const planPage = paged(data.plans, 'schoolAdmissionPlanTable', 20);
|
||||
return `<section class="panel admission-plan-console structured"><div class="panel-title"><div><h2>提交本校招生计划</h2><p>按招生类别设置计划人数、特长资格和各生源校指标,提交后由超级管理员审核。</p></div></div><form data-form="school-admission-plan"><label><span>招生考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label>${admissionCategoriesEditor(h, data.sourceSchools)}<label><span>计划说明</span><textarea name="note" rows="2" placeholder="填写政策依据或补充说明"></textarea></label><button class="solid-button" type="submit">提交超级管理员审核</button></form></section><section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="schoolAdmissionPlanTable" placeholder="跨页搜索考试、类别、指标学校或审核意见"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="all">全部</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="pending">待审核</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="approved">已通过</button><button data-action="status-filter" data-target="schoolAdmissionPlanTable" data-status="rejected">已退回</button></div></div><div class="table-scroll"><table id="schoolAdmissionPlanTable"><thead><tr><th>考试</th><th>类别计划</th><th>实时完成率</th><th>指标分配</th><th>状态</th><th>审核意见</th></tr></thead><tbody>${planPage.items.map(plan => `<tr><td>${h(data.exams.find(exam => exam.id === plan.examId)?.name || plan.examId)}</td><td>${plan.payload.categories.map(item => `<strong>${h(item.name)} ${h(item.quota)} 人</strong><small>${h(specialtyLabel(item.specialtyCategory, item.specialtyType) || '普通 / 政策类')}</small>`).join('')}</td><td><strong>${h(plan.progress?.admissionRate || 0)}%</strong><small>正式录取 ${h(plan.progress?.finalCount || 0)} / ${h(plan.progress?.totalQuota || 0)}</small><small>实际报到 ${h(plan.progress?.reportingRate || 0)}%</small></td><td>${plan.payload.categories.flatMap(category => (category.indicatorAllocations || []).map(allocation => `${h(data.sourceSchools.find(item => item.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId)} ${h(allocation.quota)} 人`)).join('<br>') || '无定向指标'}</td><td>${badge(plan.status)}</td><td>${h(plan.payload.reviewNote || '等待审核')}</td></tr>`).join('') || '<tr><td colspan="6" class="empty-state">尚未提交计划</td></tr>'}</tbody></table></div>${pagination(planPage)}</section>`;
|
||||
}
|
||||
|
||||
function placements(data) {
|
||||
const exportBar = data.completedExams?.length ? `<section class="panel admission-export-bar"><div><span>FINAL ROSTER</span><strong>正式录取考生信息 Excel</strong><small>仅录取工作结束后开放,包含本校全部正式录取考生资料与当次成绩。</small></div><label><span>已完成考试</span><select name="exportExamId">${data.completedExams.map(exam => `<option value="${h(exam.id)}">${h(exam.name)}</option>`).join('')}</select></label><button class="solid-button" data-action="download-admitted-candidates">下载 Excel</button></section>` : '';
|
||||
const exams = [...new Map(data.placements.map(item => [item.examId, item.examName])).entries()];
|
||||
const categories = [...new Set(data.placements.map(item => item.payload.categoryName).filter(Boolean))];
|
||||
const pendingCount = data.placements.filter(item => item.status === 'school_review').length;
|
||||
const placementPage = paged(data.placements, 'placementReviewTable');
|
||||
const rows = placementPage.items.map(item => `<tr data-status="${h(item.status)}" data-exam="${h(item.examId)}" data-category="${h(item.payload.categoryName)}"><td><input type="checkbox" data-placement-select value="${h(item.id)}" ${item.status === 'school_review' ? '' : 'disabled'} aria-label="选择 ${h(item.candidate.name)}"></td><td><strong>${h(item.candidate.name)}</strong><small class="mono">${h(item.candidate.registrationNumber)} · ${h(item.candidate.idNumberMasked)}</small><small>${h(item.examName)}</small></td><td>${h(item.candidate.specialtyLabel || '普通生')}<small>${h(item.candidate.specialtyCertificate || '')}</small><small>${h(item.candidate.policyEligibility || '')}</small></td><td>${item.results.map(result => `${h(result.subjectName)} ${h(result.score)}`).join('<br>')}<strong>投档分 ${h(item.payload.totalScore)} · 特征分 ${h(item.featureScore || 0)}</strong></td><td>${h(item.payload.categoryName)}<small>第 ${h(item.payload.preferenceOrder)} 志愿</small></td><td>${badge(item.status)}</td><td>${item.status === 'school_review' ? `<form class="placement-review-form" data-form="placement-review"><input type="hidden" name="id" value="${h(item.id)}"><select name="decision"><option value="accept">接收</option><option value="withdraw">申请退档</option></select><input name="note" placeholder="退档须填写至少 8 字理由"><button class="row-action primary" type="submit">确认</button></form>` : `<small>${h(item.payload.schoolDecisionNote || '已处理')}</small>`}</td></tr>`).join('');
|
||||
return `${exportBar}<section class="panel data-panel placement-review-ledger"><div class="panel-title"><div><h2>本校投档审核台账</h2><p>可搜索、筛选和多选批量处理;仅待审核记录可被选中。</p></div><span>${pendingCount} 人待审 / 共 ${data.placements.length} 人</span></div><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="placementReviewTable" placeholder="跨页搜索姓名、报名号、考试、类别或资格"></label><div class="table-filter-selects"><select data-table-filter="exam" data-target="placementReviewTable"><option value="">全部考试</option>${exams.map(([id, name]) => `<option value="${h(id)}">${h(name)}</option>`).join('')}</select><select data-table-filter="category" data-target="placementReviewTable"><option value="">全部招生类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button type="button" class="row-action" data-action="clear-table-filters" data-target="placementReviewTable">清除筛选</button></div></div><div class="filter-pills placement-status-pills"><button type="button" class="active" data-action="status-filter" data-target="placementReviewTable" data-status="all">全部</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="school_review">待审核</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="admitted">已接收</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="withdrawal_pending">退档待审</button><button type="button" data-action="status-filter" data-target="placementReviewTable" data-status="final">正式录取</button></div><div class="placement-bulk-toolbar"><label><input type="checkbox" data-placement-select-all data-target="placementReviewTable"><span>选择当前页筛选结果中的待审核考生</span></label><div><strong data-placement-selected-count>已选 0 人</strong><button type="button" class="ghost-button" data-action="bulk-placement-review" data-decision="withdraw">批量申请退档</button><button type="button" class="solid-button" data-action="bulk-placement-review" data-decision="accept">批量接收</button></div></div><div class="table-scroll"><table id="placementReviewTable"><thead><tr><th class="select-column">选择</th><th>考生 / 考试</th><th>资格</th><th>当次成绩</th><th>投档类别</th><th>状态</th><th>单人审核</th></tr></thead><tbody>${rows || '<tr><td colspan="7" class="empty-state">暂无投档考生</td></tr>'}</tbody></table></div>${pagination(placementPage)}</section>`;
|
||||
}
|
||||
return { renderAdmission };
|
||||
}
|
||||
import { admissionCategoriesEditor } from './admission-plan-editor.mjs';
|
||||
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { filterTableItems } from './table-state.mjs';
|
||||
+23
-2
@@ -1,4 +1,6 @@
|
||||
export async function api(path, options = {}) {
|
||||
const pendingReads = new Map();
|
||||
|
||||
async function request(path, options) {
|
||||
const binaryBody = options.body instanceof ArrayBuffer || options.body instanceof Blob || options.body instanceof FormData;
|
||||
const response = await fetch(path, {
|
||||
credentials: 'same-origin',
|
||||
@@ -8,6 +10,25 @@ export async function api(path, options = {}) {
|
||||
});
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const data = type.includes('application/json') ? await response.json() : await response.text();
|
||||
if (!response.ok) throw new Error(data?.message || '操作未完成,请稍后重试');
|
||||
if (!response.ok) {
|
||||
const error = new Error(data?.message || '操作未完成,请稍后重试');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function api(path, options = {}) {
|
||||
const method = String(options.method || 'GET').toUpperCase();
|
||||
if (method !== 'GET' || options.body != null || options.signal) return request(path, options);
|
||||
|
||||
// A quick double click or repeated render must not download and parse the
|
||||
// same large JSON response more than once while the first request is active.
|
||||
const key = String(path);
|
||||
if (pendingReads.has(key)) return pendingReads.get(key);
|
||||
const loading = request(path, options).finally(() => {
|
||||
if (pendingReads.get(key) === loading) pendingReads.delete(key);
|
||||
});
|
||||
pendingReads.set(key, loading);
|
||||
return loading;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mountRegionSelects } from './region-select.mjs';
|
||||
import { resolveProfileSpecialty, specialtyCatalog, specialtyLabel } from '../data/specialty-types.mjs';
|
||||
|
||||
export function createCandidateViews(context) {
|
||||
const {
|
||||
@@ -14,30 +15,35 @@ export function createCandidateViews(context) {
|
||||
icons,
|
||||
api,
|
||||
renderError,
|
||||
requireLogin,
|
||||
emptyState,
|
||||
brand
|
||||
} = context;
|
||||
|
||||
const candidateNav = [
|
||||
['dashboard', '总览', 'home'], ['profile', '个人资料', 'user'], ['exams', '考试报名', 'exam'],
|
||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['notices', '通知公告', 'bell'],
|
||||
['registrations', '我的报名', 'check'], ['admit', '准考证', 'ticket'], ['results', '成绩查询', 'chart'], ['admissions', '志愿与录取', 'check'], ['notices', '通知公告', 'bell'],
|
||||
['security', '账户安全', 'user']
|
||||
];
|
||||
function adminNavForUser() {
|
||||
const level = state.user?.adminLevel || 'super';
|
||||
const core = [['dashboard', '工作台', 'home'], ['candidates', level === 'class' ? '本班考生' : '考生信息', 'users'], ['registrations', level === 'class' ? '报名状态' : '报名审核', 'check'], ['payments', level === 'class' ? '缴费确认' : '缴费名单', 'ticket'], ['results', level === 'super' ? '成绩发布' : '成绩查看', 'chart']];
|
||||
const security = ['security', '账户安全', 'user'];
|
||||
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], ['flows', '流程中心', 'check'], core[4], security];
|
||||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], core[3], ['admit', '校内准考证', 'ticket'], ...operations, core[4], security];
|
||||
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], core[2], core[3], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[4], security];
|
||||
if (level === 'class') return [core[0], core[1], core[2], core[3], ['admit', '本班准考证', 'ticket'], core[4], ['flows', '流程中心', 'check'], security];
|
||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], ['indicator-qualifications', '指标资格确认', 'check'], core[2], core[3], ['admit', '校内准考证', 'ticket'], core[4], ['centers', '考场信息', 'exam'], ['flows', '流程中心', 'check'], security];
|
||||
return [core[0], ['schools', '学校管理', 'exam'], ['admins', '管理员', 'users'], core[1], ['exams', '考试与科目', 'exam'], core[2], core[3], ['admit', '准考证编排', 'ticket'], core[4], ['admission-settings', '录取设置', 'check'], ['admission-accounts', '招生账户', 'users'], ['admission-plans', '招生计划', 'exam'], ['admission-reporting', '报到与补录', 'bell'], ['admission-supervision', '投档监督', 'check'], ['notices', '通知发布', 'bell'], ['centers', '考场信息', 'exam'], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], security];
|
||||
}
|
||||
|
||||
function portalShell(role, page, content, title, description) {
|
||||
const nav = role === 'admin' ? adminNavForUser() : candidateNav;
|
||||
const roleName = role === 'admin' ? '管理后台' : '考生中心';
|
||||
const adminTitle = statusLabels[state.user?.adminLevel] || '管理员';
|
||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav>${nav.map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||
const groupFor = id => role === 'candidate'
|
||||
? ({ dashboard: '个人总览', profile: '账户与档案', security: '账户与档案', exams: '考试服务', registrations: '考试服务', admit: '考试服务', results: '考试服务', admissions: '招生录取', notices: '招生录取' }[id] || '其他')
|
||||
: ({ dashboard: '运行总览', schools: '组织与账户', organization: '组织与账户', admins: '组织与账户', 'account-batches': '组织与账户', candidates: '报名考务', registrations: '报名考务', payments: '报名考务', admit: '报名考务', exams: '考试与成绩', results: '考试与成绩', admissions: '招生录取', 'admission-settings': '招生录取', 'admission-accounts': '招生录取', 'admission-plans': '招生录取', 'admission-reporting': '招生录取', 'admission-supervision': '招生录取', 'indicator-qualifications': '招生录取', notices: '招生录取', centers: '场所与流程', flows: '场所与流程', 'flow-design': '系统配置', 'number-rules': '系统配置', security: '系统配置' }[id] || '其他');
|
||||
const groups = [...new Set(nav.map(([id]) => groupFor(id)))];
|
||||
const navHtml = groups.map(group => `<section class="portal-nav-group"><strong>${h(group)}</strong>${nav.filter(([id]) => groupFor(id) === group).map(([id, label, icon]) => `<button class="${page === id ? 'active' : ''}" data-route="${role}/${id}"><span>${icons[icon]}</span>${label}${role === 'admin' && ((id === 'candidates' && state.pageData?.metrics?.pendingCandidates) || (id === 'registrations' && state.pageData?.metrics?.pendingRegistrations) || (id === 'payments' && state.user?.adminLevel === 'class' && state.pageData?.metrics?.pendingPayments) || (id === 'flows' && state.pageData?.metrics?.pendingFlows)) ? '<em>待办</em>' : ''}</button>`).join('')}</section>`).join('');
|
||||
return `<div class="portal"><aside class="portal-sidebar" id="portalSidebar"><div class="portal-brand">${brand()}<button data-action="close-sidebar">×</button></div><p class="portal-role">${role === 'admin' ? `${adminTitle} · ${h(state.scopeLabel || '加载中')}` : roleName}</p><nav class="portal-nav-groups">${navHtml}</nav><div class="sidebar-help"><span>当前数据范围</span><strong>${h(role === 'admin' ? state.scopeLabel : '个人数据')}</strong><small>权限在服务端同步校验</small></div></aside><main class="portal-main"><header class="portal-topbar"><button class="sidebar-toggle" data-action="open-sidebar" aria-label="打开菜单">${icons.menu}</button><div><span>${roleName}</span><b>/</b><strong>${h(title)}</strong></div><div class="portal-user">${role === 'admin' ? `<button class="notification-button" data-route="admin/flows">${icons.bell}<i></i></button>` : ''}<span class="user-avatar">${h((state.user?.displayName || '用').slice(0, 1))}</span><span><strong>${h(state.user?.displayName)}</strong><small>${role === 'admin' ? adminTitle : `资料${statusLabels[state.profile?.status] || '未完善'}`}</small></span><button class="logout-button" data-action="logout" title="退出登录">${icons.logout}</button></div></header><section class="portal-content"><div class="portal-heading"><div><p class="overline">${role === 'admin' ? 'EXAM OPERATIONS' : 'CANDIDATE SERVICE'}</p><h1>${h(title)}</h1><p>${h(description)}</p></div>${portalHeadingAction(role, page)}</div>${content}</section></main></div>`;
|
||||
}
|
||||
|
||||
function portalHeadingAction(role, page) {
|
||||
@@ -54,6 +60,14 @@ export function createCandidateViews(context) {
|
||||
function loadingPanel() {
|
||||
return `<div class="loading-panel"><i></i><span>正在读取数据</span></div>`;
|
||||
}
|
||||
|
||||
function mountAdmissionProfileFields(profile = {}) {
|
||||
const actions = app.querySelector('.profile-form .form-actions');
|
||||
if (!actions || app.querySelector('[data-admission-profile-fields]')) return;
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const selectedCategory = specialtyCatalog.find(item => item.code === qualification.category);
|
||||
actions.insertAdjacentHTML('beforebegin', `<div class="form-section-title" data-admission-profile-fields><span>04</span><div><h2>中考招生资格</h2><p>特长资格按大类和小类登记,填志愿时系统只显示与本人资格相符的招生类别。</p></div></div><div class="form-grid specialty-qualification-grid"><label><span>特长生大类</span><select name="specialtyCategory" data-action="specialty-category"><option value="">无特长生资格</option>${specialtyCatalog.map(item => `<option value="${h(item.code)}" ${item.code === qualification.category ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长生小类</span><select name="specialtyType" data-specialty-type ${selectedCategory ? '' : 'disabled'}><option value="">${selectedCategory ? '请选择小类' : '请先选择大类'}</option>${(selectedCategory?.types || []).map(item => `<option value="${h(item.code)}" ${item.code === qualification.type ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>特长证明编号</span><input name="specialtyCertificate" value="${h(profile.specialtyCertificate || '')}" placeholder="证书或统一测试材料编号"></label><label class="wide-field"><span>政策资格说明</span><input name="policyEligibility" value="${h(profile.policyEligibility || '')}" placeholder="例如:指标生资格已核验"></label></div>`);
|
||||
}
|
||||
|
||||
function onboardingShell(stage, content) {
|
||||
const passwordDone = stage !== 'password';
|
||||
@@ -65,7 +79,7 @@ export function createCandidateViews(context) {
|
||||
}
|
||||
|
||||
async function renderCandidate(page) {
|
||||
if (state.user?.role !== 'candidate') return navigate('login');
|
||||
if (state.user?.role !== 'candidate') return requireLogin();
|
||||
app.classList.remove('admin-readable');
|
||||
if (state.user.mustChangePassword) {
|
||||
app.innerHTML = onboardingShell('password', passwordOnboardingForm());
|
||||
@@ -76,6 +90,7 @@ export function createCandidateViews(context) {
|
||||
const data = await api('/api/candidate/profile');
|
||||
state.pageData = data; state.profile = data.profile;
|
||||
app.innerHTML = onboardingShell('profile', candidateProfile(data, true));
|
||||
mountAdmissionProfileFields(data.profile);
|
||||
mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
|
||||
} catch (error) { renderError(error); }
|
||||
return;
|
||||
@@ -87,23 +102,24 @@ export function createCandidateViews(context) {
|
||||
registrations: ['我的报名', '查看已提交的考试、科目与审核进度。'],
|
||||
admit: ['准考证', '管理员生成后,可在规定下载时间内保存准考证。'],
|
||||
results: ['成绩查询', '仅显示考试中心已经正式发布的成绩。'],
|
||||
admissions: ['志愿填报与录取', '成绩发布后由本人填报志愿,并在这里查看投档与录取进度。'],
|
||||
notices: ['通知公告', '查看与报名、考试和成绩相关的最新消息。'],
|
||||
security: ['账户安全', '使用当前密码设置新的登录密码。']
|
||||
};
|
||||
if (!meta[page]) page = 'dashboard';
|
||||
app.innerHTML = portalShell('candidate', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : 'registrations';
|
||||
const data = page === 'notices' ? { notices: state.publicData.notices } : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
|
||||
const endpoint = page === 'dashboard' ? 'dashboard' : page === 'profile' ? 'profile' : page === 'exams' ? 'exams' : page === 'results' ? 'results' : page === 'admissions' ? 'admissions' : 'registrations';
|
||||
const data = page === 'notices' ? await api('/api/candidate/notices') : page === 'security' ? await api('/api/auth/totp') : await api(`/api/candidate/${endpoint}`);
|
||||
state.pageData = data;
|
||||
if (data.profile) state.profile = data.profile;
|
||||
const content = {
|
||||
dashboard: () => candidateDashboard(data), profile: () => candidateProfile(data), exams: () => candidateExams(data),
|
||||
registrations: () => candidateRegistrations(data.registrations), admit: () => candidateAdmit(data.registrations),
|
||||
results: () => candidateResults(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
|
||||
results: () => candidateResults(data), admissions: () => candidateAdmissions(data), notices: () => candidateNotices(data.notices), security: () => accountSecurity(data)
|
||||
}[page]();
|
||||
app.innerHTML = portalShell('candidate', page, content, ...meta[page]);
|
||||
if (page === 'profile') mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' });
|
||||
if (page === 'profile') { mountAdmissionProfileFields(data.profile); mountRegionSelects(app, data.profile, { className: 'region-selects wide-field' }); }
|
||||
} catch (error) { renderError(error); }
|
||||
}
|
||||
|
||||
@@ -177,11 +193,42 @@ export function createCandidateViews(context) {
|
||||
const lineState = item.qualified == null ? 'neutral' : item.qualified ? 'qualified' : 'unqualified';
|
||||
return `<article class="${lineState}"><div class="score-subject-head"><span>${h(item.subjectName)}</span><i>${item.qualified == null ? '不判定单科' : item.qualified ? '单科达线' : '单科未达线'}</i></div><strong>${h(item.score)}<small> / ${h(item.fullScore)}</small></strong><em>${h(item.grade)} · 第 ${h(item.rank)} / ${h(item.cohortSize)} 名 · 前 ${h(item.rankPercent)}%</em><div class="rank-rule-line"><span>本科排名</span><b>${h(item.passText || '不设单科线')}</b></div>${appealPanel}</article>`;
|
||||
}).join('');
|
||||
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;复议改分后只重新判断本人成绩所在排名区间。'}</p><strong>已发布 ${items.length} 科</strong></footer></section>`;
|
||||
const panel = `<section class="panel result-panel ${items[0].archivedAt ? 'archived' : ''}"><header><div><span>${h(items[0].examCode)}</span><h2>${h(examName)}</h2></div><small>${items[0].archivedAt ? `${formatDate(items[0].archivedAt, true)} 归档并锁定` : `最近发布 ${formatDate([...items].sort((a,b) => new Date(b.publishedAt) - new Date(a.publishedAt))[0].publishedAt, true)}`}</small></header><div class="result-summary ${summary?.qualified === true ? 'qualified' : summary?.qualified === false ? 'unqualified' : ''}"><span><small>当前总分</small><strong>${h(summary?.total ?? '—')}<em> / ${h(summary?.fullScore ?? '—')}</em></strong><i>科目等级按排名</i></span><span><small>特征分</small><strong>${h(summary?.featureScore ?? 0)}</strong><i>独立于考试科目</i></span><span><small>整场合格判定</small><strong>${h(stateText)}</strong><em>${h(detail)}</em></span><span><small>发布进度</small><strong>${h(summary?.publishedSubjects ?? items.length)}<em> / ${h(summary?.subjectCount ?? items.length)} 科</em></strong><i>${summary?.complete ? '成绩已出齐' : '持续发布中'}</i></span></div><div class="score-grid">${scores}</div><footer><p>${items[0].archivedAt ? '本场所有成绩已永久锁定,以下内容仅保留历史查阅。' : '等级按同场同科已发布成绩排名计算;特征分单独登记,不计入文化课总分。'}</p><div class="result-footer-actions"><strong>已发布 ${items.length} 科</strong><button class="solid-button" data-action="download-score-report" data-exam-id="${h(items[0].examId)}">下载 PDF 成绩单</button></div></footer></section>`;
|
||||
return items[0].archivedAt ? `<details class="candidate-archive-fold result-archive-fold"><summary><span><strong>${h(examName)}</strong><small>${h(items[0].examCode)} · ${items.length} 科成绩 · 已永久锁定</small></span><b>历史成绩</b></summary>${panel}</details>` : panel;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function candidateAdmissions(data) {
|
||||
const phaseLabels = { draft: '尚未开放', filling: '志愿填报中', closed: '填报已截止', matching: '正在投档', school_review: '招生学校审核中', reporting: '考生报到中', supplementary: '补录填报中', completed: '录取结束' };
|
||||
if (!data.admissions?.length) return emptyState('暂无志愿填报安排', '只有启用志愿功能且成绩已经发布的考试会显示在这里。', 'candidate/results', '查看成绩');
|
||||
const notificationCards = (data.notifications || []).map(notification => {
|
||||
const invalid = ['withdrawn', 'forfeited'].includes(notification.placementStatus);
|
||||
return `<article class="admission-notification ${invalid ? 'invalid' : ''}"><div class="admission-notification-mark"><span>ADMISSION</span><strong>${invalid ? '失' : '录'}</strong></div><div class="admission-notification-copy"><header><div><span>${invalid ? '录取状态已更新' : '录取结果已发布'}</span><h2>${h(notification.payload?.title || '录取结果通知')}</h2></div><time>${formatDate(notification.createdAt, true)}</time></header><p>${h(notification.payload?.message || '录取结果已经发布,请核对以下信息。')}</p><dl><div><dt>录取学校</dt><dd>${h(notification.schoolName || '招生学校')}</dd></div><div><dt>招生类别</dt><dd>${h(notification.categoryName || '以录取通知书为准')}</dd></div><div><dt>所属考试</dt><dd>${h(notification.examName || '—')}</dd></div>${notification.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(notification.noticeNumber)}</dd></div>` : ''}</dl></div><span class="admission-notification-status">${invalid ? '资格已失效' : '正式录取'}</span></article>`;
|
||||
}).join('');
|
||||
return `${notificationCards ? `<section class="admission-notification-stack" aria-label="录取结果通知">${notificationCards}</section>` : ''}<div class="admission-candidate-list">${data.admissions.map(item => {
|
||||
const choices = item.preference?.payload?.choices || [];
|
||||
const canFill = ['filling', 'supplementary'].includes(item.status) && item.totalScore != null && !item.preferenceLocked && item.supplementEligible !== false;
|
||||
const placementSchool = item.placementSchool?.name || item.plans.find(plan => plan.schoolId === item.placement?.schoolId)?.schoolName || '招生学校';
|
||||
const progressSteps = ['filling', 'closed', 'school_review', 'completed'];
|
||||
const progressIndex = item.status === 'supplementary' ? 1 : item.status === 'reporting' ? 3 : Math.max(0, progressSteps.indexOf(item.status));
|
||||
const indicatorChoice = choices.find(choice => choice.preferenceType === 'indicator') || {};
|
||||
const generalChoices = choices.filter(choice => choice.preferenceType !== 'indicator');
|
||||
const indicatorEligible = item.indicatorQualification?.payload?.eligible === true;
|
||||
const choiceRow = (choice, preferenceType, index) => {
|
||||
const eligiblePlans = item.plans.filter(plan => plan.categories.some(category => category.preferenceTypes?.includes(preferenceType)));
|
||||
const plan = eligiblePlans.find(entry => entry.schoolId === choice.schoolId);
|
||||
const categoryOptions = (plan?.categories || []).filter(category => category.preferenceTypes?.includes(preferenceType) && ((preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining) > 0 || category.code === choice.categoryCode));
|
||||
const disabled = preferenceType === 'indicator' && !indicatorEligible;
|
||||
return `<div class="preference-choice-row ${preferenceType}" data-preference-type="${preferenceType}"><b>${preferenceType === 'indicator' ? '指标' : index + 1}</b><label><span>${preferenceType === 'indicator' ? '指标分配志愿学校' : `普通志愿 ${index + 1} · 招生学校`}</span><select name="choiceSchool" data-action="preference-school" data-exam-id="${h(item.examId)}" ${disabled ? 'disabled' : ''}><option value="">${disabled ? '本场无指标分配资格' : '可不填'}</option>${eligiblePlans.map(entry => `<option value="${h(entry.schoolId)}" ${entry.schoolId === choice.schoolId ? 'selected' : ''}>${h(entry.schoolCode)} · ${h(entry.schoolName)}</option>`).join('')}</select></label><label><span>该校招生类别</span><select name="choiceCategory" ${plan && !disabled ? '' : 'disabled'}><option value="">${plan ? '请选择招生类别' : '请先按代码选择学校'}</option>${categoryOptions.map(category => `<option value="${h(category.code)}" ${category.code === choice.categoryCode ? 'selected' : ''}>${h(category.name)}${category.specialtyCategory ? `(${h(specialtyLabel(category.specialtyCategory, category.specialtyType))})` : ''} · 对应余 ${h(preferenceType === 'indicator' ? category.indicatorRemaining : category.generalRemaining)}</option>`).join('')}</select></label></div>`;
|
||||
};
|
||||
const choiceRows = choiceRow(indicatorChoice, 'indicator', 0) + Array.from({ length: Number(item.payload.maxChoices || 5) }, (_, index) => choiceRow(generalChoices[index] || {}, 'general', index)).join('');
|
||||
const lockedRows = choices.map((choice, index) => { const plan = item.plans.find(entry => entry.schoolId === choice.schoolId); const category = plan?.categories.find(entry => entry.code === choice.categoryCode); const schoolCode = choice.schoolCode || plan?.schoolCode || ''; const schoolName = choice.schoolName || plan?.schoolName || choice.schoolId; const categoryName = choice.categoryName || category?.name || choice.categoryCode; return `<span><b>${choice.preferenceType === 'indicator' ? '指标' : index + 1}</b><i><strong>${h(schoolName)}</strong><small>${h([schoolCode, categoryName].filter(Boolean).join(' · '))}</small></i></span>`; }).join('');
|
||||
const qualification = specialtyLabel(item.specialtyQualification?.category, item.specialtyQualification?.type) || '普通生';
|
||||
const indicatorText = !item.indicatorQualification ? '待生源校确认' : indicatorEligible ? '有指标分配资格' : '无指标分配资格';
|
||||
return `<section class="panel admission-candidate-card"><header><div><span>${h(item.exam.code)} · 第 ${h(item.payload.round || 1)} 轮</span><h2>${h(item.exam.name)}</h2></div>${badge(item.status)}</header><div class="admission-progress-track">${['填报志愿','志愿锁定','投档审核','录取结束'].map((label, index) => `<div class="${index < progressIndex ? 'done' : index === progressIndex ? 'current' : ''}"><i>${index < progressIndex ? '✓' : index + 1}</i><span>${label}</span></div>`).join('')}</div><div class="admission-score-strip"><span>本场总成绩</span><strong>${item.totalScore == null ? '成绩尚未完整发布' : `${h(item.totalScore)} 分`}</strong><span>特征分 <b>${h(item.featureScore || 0)}</b></span><span>特长类型 <b>${h(qualification)}</b></span><span>指标资格 <b>${h(indicatorText)}</b></span><em>${h(phaseLabels[item.status] || item.status)}</em></div><p class="admission-progress-copy">${h(item.payload.progress || '等待录取工作更新')}</p>${item.placement ? `<div class="admission-result-banner ${h(item.placement.status)}"><span>当前结果</span><strong>${h(placementSchool)} · ${h(item.placement.payload.categoryName)}</strong>${item.noticeNumber ? `<small class="mono">录取通知书编号:${h(item.noticeNumber)}</small>` : ''}<small>${item.placement.status === 'final' ? '已正式录取,可下载带防伪二维码的正式录取通知书' : item.placement.status === 'withdrawal_pending' ? '招生学校申请退档,等待超级管理员审核' : '材料已发送招生学校审核'}</small>${item.placement.status === 'final' ? `<button class="solid-button" data-action="download-admission-notice" data-exam-id="${h(item.examId)}">下载录取通知书 PDF</button>` : ''}</div>` : ''}${canFill ? `<form class="preference-form" data-form="volunteer-preference"><input type="hidden" name="examId" value="${h(item.examId)}"><div class="preference-form-head"><div><strong>1 个指标分配志愿 + ${h(item.payload.maxChoices)} 个普通志愿</strong><small>指标栏仅在生源校确认有资格时开放;每次保存计为一次提交。</small></div><span>已提交 ${h(item.submissionCount)} / ${h(item.maxSubmissions)} 次</span></div><div class="preference-choice-list">${choiceRows}</div><button class="solid-button" type="submit">保存本人志愿(剩余 ${h(item.remainingSubmissions)} 次)</button></form>` : choices.length ? `<div class="locked-preferences"><strong>${item.preferenceLocked ? `达到 ${h(item.maxSubmissions)} 次上限,志愿已自动锁定` : '已锁定志愿顺序'}</strong>${lockedRows}</div>` : `<div class="read-only-callout ${item.supplementEligible === false ? 'warning' : ''}">${h(item.supplementIneligibilityReason || (item.preferenceLocked ? '志愿提交次数已用完,系统已自动锁定。' : '当前不能填报:请等待成绩完整发布或志愿填报窗口开放。'))}</div>`}</section>`;
|
||||
}).join('')}</div>`;
|
||||
}
|
||||
|
||||
function candidateNotices(notices) {
|
||||
return `<section class="panel notice-center"><div class="notice-center-list">${notices.map(notice => `<button data-action="open-notice" data-id="${h(notice.id)}"><time><strong>${new Date(notice.publishAt).getDate()}</strong><span>${new Date(notice.publishAt).toLocaleString('zh-CN',{month:'short'})}</span></time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${notice.pinned ? '<i>置顶</i>' : ''}${icons.arrow}</button>`).join('')}</div></section>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
const A4 = { width: 2480, height: 3508 };
|
||||
|
||||
function roundRect(ctx, x, y, width, height, radius = 18) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, width, height, radius);
|
||||
}
|
||||
|
||||
function fitText(ctx, text, maxWidth, initialSize, weight = 400) {
|
||||
let size = initialSize;
|
||||
do {
|
||||
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||
if (ctx.measureText(String(text)).width <= maxWidth) return size;
|
||||
size -= 2;
|
||||
} while (size > 24);
|
||||
return size;
|
||||
}
|
||||
|
||||
function drawText(ctx, text, x, y, { size = 36, weight = 400, color = '#17213f', align = 'left', maxWidth } = {}) {
|
||||
if (maxWidth) size = fitText(ctx, text, maxWidth, size, weight);
|
||||
ctx.font = `${weight} ${size}px "Microsoft YaHei", "PingFang SC", sans-serif`;
|
||||
ctx.fillStyle = color;
|
||||
ctx.textAlign = align;
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
ctx.fillText(String(text ?? ''), x, y, maxWidth);
|
||||
}
|
||||
|
||||
function jpegPdf(dataUrl, width, height) {
|
||||
const binary = atob(dataUrl.split(',')[1]);
|
||||
const image = Uint8Array.from(binary, char => char.charCodeAt(0));
|
||||
const encoder = new TextEncoder();
|
||||
const chunks = [];
|
||||
const offsets = [0];
|
||||
let length = 0;
|
||||
const add = value => { const bytes = typeof value === 'string' ? encoder.encode(value) : value; chunks.push(bytes); length += bytes.length; };
|
||||
add('%PDF-1.4\n%\xE2\xE3\xCF\xD3\n');
|
||||
const object = (id, body) => { offsets[id] = length; add(`${id} 0 obj\n${body}\nendobj\n`); };
|
||||
object(1, '<< /Type /Catalog /Pages 2 0 R >>');
|
||||
object(2, '<< /Type /Pages /Kids [3 0 R] /Count 1 >>');
|
||||
object(3, '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595.28 841.89] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>');
|
||||
offsets[4] = length;
|
||||
add(`4 0 obj\n<< /Type /XObject /Subtype /Image /Width ${width} /Height ${height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ${image.length} >>\nstream\n`);
|
||||
add(image); add('\nendstream\nendobj\n');
|
||||
const stream = 'q\n595.28 0 0 841.89 0 0 cm\n/Im0 Do\nQ';
|
||||
object(5, `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
|
||||
const xref = length;
|
||||
add(`xref\n0 6\n0000000000 65535 f \n`);
|
||||
for (let id = 1; id <= 5; id += 1) add(`${String(offsets[id]).padStart(10, '0')} 00000 n \n`);
|
||||
add(`trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF`);
|
||||
const output = new Uint8Array(length);
|
||||
let cursor = 0;
|
||||
for (const chunk of chunks) { output.set(chunk, cursor); cursor += chunk.length; }
|
||||
return new Blob([output], { type: 'application/pdf' });
|
||||
}
|
||||
|
||||
function downloadCanvasPdf(canvas, filename) {
|
||||
const blob = jpegPdf(canvas.toDataURL('image/jpeg', .94), canvas.width, canvas.height);
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = filename.replace(/[\\/:*?"<>|]/g, '-');
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(link.href), 3000);
|
||||
}
|
||||
|
||||
function loadImage(source) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('防伪二维码加载失败'));
|
||||
image.src = source;
|
||||
});
|
||||
}
|
||||
|
||||
async function drawQrCode(ctx, dataUrl, x, y, size) {
|
||||
if (!dataUrl) return;
|
||||
const image = await loadImage(dataUrl);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x - 10, y - 10, size + 20, size + 20);
|
||||
ctx.drawImage(image, x, y, size, size);
|
||||
}
|
||||
|
||||
export async function downloadScoreReport({ organization, candidate, exam, results, summary, verificationCode, verificationUrl, verificationQr }) {
|
||||
const canvas = document.createElement('canvas');
|
||||
Object.assign(canvas, A4);
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#f5f8fb'; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#14234b'; ctx.fillRect(0, 0, canvas.width, 270);
|
||||
ctx.fillStyle = '#c94b45'; ctx.fillRect(170, 234, 250, 12);
|
||||
drawText(ctx, organization?.name || '考试服务平台', 170, 112, { size: 42, weight: 700, color: '#ffffff' });
|
||||
drawText(ctx, '考 生 成 绩 单', 170, 205, { size: 76, weight: 700, color: '#ffffff' });
|
||||
drawText(ctx, exam.code, 2300, 115, { size: 32, weight: 600, color: '#9eadce', align: 'right' });
|
||||
drawText(ctx, exam.name, 2300, 192, { size: 38, weight: 500, color: '#ffffff', align: 'right', maxWidth: 1120 });
|
||||
|
||||
const box = (x, y, w, h, fill = '#ffffff') => { roundRect(ctx, x, y, w, h, 22); ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = '#dce4ec'; ctx.lineWidth = 2; ctx.stroke(); };
|
||||
box(170, 330, 2140, 300);
|
||||
const meta = [['姓名', candidate.name], ['报名号', candidate.candidateNumber], ['考试', exam.name], ['发布时间', summary?.publishedAt ? new Date(summary.publishedAt).toLocaleString('zh-CN') : '以系统记录为准']];
|
||||
meta.forEach(([label, value], index) => {
|
||||
const x = 225 + (index % 2) * 1050, y = 420 + Math.floor(index / 2) * 115;
|
||||
drawText(ctx, label, x, y, { size: 28, color: '#77839a' });
|
||||
drawText(ctx, value, x + 155, y, { size: 34, weight: 600, maxWidth: 800 });
|
||||
});
|
||||
box(170, 690, 2140, 320, '#eaf4f1');
|
||||
const totals = [['总分', `${summary?.total ?? '—'} / ${summary?.fullScore ?? '—'}`], ['特征分', summary?.featureScore ?? 0], ['合格结论', summary?.qualified == null ? '不判定' : summary.qualified ? '合格' : '未合格'], ['发布进度', `${summary?.publishedSubjects ?? results.length} / ${summary?.subjectCount ?? results.length} 科`]];
|
||||
totals.forEach(([label, value], index) => {
|
||||
const x = 235 + index * 520;
|
||||
drawText(ctx, label, x, 790, { size: 28, color: '#5d766f' });
|
||||
drawText(ctx, value, x, 900, { size: 48, weight: 700, color: '#173b35', maxWidth: 440 });
|
||||
});
|
||||
|
||||
drawText(ctx, '科目成绩与等级排名', 170, 1115, { size: 42, weight: 700 });
|
||||
drawText(ctx, '等级与排名均以系统正式发布数据为准', 2310, 1115, { size: 25, color: '#7b8598', align: 'right' });
|
||||
const cols = 2, gap = 34, cardW = (2140 - gap) / cols, cardH = Math.min(300, Math.max(220, (1760 - Math.ceil(results.length / cols) * 20) / Math.ceil(results.length / cols)));
|
||||
results.forEach((item, index) => {
|
||||
const col = index % cols, row = Math.floor(index / cols), x = 170 + col * (cardW + gap), y = 1180 + row * (cardH + 20);
|
||||
box(x, y, cardW, cardH);
|
||||
drawText(ctx, item.subjectName, x + 42, y + 72, { size: 38, weight: 700, maxWidth: cardW - 450 });
|
||||
drawText(ctx, item.qualified == null ? '不判定' : item.qualified ? '达线' : '未达线', x + cardW - 42, y + 70, { size: 27, weight: 600, color: item.qualified === false ? '#b43d38' : '#2d7462', align: 'right' });
|
||||
drawText(ctx, item.score, x + 42, y + 158, { size: 62, weight: 700 });
|
||||
drawText(ctx, `/ ${item.fullScore}`, x + 190, y + 156, { size: 28, color: '#8993a6' });
|
||||
drawText(ctx, `${item.grade} · 第 ${item.rank} / ${item.cohortSize} 名 · 前 ${item.rankPercent}%`, x + 42, y + 220, { size: 28, color: '#455068', maxWidth: cardW - 84 });
|
||||
drawText(ctx, item.passText || '不设单科线', x + 42, y + cardH - 30, { size: 24, color: '#7c8798', maxWidth: cardW - 84 });
|
||||
});
|
||||
|
||||
const footerY = 3100;
|
||||
box(170, footerY, 2140, 235, '#f0f3f7');
|
||||
drawText(ctx, '防伪查询码', 225, footerY + 70, { size: 28, color: '#6f7a8e' });
|
||||
drawText(ctx, verificationCode, 225, footerY + 135, { size: 38, weight: 700, color: '#17213f' });
|
||||
drawText(ctx, '登录考试服务平台,在“文书防伪查询”中输入本码核验。', 225, footerY + 188, { size: 24, color: '#667085' });
|
||||
drawText(ctx, verificationUrl, 2035, footerY + 135, { size: 21, color: '#53627b', align: 'right', maxWidth: 820 });
|
||||
await drawQrCode(ctx, verificationQr, 2075, footerY + 26, 180);
|
||||
drawText(ctx, `生成时间 ${new Date().toLocaleString('zh-CN')}`, 2310, 3435, { size: 22, color: '#8a94a6', align: 'right' });
|
||||
downloadCanvasPdf(canvas, `${exam.name}-${candidate.name}-成绩单.pdf`);
|
||||
}
|
||||
|
||||
export async function downloadAdmissionNotice({ organization, candidate, exam, placement, school, template, verificationCode, verificationUrl, verificationQr, noticeNumber }) {
|
||||
const canvas = document.createElement('canvas'); Object.assign(canvas, A4);
|
||||
const ctx = canvas.getContext('2d');
|
||||
const primary = template.primaryColor || '#8d2028', accent = template.accentColor || '#c9a45b';
|
||||
ctx.fillStyle = '#fffdf8'; ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = primary; ctx.lineWidth = 10; ctx.strokeRect(90, 90, 2300, 3328);
|
||||
ctx.strokeStyle = accent; ctx.lineWidth = 3; ctx.strokeRect(120, 120, 2240, 3268);
|
||||
drawText(ctx, template.eyebrow || 'ADMISSION NOTICE', 1240, 350, { size: 30, weight: 600, color: accent, align: 'center' });
|
||||
drawText(ctx, template.title || '录 取 通 知 书', 1240, 560, { size: 96, weight: 700, color: primary, align: 'center', maxWidth: 1950 });
|
||||
drawText(ctx, school.name, 1240, 700, { size: 42, weight: 600, align: 'center', maxWidth: 1900 });
|
||||
drawText(ctx, `通知书编号:${noticeNumber || placement.payload.noticeNumber || '—'}`, 2180, 835, { size: 27, weight: 600, color: '#655d53', align: 'right', maxWidth: 1250 });
|
||||
drawText(ctx, `${candidate.name} 同学:`, 300, 1040, { size: 48, weight: 700 });
|
||||
const body = (template.body || '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。')
|
||||
.replaceAll('{{考生姓名}}', candidate.name).replaceAll('{{考试名称}}', exam.name).replaceAll('{{录取学校}}', school.name).replaceAll('{{录取类别}}', placement.payload.categoryName || '招生类别');
|
||||
const lines = [];
|
||||
for (const paragraph of body.split(/\n+/)) {
|
||||
let line = '';
|
||||
for (const char of paragraph) {
|
||||
ctx.font = '400 42px "Microsoft YaHei", sans-serif';
|
||||
if (ctx.measureText(line + char).width > 1840) { lines.push(line); line = char; } else line += char;
|
||||
}
|
||||
if (line) lines.push(line); lines.push('');
|
||||
}
|
||||
lines.slice(0, 13).forEach((line, index) => drawText(ctx, line, 320, 1210 + index * 82, { size: 42, color: '#332f2c' }));
|
||||
drawText(ctx, template.footer || '请妥善保管本通知书,报到时出示。', 300, 2550, { size: 32, color: '#6c6257', maxWidth: 1700 });
|
||||
drawText(ctx, school.name, 2080, 2750, { size: 38, weight: 700, color: primary, align: 'right' });
|
||||
drawText(ctx, new Date().toLocaleDateString('zh-CN'), 2080, 2820, { size: 30, color: '#5f5951', align: 'right' });
|
||||
roundRect(ctx, 240, 3040, 2000, 210, 20); ctx.fillStyle = '#f4efe5'; ctx.fill();
|
||||
drawText(ctx, '防伪查询码', 300, 3115, { size: 26, color: '#756b5e' });
|
||||
drawText(ctx, verificationCode, 300, 3185, { size: 35, weight: 700 });
|
||||
drawText(ctx, verificationUrl, 1940, 3185, { size: 20, color: '#71695f', align: 'right', maxWidth: 820 });
|
||||
await drawQrCode(ctx, verificationQr, 1995, 3055, 175);
|
||||
drawText(ctx, organization?.name || '考试服务平台', 1240, 3380, { size: 23, color: '#8a8177', align: 'center' });
|
||||
downloadCanvasPdf(canvas, `${school.name}-${candidate.name}-录取通知书.pdf`);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { filterTableItems } from './table-state.mjs';
|
||||
|
||||
export function createPublicViews(context) {
|
||||
const {
|
||||
state,
|
||||
@@ -20,7 +22,7 @@ export function createPublicViews(context) {
|
||||
}
|
||||
|
||||
function publicHeader() {
|
||||
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#home-notices" data-action="scroll-to" data-target="home-notices">通知公告</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
return `<header class="public-header"><div class="public-nav">${brand()}<nav><a href="#home" data-route="home">首页</a><a href="#home-exams" data-action="scroll-to" data-target="home-exams">考试报名</a><a href="#notices" data-route="notices">通知公告</a><a href="#verify" data-route="verify">文书防伪查询</a><a href="#service-flow" data-action="scroll-to" data-target="service-flow">办事指南</a></nav><div class="nav-actions">${state.user ? `<button class="text-button" data-route="${state.user.role}/dashboard">进入${state.user.role === 'admin' ? '管理后台' : state.user.role === 'admission_school' ? '招生学校' : '考生中心'}</button><button class="solid-button" data-action="logout">退出</button>` : `<button class="text-button" data-route="login">登录</button><button class="solid-button" data-route="register">考生注册</button>`}<button class="mobile-menu" data-action="toggle-public-nav" aria-label="打开导航">${icons.menu}</button></div></div></header>`;
|
||||
}
|
||||
|
||||
function renderHome() {
|
||||
@@ -32,15 +34,90 @@ export function createPublicViews(context) {
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main">
|
||||
<section class="hero">
|
||||
<div class="hero-grid">
|
||||
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-action="open-notice" data-id="${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
|
||||
<div class="hero-copy"><div class="notice-ticker"><span>最新</span><button data-route="notice/${h(topNotice?.id)}">${h(topNotice?.title || '欢迎使用衡准考试服务平台')}</button></div><p class="overline">${h(siteCopy.heroEyebrow || 'EXAMINATION SERVICE')}</p><h1>${h(siteCopy.heroTitle || '一个报名号,')}<br><em>${h(siteCopy.heroHighlight || '贯穿每一次考试。')}</em></h1><p class="hero-lead">${h(siteCopy.heroDescription || '')}</p><div class="hero-actions">${state.user?.role === 'candidate' ? `<button class="solid-button large" data-route="candidate/dashboard">进入考生中心 ${icons.arrow}</button>` : state.publicData.selfRegistrationEnabled ? `<button class="solid-button large" data-route="register">申请报名号 ${icons.arrow}</button>` : `<button class="solid-button large" data-route="login">使用报名号登录 ${icons.arrow}</button>`}<button class="ghost-button large" data-action="scroll-to" data-target="home-exams">查看开放考试</button></div><div class="hero-stats"><div><strong>${h(stats.candidates || 0)}</strong><span>在册考生</span></div><div><strong>${h(stats.registrations || 0)}</strong><span>报名记录</span></div><div><strong>${h(stats.exams || 0)}</strong><span>开放考试</span></div></div></div>
|
||||
${featured ? renderHeroTicket(featured) : '<div class="hero-ticket empty-state">暂无开放考试</div>'}
|
||||
</div>
|
||||
</section>
|
||||
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>报名、考试、准考证与成绩发布的重要时间,请以平台通知为准。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-action="open-notice" data-id="${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div></div></section>
|
||||
<section class="content-section" id="home-notices"><div class="section-heading"><div><p class="overline">NOTICE BOARD</p><h2>通知公告</h2></div><p>招生录取公示已纳入通知公告,可按类别统一查询。</p></div><div class="notice-layout"><article class="featured-notice">${topNotice ? `<span>${h(topNotice.category)}</span><h3>${h(topNotice.title)}</h3><p>${h(topNotice.summary)}</p><footer><time>${formatDate(topNotice.publishAt)}</time><button data-route="notice/${h(topNotice.id)}">阅读通知 ${icons.arrow}</button></footer>` : '<p>暂无通知</p>'}</article><div class="notice-list">${notices.slice(1, 5).map(renderNoticeRow).join('') || '<div class="empty-state">暂无更多通知</div>'}</div><button class="notice-archive-link" data-route="notices">查看全部通知与录取公示 ${icons.arrow}</button></div></section>
|
||||
<section class="content-section exam-section" id="home-exams"><div class="section-heading"><div><p class="overline">OPEN EXAMINATIONS</p><h2>考试报名</h2></div><p>登录后选择考试,并按实际需要勾选报考科目。</p></div><div class="public-exam-grid">${exams.map(renderPublicExam).join('') || '<div class="empty-state">当前没有已发布的考试</div>'}</div></section>
|
||||
<section class="service-flow" id="service-flow"><div class="section-heading light"><div><p class="overline">SERVICE FLOW</p><h2>报名号是唯一账户</h2></div><p>报名号不会随考试改变,每场考试只新增一条报名记录。</p></div><div class="flow-track">${[['01','领取报名号','学校创建账户并下发初始密码。'],['02','修改初始密码','首次登录必须设置自己的新密码。'],['03','补全个人信息','填写籍贯、住址、手机、邮箱和班级等资料。'],['04','选择考试科目','资料审核通过后自主选择考试。'],['05','下载准考证与查分','继续使用同一报名号办理后续事项。']].map(item => `<article><span>${item[0]}</span><h3>${item[1]}</h3><p>${item[2]}</p></article>`).join('')}</div></section>
|
||||
</main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p>${organization.address || organization.email ? `<p class="public-contact-detail">${[organization.address, organization.email].filter(Boolean).map(h).join(' · ')}</p>` : ''}</div><span>${h(siteCopy.footerNotice || '')}</span></footer>`;
|
||||
}
|
||||
|
||||
function noticeDocuments(data = state.publicAnnouncements) {
|
||||
const ordinary = (state.publicData.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
|
||||
const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `共 ${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` }));
|
||||
const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` }));
|
||||
const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: item.round ? `第 ${item.round} 轮录取名单` : '最终录取名单', title: item.title || `${item.examName}最终录取名单`, summary: `共 ${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` }));
|
||||
const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
|
||||
const reports = (data.reports || []).map(item => ({ ...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示', subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况', title: item.title, summary: item.summary }));
|
||||
return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
||||
}
|
||||
|
||||
function publicPaged(items, key, pageSize = 50) {
|
||||
const filtered = filterTableItems(state, items, key);
|
||||
state.tablePages ||= {};
|
||||
const current = state.tablePages[key] || { page: 1, pageSize };
|
||||
const size = [20, 50, 100].includes(Number(current.pageSize)) ? Number(current.pageSize) : pageSize;
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / size));
|
||||
const page = Math.min(totalPages, Math.max(1, Number(current.page || 1)));
|
||||
state.tablePages[key] = { page, pageSize: size };
|
||||
return { items: filtered.slice((page - 1) * size, page * size), total: filtered.length, totalPages, page, pageSize: size, key };
|
||||
}
|
||||
|
||||
function publicPagination(meta) {
|
||||
if (!meta || meta.total <= meta.pageSize) return '';
|
||||
const start = (meta.page - 1) * meta.pageSize + 1;
|
||||
const end = Math.min(meta.total, meta.page * meta.pageSize);
|
||||
return `<nav class="table-pagination" aria-label="列表分页"><span>第 ${start}—${end} 条,共 ${meta.total} 条</span><div><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page - 1}" ${meta.page === 1 ? 'disabled' : ''}>上一页</button><button type="button" class="active" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page}">${meta.page}</button><button type="button" data-action="table-page" data-table-key="${h(meta.key)}" data-page="${meta.page + 1}" ${meta.page === meta.totalPages ? 'disabled' : ''}>下一页</button><label>每页 <select data-action="table-page-size" data-table-key="${h(meta.key)}">${[20, 50, 100].map(size => `<option value="${size}" ${size === meta.pageSize ? 'selected' : ''}>${size}</option>`).join('')}</select> 条</label></div></nav>`;
|
||||
}
|
||||
|
||||
function renderPublicQualification(document) {
|
||||
const key = `publicQualification-${document.documentId}`;
|
||||
const page = publicPaged(document.rows.map(row => ({ ...row, status: row.eligible ? 'eligible' : 'ineligible' })), key);
|
||||
return `<p class="document-lead">本公示由生源校完成全部考生资格确认后自动生成。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名或特长类型"></label><div class="filter-pills"><button class="active" data-action="status-filter" data-target="${h(key)}" data-status="all">全部</button><button data-action="status-filter" data-target="${h(key)}" data-status="eligible">有资格</button><button data-action="status-filter" data-target="${h(key)}" data-status="ineligible">无资格</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>指标分配资格</th><th>特长类型</th></tr></thead><tbody>${page.items.map(row => `<tr data-status="${h(row.status)}"><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td><span class="qualification-result ${row.eligible ? 'eligible' : ''}">${row.eligible ? '有' : '无'}</span></td><td>${h(row.specialtyLabel || '普通生')}</td></tr>`).join('') || '<tr><td colspan="4" class="empty-state">没有符合条件的资格记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
|
||||
}
|
||||
|
||||
function renderPublicAdmission(document) {
|
||||
const key = `publicAdmission-${document.documentId}`;
|
||||
const page = publicPaged(document.rows, key);
|
||||
const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))];
|
||||
const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))];
|
||||
return `<p class="document-lead">${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
|
||||
}
|
||||
|
||||
function renderDocumentBody(document) {
|
||||
if (document.documentType === 'notice') return `<article class="notice-document-content">${document.contentHtml || `<p>${h(document.content || '').replace(/\r?\n/g, '</p><p>')}</p>`}</article>`;
|
||||
if (document.documentType === 'plan') return `<p class="document-lead">招生计划经考试中心审核通过后由系统自动公示。计划人数包含普通计划与定向指标,具体执行以本公示为准。</p><div class="table-scroll"><table><thead><tr><th>类别代码</th><th>招生类别</th><th>计划人数</th><th>其中定向指标</th><th>指标分配</th></tr></thead><tbody>${document.rows.map(row => `<tr><td class="mono">${h(row.code)}</td><td><strong>${h(row.name)}</strong><small>${h(row.specialtyLabel || '普通 / 政策类')}</small></td><td><strong>${h(row.quota)} 人</strong></td><td>${h(row.indicatorQuota || 0)} 人</td><td>${row.indicatorAllocations?.length ? row.indicatorAllocations.map(allocation => `<span>${h(allocation.sourceSchoolName)} ${h(allocation.quota)} 人</span>`).join('<br>') : '无定向指标'}</td></tr>`).join('')}</tbody></table></div>${document.note ? `<p class="document-note"><strong>计划说明:</strong>${h(document.note)}</p>` : ''}`;
|
||||
if (document.documentType === 'qualification') return renderPublicQualification(document);
|
||||
if (document.documentType === 'admission') return renderPublicAdmission(document);
|
||||
if (document.documentType === 'reporting') {
|
||||
const stats = document.statistics || {};
|
||||
return `<p class="document-lead">本公示由招生学校提交报到情况和补录决定,经超级管理员审批后自动发布。</p><div class="reporting-public-stats"><article><span>招生计划</span><strong>${h(stats.totalQuota || 0)}</strong><small>人</small></article><article><span>正式录取</span><strong>${h(stats.finalCount || 0)}</strong><small>人</small></article><article><span>已报到</span><strong>${h(stats.reportedCount || 0)}</strong><small>人</small></article><article><span>计划完成率</span><strong>${h(stats.reportingRate || 0)}%</strong><small>按实际报到</small></article></div><p class="document-note"><strong>学校说明:</strong>${h(document.decisionNote || (document.supplementDecision === 'supplement' ? '学校申请补录并已获批准。' : '本轮不进行补录。'))}</p>`;
|
||||
}
|
||||
return `<p class="document-lead">录取分数线为对应学校、招生类别最终录取考生的最低总成绩。</p><div class="table-scroll"><table><thead><tr><th>招生学校</th><th>招生类别</th><th>计划数</th><th>录取数</th><th>最高分</th><th>录取分数线</th></tr></thead><tbody>${document.rows.map(row => `<tr><td><strong>${h(row.schoolName)}</strong></td><td>${h(row.categoryName)}</td><td>${h(row.planQuota)}</td><td>${h(row.admittedCount)}</td><td>${h(row.highestScore)}</td><td><strong class="cutoff-score">${h(row.cutoffScore)}</strong></td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function renderNoticeCenter(data = state.publicAnnouncements, selectedId = '') {
|
||||
app.classList.remove('admin-readable');
|
||||
const documents = noticeDocuments(data);
|
||||
const selected = documents.find(item => item.documentId === selectedId);
|
||||
const organization = state.publicData.organization || {};
|
||||
if (selected) {
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main notice-document-page"><div class="notice-breadcrumb"><button data-route="notices">通知公告</button><span>/</span><strong>${h(selected.subtype)}</strong></div><article class="notice-document"><header><span>${h(selected.category)} · ${h(selected.subtype)}</span><h1>${h(selected.title)}</h1><p>${formatDate(selected.publishedAt, true)}${selected.author ? ` · ${h(selected.author)}` : ''}</p></header><section>${renderDocumentBody(selected)}</section><footer><button class="ghost-button" data-route="notices">返回通知公告列表</button></footer></article></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>公开信息以本页面正式发布内容为准</span></footer>`;
|
||||
return;
|
||||
}
|
||||
const categories = ['全部', ...new Set(documents.map(item => item.category || '通知公告'))];
|
||||
const category = categories.includes(state.noticeCategory) ? state.noticeCategory : '全部';
|
||||
const searched = filterTableItems(state, documents, 'publicNoticeDirectory');
|
||||
const filtered = category === '全部' ? searched : searched.filter(item => item.category === category);
|
||||
const pageSize = 8;
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||
const page = Math.min(totalPages, Math.max(1, Number(state.noticePage || 1)));
|
||||
state.noticeCategory = category; state.noticePage = page;
|
||||
const pageRows = filtered.slice((page - 1) * pageSize, page * pageSize);
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main notice-center-page"><section class="notice-center-hero"><div><p class="overline">PUBLIC NOTICE ARCHIVE</p><h1>通知公告</h1><p>考试通知、成绩发布与招生录取公示统一归档,按发布时间倒序公开。</p></div><strong>${h(documents.length)}<small>份公开文件</small></strong></section><section class="notice-center-shell"><nav class="notice-category-nav">${categories.map(item => `<button class="${item === category ? 'active' : ''}" data-action="notice-category" data-category="${h(item)}">${h(item)}<span>${item === '全部' ? documents.length : documents.filter(document => document.category === item).length}</span></button>`).join('')}</nav><div class="notice-directory"><header><div><strong>${h(category)}</strong><span>第 ${h(page)} / ${h(totalPages)} 页</span></div><small>共 ${h(filtered.length)} 条</small></header><div class="data-toolbar notice-directory-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="publicNoticeDirectory" placeholder="搜索全部通知、公示标题、分类或摘要"></label><button class="row-action" data-action="clear-table-filters" data-target="publicNoticeDirectory">清除搜索</button></div><div class="notice-directory-list">${pageRows.map(item => `<button data-route="notice/${h(item.documentId)}"><time><strong>${String(new Date(item.publishedAt).getDate()).padStart(2,'0')}</strong><span>${new Date(item.publishedAt).toLocaleDateString('zh-CN',{year:'numeric',month:'2-digit'}).replace('/','.')}</span></time><span class="notice-directory-copy"><em>${h(item.subtype)}</em><strong>${h(item.title)}</strong><small>${h(item.summary || '')}</small></span><span class="notice-directory-arrow">${icons.arrow}</span></button>`).join('') || '<div class="empty-state">当前分类暂无公开信息</div>'}</div><footer class="notice-pagination"><button data-action="notice-page" data-page="${page - 1}" ${page <= 1 ? 'disabled' : ''}>上一页</button>${Array.from({length:totalPages},(_,index) => index + 1).map(value => `<button class="${value === page ? 'active' : ''}" data-action="notice-page" data-page="${value}">${value}</button>`).join('')}<button data-action="notice-page" data-page="${page + 1}" ${page >= totalPages ? 'disabled' : ''}>下一页</button></footer></div></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>录取公示为通知公告中的公开类别</span></footer>`;
|
||||
}
|
||||
|
||||
function renderHeroTicket(exam) {
|
||||
const status = exam.registrationState;
|
||||
@@ -48,7 +125,7 @@ export function createPublicViews(context) {
|
||||
}
|
||||
|
||||
function renderNoticeRow(notice) {
|
||||
return `<button class="notice-row" data-action="open-notice" data-id="${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
|
||||
return `<button class="notice-row" data-route="notice/${h(notice.id)}"><time>${formatDate(notice.publishAt)}</time><span><em>${h(notice.category)}</em><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></span>${icons.arrow}</button>`;
|
||||
}
|
||||
|
||||
function renderPublicExam(exam) {
|
||||
@@ -59,7 +136,16 @@ export function createPublicViews(context) {
|
||||
app.classList.remove('admin-readable');
|
||||
const login = kind === 'login';
|
||||
const selfRegistration = state.publicData.selfRegistrationEnabled;
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
|
||||
const authNotice = login && state.authNotice ? `<div class="auth-session-notice" role="status"><strong>需要重新登录</strong><span>${h(state.authNotice)}</span></div>` : '';
|
||||
app.innerHTML = `<main class="auth-page"><section class="auth-story"><div>${brand()}<p class="overline">CANDIDATE SERVICE</p><h1>${login ? '凭一个号码,' : '自主申请,'}<br><em>${login ? '办理每一次考试。' : '领取固定报名号。'}</em></h1><p>报名号就是考生账户,不因考试、科目或年度报名而改变。</p></div><div class="auth-quote"><span>首次登录顺序</span><p>修改初始密码 → 补全个人信息 → 等待资料审核。</p></div></section><section class="auth-panel"><button class="back-link" data-route="home">← 返回首页</button><div class="auth-card"><p class="overline">${login ? 'ACCOUNT LOGIN' : 'CANDIDATE NUMBER'}</p><h2>${login ? '报名号登录' : '自主申请报名号'}</h2><p>${login ? '考生填写报名号和密码;管理员继续使用管理账号。' : selfRegistration ? '提交基础学籍范围后,系统生成一个长期使用的报名号。' : '当前未开放自主注册,请联系学校领取报名号和初始密码。'}</p>${authNotice}${login ? loginForm() : selfRegistration ? registerForm() : '<div class="registration-closed"><strong>自主注册已关闭</strong><span>学校管理员会为考生创建账户并下发初始密码。</span><button class="solid-button" data-route="login">返回报名号登录</button></div>'}${login && selfRegistration ? `<div class="auth-switch">还没有报名号?<button data-route="register">自主申请</button></div>` : !login ? '<div class="auth-switch">已经有报名号?<button data-route="login">返回登录</button></div>' : ''}</div></section></main>`;
|
||||
}
|
||||
|
||||
function renderVerification(code = '', result = null, error = '') {
|
||||
app.classList.remove('admin-readable');
|
||||
const organization = state.publicData.organization || {};
|
||||
const document = result?.document;
|
||||
const outcome = document ? `<section class="verification-result verified"><span>✓</span><div><small>VERIFIED DOCUMENT</small><h2>文书真实有效</h2><p>该查询码由系统签发,当前数据与签发记录一致。</p></div><dl><div><dt>文书类型</dt><dd>${h(document.typeName)}</dd></div>${document.noticeNumber ? `<div><dt>通知书编号</dt><dd class="mono">${h(document.noticeNumber)}</dd></div>` : ''}<div><dt>考生</dt><dd>${h(document.candidateName)}</dd></div><div><dt>考试</dt><dd>${h(document.examName)}</dd></div>${document.schoolName ? `<div><dt>录取学校</dt><dd>${h(document.schoolName)}</dd></div>` : ''}${document.categoryName ? `<div><dt>录取类别</dt><dd>${h(document.categoryName)}</dd></div>` : ''}${document.totalScore != null ? `<div><dt>成绩摘要</dt><dd>${h(document.subjectCount)} 科 · 总分 ${h(document.totalScore)}</dd></div>` : ''}<div><dt>签发时间</dt><dd>${formatDate(document.issuedAt, true)}</dd></div></dl></section>` : error ? `<section class="verification-result invalid"><span>!</span><div><small>NOT VERIFIED</small><h2>未找到有效文书</h2><p>${h(error)}</p></div></section>` : '';
|
||||
app.innerHTML = `${publicHeader()}<main class="public-main verification-page"><section class="verification-hero"><div><p class="overline">DOCUMENT AUTHENTICITY</p><h1>文书防伪查询</h1><p>输入成绩单或录取通知书上的防伪查询码,核对系统签发记录。</p></div><form data-form="document-verification"><label><span>防伪查询码</span><input name="code" value="${h(code)}" required autocomplete="off" placeholder="例如 SR-XXXXXXXXXXXXXXXXXXXXXXXX"></label><button class="solid-button" type="submit">立即核验 ${icons.arrow}</button></form></section>${outcome}<section class="verification-notice"><strong>安全提示</strong><p>查询结果仅展示脱敏身份和文书摘要。请勿在非官方页面提交身份证号、密码或验证码。</p></section></main><footer class="public-footer"><div>${brand()}<p>${[organization.name, organization.phone].filter(Boolean).map(h).join(' · ')}</p></div><span>系统签名实时核验</span></footer>`;
|
||||
}
|
||||
|
||||
function loginForm() {
|
||||
@@ -71,5 +157,5 @@ export function createPublicViews(context) {
|
||||
return `<form class="stack-form register-form" data-form="register"><div class="field-row"><label><span>考生姓名 *</span><input name="name" required placeholder="与证件一致"></label><label><span>性别 *</span><select name="gender" required><option value="">请选择</option><option>男</option><option>女</option></select></label></div><div class="field-row"><label><span>就读学校 *</span><select name="schoolId" data-action="school-select" required><option value="">请选择学校</option>${schools.map(item => `<option value="${h(item.id)}">${h(item.name)}</option>`).join('')}</select></label><label><span>班级 *</span><select name="classId" required><option value="">请先选择学校</option></select></label></div><label><span>设置登录密码 *</span><input name="password" type="password" required minlength="8" placeholder="至少 8 位字符"></label><label class="agreement"><input type="checkbox" required><span>我会妥善保存系统生成的报名号,并在登录后补全真实个人信息。</span></label><button class="solid-button large" type="submit">生成我的报名号 ${icons.arrow}</button></form>`;
|
||||
}
|
||||
|
||||
return { brand, renderHome, renderAuth };
|
||||
return { brand, renderHome, renderNoticeCenter, renderAuth, renderVerification };
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ export const state = {
|
||||
user: null,
|
||||
profile: null,
|
||||
publicData: { organization: {}, notices: [], exams: [], stats: {} },
|
||||
publicAnnouncements: { plans: [], qualifications: [], admissions: [], cutoffs: [] },
|
||||
noticeCategory: '全部',
|
||||
noticePage: 1,
|
||||
permissions: [],
|
||||
scopeLabel: '',
|
||||
authNotice: '',
|
||||
pageData: null,
|
||||
resultExamFilter: '',
|
||||
resultSubjectFilter: '',
|
||||
resultExamCatalog: null,
|
||||
resultImportPreview: null,
|
||||
reportingImportSummaries: {},
|
||||
tablePages: {},
|
||||
tableFilters: {},
|
||||
loading: false
|
||||
};
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
function controlState(state, key) {
|
||||
state.tableFilters ||= {};
|
||||
return state.tableFilters[key] ||= { query: '', status: 'all', filters: {} };
|
||||
}
|
||||
|
||||
function searchable(value) {
|
||||
if (value == null) return '';
|
||||
if (Array.isArray(value)) return value.map(searchable).join(' ');
|
||||
if (typeof value === 'object') return Object.values(value).map(searchable).join(' ');
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function statusTokens(item) {
|
||||
const tokens = [item?.status, item?.paymentStatus];
|
||||
if (typeof item?.active === 'boolean') tokens.push(item.active ? 'active approved' : 'inactive disabled closed');
|
||||
if (typeof item?.published === 'boolean') tokens.push(item.published ? 'published visible' : 'draft hidden');
|
||||
if (typeof item?.qualified === 'boolean') tokens.push(item.qualified ? 'qualified' : 'unqualified');
|
||||
if (typeof item?.confirmed === 'boolean') tokens.push(item.confirmed ? (item.eligible ? 'confirmed eligible' : 'confirmed ineligible') : 'unconfirmed');
|
||||
return tokens.filter(Boolean).join(' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function filterTableItems(state, items, key) {
|
||||
const control = controlState(state, key);
|
||||
const query = String(control.query || '').trim().toLocaleLowerCase('zh-CN');
|
||||
const status = String(control.status || 'all').toLowerCase();
|
||||
const filters = Object.values(control.filters || {}).filter(Boolean).map(value => String(value).toLocaleLowerCase('zh-CN'));
|
||||
return (items || []).filter(item => {
|
||||
const haystack = searchable(item).toLocaleLowerCase('zh-CN');
|
||||
if (query && !query.split(/\s+/).every(word => haystack.includes(word))) return false;
|
||||
if (status !== 'all' && !statusTokens(item).split(/\s+/).includes(status)) return false;
|
||||
return filters.every(value => haystack.includes(value));
|
||||
});
|
||||
}
|
||||
|
||||
export function setTableControl(state, key, patch) {
|
||||
const current = controlState(state, key);
|
||||
Object.assign(current, patch);
|
||||
if (patch.filters) current.filters = { ...(current.filters || {}), ...patch.filters };
|
||||
if (state.tablePages?.[key]) state.tablePages[key].page = 1;
|
||||
}
|
||||
|
||||
export function getTableControl(state, key) {
|
||||
return controlState(state, key);
|
||||
}
|
||||
+3
-1
@@ -1,8 +1,10 @@
|
||||
export const statusLabels = {
|
||||
pending: '待审核', approved: '已通过', rejected: '需修改',
|
||||
published: '已发布', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||
published: '已发布', visible: '已显示', hidden: '已隐藏', draft: '草稿', closed: '已结束', archived: '已归档',
|
||||
open: '报名中', upcoming: '即将开始', paid: '已缴费', unpaid: '待缴费',
|
||||
super: '超级管理员', school: '校级管理员', class: '班级管理员'
|
||||
, admission_school: '招生学校', filling: '志愿填报中', matching: '投档中', school_review: '学校审核中',
|
||||
reporting: '考生报到中', supplementary: '补录中', completed: '录取完成', admitted: '学校已接收', withdrawal_pending: '退档待审', withdrawn: '已退档', forfeited: '未报到失效', final: '正式录取', unread: '未读', submitted: '已提交', pending_approval: '待审批'
|
||||
};
|
||||
|
||||
export const icons = {
|
||||
|
||||
+4
-2
@@ -1,3 +1,5 @@
|
||||
import { CURRENT_SCHEMA_VERSION } from '../database/version.mjs';
|
||||
|
||||
const admissionNumberRules = (nowIso) => [
|
||||
{
|
||||
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
||||
@@ -63,7 +65,7 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
||||
const adminId = 'usr_admin';
|
||||
const createdAt = nowIso();
|
||||
return {
|
||||
meta: { version: 17, createdAt },
|
||||
meta: { version: CURRENT_SCHEMA_VERSION, createdAt },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: { name: '考试服务平台', code: 'EXAM-SERVICE', phone: '', address: '' },
|
||||
schools: [], classes: [],
|
||||
@@ -83,6 +85,6 @@ export function createBaseDatabase({ nowIso, hashPassword, initialAdmin = {} })
|
||||
{ id: 'segment_sequence', position: 4, type: 'sequence', value: '', width: 4 }
|
||||
]
|
||||
}],
|
||||
workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], auditLogs: []
|
||||
workflows: workflows(adminId, nowIso), workflowInstances: [], workflowActions: [], admissionRecords: [], auditLogs: []
|
||||
};
|
||||
}
|
||||
|
||||
+160
-70
@@ -1,4 +1,4 @@
|
||||
export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
export function createSeedDatabase({ nowIso, hashPassword, candidateCount = 1200 }) {
|
||||
const adminId = 'usr_admin';
|
||||
const schoolAdminId = 'usr_school_admin';
|
||||
const schoolAdmin2Id = 'usr_school_admin_2';
|
||||
@@ -6,6 +6,39 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
const testPasswordHash = hashPassword('12345678');
|
||||
const mainSubjectDefinitions = [
|
||||
{ id: 'sub_chinese', name: '语文', date: '2026-06-20', start: '09:00', end: '11:00', fullScore: 120 },
|
||||
{ id: 'sub_math', name: '数学', date: '2026-06-20', start: '14:30', end: '16:30', fullScore: 120 },
|
||||
{ id: 'sub_english', name: '外语', date: '2026-06-21', start: '09:00', end: '11:00', fullScore: 120 },
|
||||
{ id: 'sub_history', name: '历史', date: '2026-06-21', start: '14:30', end: '15:45', fullScore: 75 },
|
||||
{ id: 'sub_politics', name: '政治', date: '2026-06-21', start: '16:10', end: '17:25', fullScore: 75 },
|
||||
{ id: 'sub_physics', name: '物理', date: '2026-06-22', start: '09:00', end: '10:20', fullScore: 80 },
|
||||
{ id: 'sub_chemistry', name: '化学', date: '2026-06-22', start: '10:45', end: '12:00', fullScore: 70 },
|
||||
{ id: 'sub_experiment', name: '实验', date: '2026-06-22', start: '14:30', end: '15:00', fullScore: 20 },
|
||||
{ id: 'sub_it', name: '信息技术', date: '2026-06-22', start: '15:30', end: '16:00', fullScore: 10 }
|
||||
].map((subject, index) => ({
|
||||
...subject, fee: 0, passRule: 'fixed_score', passValue: subject.fullScore * 0.6,
|
||||
passScore: subject.fullScore * 0.6, order: index + 1
|
||||
}));
|
||||
const mainSubjectIds = mainSubjectDefinitions.map(subject => subject.id);
|
||||
const mainCandidateCount = Math.max(1, Math.trunc(Number(candidateCount) || 1200));
|
||||
const specialtyCandidateCount = Math.min(150, mainCandidateCount);
|
||||
|
||||
// 固定种子使每次导入得到相同的近似正态成绩,便于复现测试。
|
||||
let randomState = 0x20260620;
|
||||
const seededRandom = () => {
|
||||
randomState = (randomState + 0x6D2B79F5) >>> 0;
|
||||
let value = randomState;
|
||||
value = Math.imul(value ^ (value >>> 15), value | 1);
|
||||
value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
|
||||
return ((value ^ (value >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
const normalRandom = () => {
|
||||
const first = Math.max(seededRandom(), Number.EPSILON);
|
||||
return Math.sqrt(-2 * Math.log(first)) * Math.cos(2 * Math.PI * seededRandom());
|
||||
};
|
||||
const normalScore = fullScore => Number(Math.min(fullScore, Math.max(0, fullScore * 0.72 + fullScore * 0.14 * normalRandom())).toFixed(1));
|
||||
const scoreGrade = (score, fullScore) => score >= fullScore * 0.9 ? 'A' : score >= fullScore * 0.75 ? 'B' : score >= fullScore * 0.6 ? 'C' : 'D';
|
||||
const database = {
|
||||
meta: { version: 15, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
@@ -16,8 +49,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
address: '江苏省连云港市海州区文教路 18 号'
|
||||
},
|
||||
schools: [
|
||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', active: true },
|
||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', active: true }
|
||||
{ id: 'school_hz1', name: '海州市第一中学', code: 'HZ01', address: '江苏省连云港市海州区学府路 8 号', isSourceSchool: true, isAdmissionSchool: false, active: true },
|
||||
{ id: 'school_hz3', name: '海州市第三中学', code: 'HZ03', address: '江苏省连云港市连云区育才路 16 号', isSourceSchool: true, isAdmissionSchool: false, active: true }
|
||||
],
|
||||
classes: [
|
||||
{ id: 'class_hz1_301', schoolId: 'school_hz1', name: '高三(1)班', grade: '高三', active: true },
|
||||
@@ -39,7 +72,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
phone: '13800138000', email: 'zhou@example.com', school: '海州市第一中学', grade: '高三(2)班', schoolId: 'school_hz1', classId: 'class_hz1_302',
|
||||
provinceCode: '320000', provinceName: '江苏省', cityCode: '320700', cityName: '连云港市', districtCode: '320706', districtName: '海州区',
|
||||
address: '学府路 8 号', emergencyContact: '周建国', emergencyPhone: '13900139000',
|
||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000', profileCompleted: false,
|
||||
nativePlace: '江苏海州', birthDate: '2008-08-16', ethnicity: '汉族', postalCode: '222000', guardianName: '周建国', guardianPhone: '13900139000',
|
||||
specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyTypes: ['fine_arts'], specialtyCertificate: 'ART-DEMO-0001', policyEligibility: '特长生资格已核验', profileCompleted: true,
|
||||
status: 'approved', reviewNote: '身份信息与学籍信息核验一致', reviewedAt: '2026-07-18T08:30:00.000Z', updatedAt: '2026-07-17T09:20:00.000Z'
|
||||
}
|
||||
],
|
||||
@@ -50,33 +84,29 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
],
|
||||
exams: [
|
||||
{
|
||||
id: examId, code: 'EX-2026-AUT', name: '2026 年秋季统一考试', description: '面向全市普通高中高三在籍学生的统一学业考试。',
|
||||
registrationStart: '2026-07-01T00:00:00.000Z', registrationEnd: '2026-07-31T15:59:59.000Z',
|
||||
examStart: '2026-08-16T01:00:00.000Z', examEnd: '2026-08-18T09:00:00.000Z',
|
||||
admitDownloadStart: '2026-07-19T00:00:00.000Z', admitDownloadEnd: '2026-08-16T00:45:00.000Z',
|
||||
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-06-18T02:00:00.000Z',
|
||||
subjects: [
|
||||
{ id: 'sub_chinese', name: '语文', date: '2026-08-16', start: '09:00', end: '11:30', fee: 30, fullScore: 150, passRule: 'fixed_score', passValue: 90, passScore: 90 },
|
||||
{ id: 'sub_math', name: '数学', date: '2026-08-16', start: '15:00', end: '17:00', fee: 30, fullScore: 150, passRule: 'rank_percent', passValue: 60, passScore: null },
|
||||
{ id: 'sub_physics', name: '物理', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
|
||||
{ id: 'sub_history', name: '历史', date: '2026-08-17', start: '09:00', end: '10:30', fee: 25, fullScore: 100, passScore: 60 },
|
||||
{ id: 'sub_english', name: '外语', date: '2026-08-17', start: '15:00', end: '16:30', fee: 30, fullScore: 150, passScore: 90 },
|
||||
{ id: 'sub_chemistry', name: '化学', date: '2026-08-18', start: '09:00', end: '10:15', fee: 25, fullScore: 100, passScore: 60 },
|
||||
{ id: 'sub_biology', name: '生物', date: '2026-08-18', start: '15:00', end: '16:15', fee: 25, fullScore: 100, passScore: 60 }
|
||||
]
|
||||
id: examId, code: 'EX-2026-ZK', name: '2026 年海州市初中学业水平考试', description: '演示数据主考试:覆盖成绩发布、特长生和第一轮志愿填报。',
|
||||
registrationStart: '2026-04-01T00:00:00.000Z', registrationEnd: '2026-04-30T15:59:59.000Z',
|
||||
examStart: '2026-06-20T01:00:00.000Z', examEnd: '2026-06-22T08:00:00.000Z',
|
||||
admitDownloadStart: '2026-06-10T00:00:00.000Z', admitDownloadEnd: '2026-06-20T00:45:00.000Z',
|
||||
location: '海州市各指定考点', passPolicy: 'rank_percent', passValue: 60, status: 'published', createdAt: '2026-03-18T02:00:00.000Z',
|
||||
subjects: mainSubjectDefinitions
|
||||
},
|
||||
{
|
||||
id: 'exam_mock_2026', code: 'EX-2026-MOCK-2', name: '第二次全市模拟考试', description: '秋季统一考试前的全流程模拟考试。',
|
||||
registrationStart: '2026-10-01T00:00:00.000Z', registrationEnd: '2026-10-20T15:59:59.000Z',
|
||||
examStart: '2026-11-08T01:00:00.000Z', examEnd: '2026-11-10T09:00:00.000Z',
|
||||
admitDownloadStart: '2026-11-01T00:00:00.000Z', admitDownloadEnd: '2026-11-08T00:45:00.000Z',
|
||||
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: []
|
||||
location: '考点待公布', passPolicy: 'rank_percent', passValue: 60, status: 'draft', createdAt: nowIso(), subjects: [
|
||||
{ id: 'mock_sub_chinese', name: '语文', date: '2026-11-08', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
|
||||
{ id: 'mock_sub_math', name: '数学', date: '2026-11-08', start: '14:30', end: '16:30', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 },
|
||||
{ id: 'mock_sub_english', name: '外语', date: '2026-11-09', start: '09:00', end: '11:00', fee: 0, fullScore: 120, passRule: 'fixed_score', passValue: 72, passScore: 72 }
|
||||
]
|
||||
}
|
||||
],
|
||||
registrations: [
|
||||
{
|
||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||
status: 'approved', paymentStatus: 'paid', paidAt: '2026-07-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default'
|
||||
id: registrationId, userId: candidateId, examId, subjectIds: mainSubjectIds,
|
||||
status: 'approved', paymentStatus: 'paid', paidAt: '2026-05-18T09:00:00.000Z', paidBy: 'usr_class_admin', createdAt: '2026-04-08T05:18:00.000Z', reviewedAt: '2026-04-18T08:32:00.000Z', reviewNote: '报名审核通过', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default', featureScore: 90
|
||||
}
|
||||
],
|
||||
results: [],
|
||||
@@ -165,20 +195,27 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
],
|
||||
workflowInstances: [],
|
||||
workflowActions: [],
|
||||
admissionRecords: [],
|
||||
auditLogs: [
|
||||
{ id: 'log_1', actorId: adminId, action: '发布通知', detail: '发布《市第三中学考点交通提示》', createdAt: '2026-07-18T06:00:00.000Z' }
|
||||
]
|
||||
};
|
||||
|
||||
const schoolDefinitions = [
|
||||
const sourceSchools = [
|
||||
{ key: 'hz1', id: 'school_hz1', code: 'HZ01', name: '海州市第一中学', districtCode: '320706', districtName: '海州区', address: '学府路 8 号' },
|
||||
{ key: 'hz3', id: 'school_hz3', code: 'HZ03', name: '海州市第三中学', districtCode: '320703', districtName: '连云区', address: '育才路 16 号' },
|
||||
{ key: 'hz5', id: 'school_hz5', code: 'HZ05', name: '海州市第五中学', districtCode: '320707', districtName: '赣榆区', address: '青口路 28 号' },
|
||||
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' }
|
||||
{ key: 'hz7', id: 'school_hz7', code: 'HZ07', name: '海州市第七中学', districtCode: '320723', districtName: '灌云县', address: '胜利路 66 号' },
|
||||
{ key: 'hz9', id: 'school_hz9', code: 'HZ09', name: '海州市第九中学', districtCode: '320724', districtName: '灌南县', address: '新安路 39 号' }
|
||||
];
|
||||
for (const school of schoolDefinitions) {
|
||||
const admissionSchools = [
|
||||
{ key: 'admission_1', id: 'school_admission_1', code: 'AD01', name: '海州市高级中学', address: '江苏省连云港市海州区苍梧路 100 号' },
|
||||
{ key: 'admission_2', id: 'school_admission_2', code: 'AD02', name: '海州市实验高级中学', address: '江苏省连云港市连云区海棠路 88 号' },
|
||||
{ key: 'admission_3', id: 'school_admission_3', code: 'AD03', name: '海州市外国语高级中学', address: '江苏省连云港市赣榆区黄海路 66 号' }
|
||||
];
|
||||
for (const school of sourceSchools) {
|
||||
if (!database.schools.some(item => item.id === school.id)) {
|
||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, active: true });
|
||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: `江苏省连云港市${school.districtName}${school.address}`, isSourceSchool: true, isAdmissionSchool: false, active: true });
|
||||
}
|
||||
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||
const classId = `class_${school.key}_30${classIndex}`;
|
||||
@@ -188,14 +225,25 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const school of schoolDefinitions) {
|
||||
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
||||
for (const school of admissionSchools) {
|
||||
database.schools.push({ id: school.id, name: school.name, code: school.code, address: school.address, isSourceSchool: false, isAdmissionSchool: true, active: true });
|
||||
database.users.push({
|
||||
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
||||
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
||||
id: `usr_${school.key}`, username: `${school.key}_admin`, passwordHash: testPasswordHash, role: 'admission_school',
|
||||
schoolId: school.id, displayName: `${school.name}招生办`, active: true, mustChangePassword: false, createdAt: nowIso()
|
||||
});
|
||||
}
|
||||
|
||||
for (const school of sourceSchools) {
|
||||
if (school.id !== 'school_hz1') {
|
||||
const generatedSchoolAdminId = `usr_test_school_admin_${school.key}`;
|
||||
database.users.push({
|
||||
id: generatedSchoolAdminId, username: `test_school_admin_${school.key}`, passwordHash: testPasswordHash,
|
||||
role: 'admin', adminLevel: 'school', schoolId: school.id, displayName: `${school.name}测试校管`, active: true, createdAt: nowIso()
|
||||
});
|
||||
}
|
||||
for (let classIndex = 1; classIndex <= 3; classIndex += 1) {
|
||||
const classId = `class_${school.key}_30${classIndex}`;
|
||||
if (classId === 'class_hz1_302') continue;
|
||||
database.users.push({
|
||||
id: `usr_test_class_admin_${school.key}_${classIndex}`, username: `test_class_admin_${school.key}_${classIndex}`,
|
||||
passwordHash: testPasswordHash, role: 'admin', adminLevel: 'class', schoolId: school.id, classId,
|
||||
@@ -204,7 +252,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const school of schoolDefinitions) {
|
||||
for (const school of sourceSchools) {
|
||||
let center = database.testCenters.find(item => item.schoolId === school.id);
|
||||
if (!center) {
|
||||
center = {
|
||||
@@ -227,21 +275,20 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试库停留在考场编排前:不预置编排计划、准考证或成绩。
|
||||
// 测试库停留在考场编排前:不预置编排计划或准考证;主考试成绩与志愿已完成。
|
||||
const familyNames = ['赵', '钱', '孙', '李', '周', '吴', '郑', '王', '冯', '陈', '褚', '卫'];
|
||||
const givenNames = ['子涵', '梓萱', '宇航', '雨欣', '浩然', '思远', '佳宁', '晨曦', '明轩', '若彤', '嘉诚', '欣怡'];
|
||||
const subjectSets = [
|
||||
['sub_chinese', 'sub_math', 'sub_english'],
|
||||
['sub_chinese', 'sub_math', 'sub_physics', 'sub_chemistry'],
|
||||
['sub_chinese', 'sub_math', 'sub_history', 'sub_biology'],
|
||||
['sub_chinese', 'sub_math', 'sub_english', 'sub_physics', 'sub_chemistry'],
|
||||
['sub_chinese', 'sub_math', 'sub_english', 'sub_history', 'sub_biology']
|
||||
const specialtyDefinitions = [
|
||||
{ category: 'sports', type: 'track_field', label: '田径' },
|
||||
{ category: 'sports', type: 'basketball', label: '篮球' },
|
||||
{ category: 'arts', type: 'fine_arts', label: '美术' },
|
||||
{ category: 'arts', type: 'vocal_music', label: '声乐' },
|
||||
{ category: 'arts', type: 'dance', label: '舞蹈' }
|
||||
];
|
||||
const registrationWorkflowId = 'workflow_registration';
|
||||
for (let index = 0; index < 360; index += 1) {
|
||||
for (let index = 0; index < mainCandidateCount - 1; index += 1) {
|
||||
const serial = index + 1001;
|
||||
const school = schoolDefinitions[index % schoolDefinitions.length];
|
||||
const classIndex = Math.floor(index / schoolDefinitions.length) % 3 + 1;
|
||||
const school = sourceSchools[index % sourceSchools.length];
|
||||
const classIndex = Math.floor(index / sourceSchools.length) % 3 + 1;
|
||||
const classId = `class_${school.key}_30${classIndex}`;
|
||||
const gender = index % 2 === 0 ? '男' : '女';
|
||||
const genderCode = gender === '男' ? 'M' : 'F';
|
||||
@@ -249,7 +296,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const profileId = `profile_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||
const registrationIdBulk = `reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||
const candidateNumber = `2026-${school.code}-${genderCode}-${String(serial).padStart(4, '0')}`;
|
||||
const createdAt = new Date(Date.UTC(2026, 6, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
||||
const createdAt = new Date(Date.UTC(2026, 3, 2 + (index % 20), 1 + (index % 8), index % 60)).toISOString();
|
||||
const name = `${familyNames[index % familyNames.length]}${givenNames[Math.floor(index / familyNames.length) % givenNames.length]}${Math.floor(index / 144) + 1}`;
|
||||
const idNumber = `3207002008${String(index % 12 + 1).padStart(2, '0')}${String(index % 28 + 1).padStart(2, '0')}${String(index + 1).padStart(4, '0')}`;
|
||||
const phone = `138${String(10000000 + index).padStart(8, '0')}`;
|
||||
@@ -257,6 +304,8 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
id: userId, username: candidateNumber, candidateNumber, passwordHash: testPasswordHash, role: 'candidate',
|
||||
displayName: name, active: true, mustChangePassword: false, createdAt
|
||||
});
|
||||
const isSpecialtyCandidate = index < specialtyCandidateCount - 1;
|
||||
const specialty = isSpecialtyCandidate ? specialtyDefinitions[index % specialtyDefinitions.length] : null;
|
||||
database.candidateProfiles.push({
|
||||
id: profileId, userId, name, gender, idNumber, phone, email: `candidate${String(index + 1).padStart(4, '0')}@example.test`,
|
||||
school: school.name, grade: `高三(${classIndex})班`, schoolId: school.id, classId,
|
||||
@@ -265,41 +314,82 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
emergencyContact: `${familyNames[index % familyNames.length]}家长`, emergencyPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||
nativePlace: `江苏${school.districtName}`, birthDate: `2008-${String(index % 12 + 1).padStart(2, '0')}-${String(index % 28 + 1).padStart(2, '0')}`,
|
||||
ethnicity: index % 19 === 0 ? '回族' : '汉族', postalCode: '222000', guardianName: `${familyNames[index % familyNames.length]}家长`,
|
||||
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`, profileCompleted: true, status: 'approved',
|
||||
reviewNote: '批量测试数据:学籍核验通过', reviewedAt: '2026-06-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
||||
guardianPhone: `139${String(10000000 + index).padStart(8, '0')}`,
|
||||
specialtyCategory: specialty?.category || '', specialtyType: specialty?.type || '', specialtyTypes: specialty ? [specialty.type] : [],
|
||||
specialtyCertificate: specialty ? `SPECIAL-2026-${String(index + 2).padStart(4, '0')}` : '', policyEligibility: specialty ? `${specialty.label}特长生资格已核验` : '',
|
||||
profileCompleted: true, status: 'approved', reviewNote: '批量演示数据:学籍核验通过',
|
||||
reviewedAt: '2026-04-30T08:00:00.000Z', reviewerId: adminId, updatedAt: createdAt
|
||||
});
|
||||
|
||||
const scenario = index % 4;
|
||||
const status = scenario === 0 ? 'pending' : scenario === 1 ? 'rejected' : 'approved';
|
||||
const paymentStatus = scenario === 3 ? 'paid' : 'unpaid';
|
||||
const classAdminId = `usr_test_class_admin_${school.key}_${classIndex}`;
|
||||
const schoolAdmin = `usr_test_school_admin_${school.key}`;
|
||||
const paymentStatus = index % 2 === 0 ? 'paid' : 'unpaid';
|
||||
const classAdminId = classId === 'class_hz1_302' ? 'usr_class_admin' : `usr_test_class_admin_${school.key}_${classIndex}`;
|
||||
database.registrations.push({
|
||||
id: registrationIdBulk, userId, examId, subjectIds: subjectSets[index % subjectSets.length], status, paymentStatus,
|
||||
paidAt: paymentStatus === 'paid' ? '2026-07-25T08:30:00.000Z' : null,
|
||||
id: registrationIdBulk, userId, examId, subjectIds: mainSubjectIds, status: 'approved', paymentStatus,
|
||||
paidAt: paymentStatus === 'paid' ? '2026-05-18T08:30:00.000Z' : null,
|
||||
paidBy: paymentStatus === 'paid' ? classAdminId : null,
|
||||
createdAt, reviewedAt: status === 'pending' ? null : '2026-07-22T08:00:00.000Z',
|
||||
reviewNote: status === 'rejected' ? '测试场景:报名资料被退回' : status === 'approved' ? '测试场景:报名审核通过' : '',
|
||||
registrationNumber: candidateNumber, numberRuleId: 'rule_default'
|
||||
createdAt, reviewedAt: '2026-04-30T08:00:00.000Z', reviewNote: '批量演示数据:报名审核通过',
|
||||
registrationNumber: candidateNumber, numberRuleId: 'rule_default',
|
||||
featureScore: specialty ? Number((80 + seededRandom() * 20).toFixed(1)) : 0
|
||||
});
|
||||
if (status === 'pending' || status === 'rejected') {
|
||||
const instanceId = `flow_reg_bulk_${String(index + 1).padStart(4, '0')}`;
|
||||
database.workflowInstances.push({
|
||||
id: instanceId, workflowId: registrationWorkflowId, businessType: 'registration_review', businessId: registrationIdBulk,
|
||||
status, currentStep: 1, assigneeId: status === 'pending' ? schoolAdmin : null, createdAt,
|
||||
completedAt: status === 'rejected' ? '2026-07-22T08:00:00.000Z' : null
|
||||
}
|
||||
|
||||
const admissionCreatedAt = '2026-07-01T00:00:00.000Z';
|
||||
database.admissionRecords.push({
|
||||
id: 'admission_setting_main_2026', kind: 'setting', examId, userId: adminId, schoolId: null, status: 'closed',
|
||||
payload: {
|
||||
enabled: true, preferenceStart: '2026-07-01T00:00:00.000Z', preferenceEnd: '2026-07-15T15:59:59.000Z',
|
||||
maxChoices: 3, maxSubmissions: 1, round: 1, autoPublish: true, progress: '第一轮志愿已全部填报完毕,等待投档'
|
||||
},
|
||||
createdAt: admissionCreatedAt, updatedAt: '2026-07-16T00:00:00.000Z'
|
||||
});
|
||||
for (const school of admissionSchools) {
|
||||
database.admissionRecords.push({
|
||||
id: `admission_plan_${school.key}`, kind: 'plan', examId, userId: `usr_${school.key}`, schoolId: school.id, status: 'approved',
|
||||
payload: {
|
||||
categories: [
|
||||
{ code: 'general', name: '普通生', quota: 350, specialtyCategory: '', specialtyType: '', indicatorAllocations: [] },
|
||||
{ code: 'sports', name: '体育特长生', quota: 1, specialtyCategory: 'sports', specialtyType: '', indicatorAllocations: [] },
|
||||
{ code: 'arts', name: '艺术特长生', quota: 1, specialtyCategory: 'arts', specialtyType: '', indicatorAllocations: [] }
|
||||
],
|
||||
note: '演示数据招生计划:普通类 350 人,特长生合计 2 人', submittedBy: `${school.name}招生办`, reviewedBy: '林老师', reviewedAt: admissionCreatedAt, publicVisible: true
|
||||
},
|
||||
createdAt: admissionCreatedAt, updatedAt: admissionCreatedAt
|
||||
});
|
||||
}
|
||||
|
||||
const mainRegistrations = database.registrations.filter(registration => registration.examId === examId);
|
||||
for (const [candidateIndex, registration] of mainRegistrations.entries()) {
|
||||
const profile = database.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
const isSpecialtyCandidate = Boolean(profile?.specialtyCategory);
|
||||
for (const subject of mainSubjectDefinitions) {
|
||||
const score = normalScore(subject.fullScore);
|
||||
database.results.push({
|
||||
id: `result_main_${String(candidateIndex + 1).padStart(4, '0')}_${subject.id.slice(4)}`,
|
||||
registrationId: registration.id, subjectId: subject.id, score, grade: scoreGrade(score, subject.fullScore),
|
||||
published: true, updatedAt: '2026-06-30T08:00:00.000Z', publishedAt: '2026-06-30T08:00:00.000Z'
|
||||
});
|
||||
database.workflowActions.push({
|
||||
id: `flow_action_submit_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: userId, action: 'submit',
|
||||
note: '提交考试报名', fromAssigneeId: null, toAssigneeId: schoolAdmin, createdAt
|
||||
});
|
||||
if (status === 'rejected') {
|
||||
database.workflowActions.push({
|
||||
id: `flow_action_reject_bulk_${String(index + 1).padStart(4, '0')}`, instanceId, actorId: schoolAdmin, action: 'reject',
|
||||
note: '测试场景:报名资料被退回', fromAssigneeId: schoolAdmin, toAssigneeId: null, createdAt: '2026-07-22T08:00:00.000Z'
|
||||
});
|
||||
}
|
||||
}
|
||||
const rotatedAdmissionSchools = admissionSchools.map((_, offset) => admissionSchools[(candidateIndex + offset) % admissionSchools.length]);
|
||||
const choices = rotatedAdmissionSchools.map((school, choiceIndex) => ({
|
||||
schoolId: school.id,
|
||||
categoryCode: isSpecialtyCandidate && choiceIndex === 0 ? profile.specialtyCategory : 'general',
|
||||
preferenceType: 'general'
|
||||
}));
|
||||
database.admissionRecords.push({
|
||||
id: `preference_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'preference', examId,
|
||||
userId: registration.userId, schoolId: null, status: 'submitted',
|
||||
payload: {
|
||||
round: 1, submissionCount: 1, submittedAt: new Date(Date.UTC(2026, 6, 5 + (candidateIndex % 10), 1 + (candidateIndex % 8), candidateIndex % 60)).toISOString(),
|
||||
choices
|
||||
},
|
||||
createdAt: admissionCreatedAt, updatedAt: '2026-07-15T08:00:00.000Z'
|
||||
});
|
||||
database.admissionRecords.push({
|
||||
id: `qualification_main_${String(candidateIndex + 1).padStart(4, '0')}`, kind: 'indicator_qualification', examId,
|
||||
userId: registration.userId, schoolId: profile.schoolId, status: 'confirmed',
|
||||
payload: { eligible: isSpecialtyCandidate, confirmedAt: '2026-06-28T08:00:00.000Z', note: isSpecialtyCandidate ? '特长资格核验通过' : '普通生' },
|
||||
createdAt: '2026-06-28T08:00:00.000Z', updatedAt: '2026-06-28T08:00:00.000Z'
|
||||
});
|
||||
}
|
||||
|
||||
return database;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
export const specialtyCatalog = Object.freeze([
|
||||
Object.freeze({
|
||||
code: 'sports',
|
||||
name: '体育',
|
||||
types: Object.freeze([
|
||||
Object.freeze({ code: 'track_field', name: '田径' }),
|
||||
Object.freeze({ code: 'basketball', name: '篮球' }),
|
||||
Object.freeze({ code: 'football', name: '足球' }),
|
||||
Object.freeze({ code: 'volleyball', name: '排球' }),
|
||||
Object.freeze({ code: 'table_tennis', name: '乒乓球' }),
|
||||
Object.freeze({ code: 'badminton', name: '羽毛球' }),
|
||||
Object.freeze({ code: 'swimming', name: '游泳' }),
|
||||
Object.freeze({ code: 'martial_arts', name: '武术' }),
|
||||
Object.freeze({ code: 'aerobics_cheer', name: '健美操与啦啦操' })
|
||||
])
|
||||
}),
|
||||
Object.freeze({
|
||||
code: 'arts',
|
||||
name: '艺术',
|
||||
types: Object.freeze([
|
||||
Object.freeze({ code: 'vocal_music', name: '声乐' }),
|
||||
Object.freeze({ code: 'instrumental_music', name: '器乐' }),
|
||||
Object.freeze({ code: 'dance', name: '舞蹈' }),
|
||||
Object.freeze({ code: 'fine_arts', name: '美术' }),
|
||||
Object.freeze({ code: 'calligraphy', name: '书法' }),
|
||||
Object.freeze({ code: 'drama_broadcasting', name: '戏剧与播音' })
|
||||
])
|
||||
})
|
||||
]);
|
||||
|
||||
const categoryMap = new Map(specialtyCatalog.map(category => [category.code, category]));
|
||||
const typeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.code, { ...type, categoryCode: category.code, categoryName: category.name }])));
|
||||
const legacyTypeMap = new Map(specialtyCatalog.flatMap(category => category.types.map(type => [type.name, { category: category.code, type: type.code }])));
|
||||
|
||||
export function specialtyCategory(code) {
|
||||
return categoryMap.get(String(code || '')) || null;
|
||||
}
|
||||
|
||||
export function specialtyType(code) {
|
||||
return typeMap.get(String(code || '')) || null;
|
||||
}
|
||||
|
||||
export function isValidSpecialty(categoryCode, typeCode) {
|
||||
if (!categoryCode && !typeCode) return true;
|
||||
const category = specialtyCategory(categoryCode);
|
||||
const type = specialtyType(typeCode);
|
||||
return Boolean(category && type && type.categoryCode === category.code);
|
||||
}
|
||||
|
||||
export function resolveProfileSpecialty(profile = {}) {
|
||||
if (isValidSpecialty(profile.specialtyCategory, profile.specialtyType) && profile.specialtyCategory) {
|
||||
return { category: profile.specialtyCategory, type: profile.specialtyType };
|
||||
}
|
||||
const legacy = (Array.isArray(profile.specialtyTypes) ? profile.specialtyTypes : []).map(value => legacyTypeMap.get(String(value))).find(Boolean);
|
||||
return legacy || { category: '', type: '' };
|
||||
}
|
||||
|
||||
export function specialtyLabel(categoryCode, typeCode) {
|
||||
const category = specialtyCategory(categoryCode);
|
||||
const type = specialtyType(typeCode);
|
||||
if (!category) {
|
||||
const legacy = legacyTypeMap.get(String(typeCode || ''));
|
||||
return legacy ? specialtyLabel(legacy.category, legacy.type) : '';
|
||||
}
|
||||
return type?.categoryCode === category.code ? `${category.name}·${type.name}` : category.name;
|
||||
}
|
||||
|
||||
export function candidateEligibleForCategory(profile, category) {
|
||||
const legacy = !category?.specialtyCategory ? legacyTypeMap.get(String(category?.specialtyType || '')) : null;
|
||||
const requiredCategory = category?.specialtyCategory || legacy?.category || '';
|
||||
const requiredType = legacy?.type || category?.specialtyType || '';
|
||||
if (!requiredCategory) return true;
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
if (qualification.category !== requiredCategory) return false;
|
||||
return !requiredType || qualification.type === requiredType;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { synchronizeMysqlPartitions } from './partition-storage.mjs';
|
||||
|
||||
import { createStateCache } from './state-cache.mjs';
|
||||
|
||||
export function createMysqlAdapter(context) {
|
||||
const {
|
||||
mkdir,
|
||||
@@ -54,7 +56,7 @@ export function createMysqlAdapter(context) {
|
||||
hasSchemaMetadata = metadataRows.length > 0;
|
||||
existingSchemaVersion = hasSchemaMetadata ? Number(metadataRows[0].schema_version) : null;
|
||||
}
|
||||
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17].includes(existingSchemaVersion))) {
|
||||
if (existingAppTables.length && (!hasSchemaMetadata || ![15, 16, 17, 18, 19, 20].includes(existingSchemaVersion))) {
|
||||
for (const table of [...mysqlTableNames].reverse()) {
|
||||
await pool.query(`DROP TABLE IF EXISTS \`${table}\``);
|
||||
}
|
||||
@@ -167,6 +169,35 @@ export function createMysqlAdapter(context) {
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 17;
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 18) {
|
||||
await pool.query("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'candidate', 'admission_school') NOT NULL");
|
||||
const [profileColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_types', 'specialty_certificate', 'policy_eligibility')");
|
||||
const existingProfileColumns = new Set(profileColumns.map(item => item.Field));
|
||||
if (!existingProfileColumns.has('specialty_types')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()) AFTER guardian_phone');
|
||||
if (!existingProfileColumns.has('specialty_certificate')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_certificate VARCHAR(255) NULL AFTER specialty_types');
|
||||
if (!existingProfileColumns.has('policy_eligibility')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN policy_eligibility VARCHAR(255) NULL AFTER specialty_certificate');
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 18;
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 19) {
|
||||
const [schoolColumns] = await pool.query("SHOW COLUMNS FROM schools WHERE Field IN ('is_source_school', 'is_admission_school')");
|
||||
const existingSchoolColumns = new Set(schoolColumns.map(item => item.Field));
|
||||
if (!existingSchoolColumns.has('is_source_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_source_school BOOLEAN NOT NULL DEFAULT TRUE AFTER address');
|
||||
if (!existingSchoolColumns.has('is_admission_school')) await pool.query('ALTER TABLE schools ADD COLUMN is_admission_school BOOLEAN NOT NULL DEFAULT TRUE AFTER is_source_school');
|
||||
const [specialtyColumns] = await pool.query("SHOW COLUMNS FROM candidate_profiles WHERE Field IN ('specialty_category', 'specialty_type')");
|
||||
const existingSpecialtyColumns = new Set(specialtyColumns.map(item => item.Field));
|
||||
if (!existingSpecialtyColumns.has('specialty_category')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_category VARCHAR(30) NULL AFTER guardian_phone');
|
||||
if (!existingSpecialtyColumns.has('specialty_type')) await pool.query('ALTER TABLE candidate_profiles ADD COLUMN specialty_type VARCHAR(40) NULL AFTER specialty_category');
|
||||
const [registrationColumns] = await pool.query("SHOW COLUMNS FROM registrations WHERE Field = 'feature_score'");
|
||||
if (!registrationColumns.length) await pool.query('ALTER TABLE registrations ADD COLUMN feature_score DECIMAL(8,2) NOT NULL DEFAULT 0 AFTER number_rule_id');
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 19;
|
||||
}
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 20) {
|
||||
await pool.query("ALTER TABLE admission_records MODIFY COLUMN kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL");
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 20;
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
@@ -349,7 +380,7 @@ export function createMysqlAdapter(context) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 17, ?, ?, ?)
|
||||
VALUES (1, 18, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
@@ -379,7 +410,9 @@ export function createMysqlAdapter(context) {
|
||||
partitionConnection.release();
|
||||
}
|
||||
|
||||
let stateCache;
|
||||
const transaction = async operations => {
|
||||
stateCache?.invalidate();
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
@@ -390,10 +423,11 @@ export function createMysqlAdapter(context) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
stateCache?.invalidate();
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
const read = async () => {
|
||||
const loadState = async () => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
@@ -407,10 +441,11 @@ export function createMysqlAdapter(context) {
|
||||
connection.release();
|
||||
}
|
||||
};
|
||||
stateCache = createStateCache({ load: loadState });
|
||||
return createRepository({
|
||||
client: 'mysql',
|
||||
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
|
||||
read,
|
||||
read: stateCache.read,
|
||||
transaction,
|
||||
close: async () => pool.end()
|
||||
});
|
||||
|
||||
+47
-2
@@ -22,6 +22,8 @@ export const sqliteSchema = `
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
address TEXT,
|
||||
is_source_school INTEGER NOT NULL DEFAULT 1 CHECK (is_source_school IN (0, 1)),
|
||||
is_admission_school INTEGER NOT NULL DEFAULT 1 CHECK (is_admission_school IN (0, 1)),
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
|
||||
) STRICT;
|
||||
|
||||
@@ -47,7 +49,7 @@ export const sqliteSchema = `
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
candidate_number TEXT UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')),
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
|
||||
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
@@ -90,6 +92,11 @@ export const sqliteSchema = `
|
||||
postal_code TEXT,
|
||||
guardian_name TEXT,
|
||||
guardian_phone TEXT,
|
||||
specialty_category TEXT,
|
||||
specialty_type TEXT,
|
||||
specialty_types TEXT NOT NULL DEFAULT '[]',
|
||||
specialty_certificate TEXT,
|
||||
policy_eligibility TEXT,
|
||||
profile_completed INTEGER NOT NULL DEFAULT 0 CHECK (profile_completed IN (0, 1)),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||||
review_note TEXT,
|
||||
@@ -171,6 +178,7 @@ export const sqliteSchema = `
|
||||
review_note TEXT,
|
||||
registration_number TEXT,
|
||||
number_rule_id TEXT,
|
||||
feature_score REAL NOT NULL DEFAULT 0 CHECK (feature_score >= 0),
|
||||
UNIQUE (user_id, exam_id)
|
||||
) STRICT;
|
||||
|
||||
@@ -426,6 +434,18 @@ export const sqliteSchema = `
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admission_records (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
|
||||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_archive_scope ON users(role, school_id, class_id, archived_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_exams_archive ON exams(archived_at, exam_end);
|
||||
@@ -446,6 +466,7 @@ export const sqliteSchema = `
|
||||
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_results_lock_archived_insert
|
||||
BEFORE INSERT ON results
|
||||
@@ -505,6 +526,8 @@ export const mysqlSchema = [
|
||||
name VARCHAR(160) NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
address VARCHAR(255) NULL,
|
||||
is_source_school BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
is_admission_school BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_schools_name (name),
|
||||
@@ -536,7 +559,7 @@ export const mysqlSchema = [
|
||||
username VARCHAR(100) NOT NULL,
|
||||
candidate_number VARCHAR(120) NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role ENUM('admin', 'candidate') NOT NULL,
|
||||
role ENUM('admin', 'candidate', 'admission_school') NOT NULL,
|
||||
admin_level ENUM('super', 'school', 'class') NULL,
|
||||
school_id VARCHAR(64) NULL,
|
||||
class_id VARCHAR(64) NULL,
|
||||
@@ -586,6 +609,11 @@ export const mysqlSchema = [
|
||||
postal_code VARCHAR(20) NULL,
|
||||
guardian_name VARCHAR(100) NULL,
|
||||
guardian_phone VARCHAR(60) NULL,
|
||||
specialty_category VARCHAR(30) NULL,
|
||||
specialty_type VARCHAR(40) NULL,
|
||||
specialty_types JSON NOT NULL DEFAULT (JSON_ARRAY()),
|
||||
specialty_certificate VARCHAR(255) NULL,
|
||||
policy_eligibility VARCHAR(255) NULL,
|
||||
profile_completed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||||
review_note VARCHAR(500) NULL,
|
||||
@@ -687,6 +715,7 @@ export const mysqlSchema = [
|
||||
review_note VARCHAR(500) NULL,
|
||||
registration_number VARCHAR(120) NULL,
|
||||
number_rule_id VARCHAR(64) NULL,
|
||||
feature_score DECIMAL(8,2) NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_registrations_user_exam (user_id, exam_id),
|
||||
KEY idx_registrations_status (status),
|
||||
@@ -987,6 +1016,22 @@ export const mysqlSchema = [
|
||||
CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admission_records (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
kind ENUM('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication') NOT NULL,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
user_id VARCHAR(64) NULL,
|
||||
school_id VARCHAR(64) NULL,
|
||||
status VARCHAR(40) NOT NULL,
|
||||
payload_json JSON NOT NULL,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
updated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_admission_records_lookup (kind, exam_id, school_id, user_id, status),
|
||||
CONSTRAINT fk_admission_record_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admission_record_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admission_record_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
actor_id VARCHAR(64) NULL,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { synchronizeSqlitePartitions } from './partition-storage.mjs';
|
||||
import { createStateCache } from './state-cache.mjs';
|
||||
|
||||
export function createSqliteAdapter(context) {
|
||||
const {
|
||||
@@ -19,8 +20,14 @@ export function createSqliteAdapter(context) {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
|
||||
const connection = new DatabaseSync(path, { timeout: 5000 });
|
||||
connection.exec('PRAGMA journal_mode = WAL;');
|
||||
connection.exec('PRAGMA synchronous = NORMAL;');
|
||||
connection.exec(`
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA synchronous = NORMAL;
|
||||
PRAGMA temp_store = MEMORY;
|
||||
PRAGMA cache_size = -65536;
|
||||
PRAGMA mmap_size = 268435456;
|
||||
PRAGMA wal_autocheckpoint = 1000;
|
||||
`);
|
||||
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||||
const ensureColumns = (table, columns) => {
|
||||
if (!tableExists(table)) return;
|
||||
@@ -32,16 +39,23 @@ export function createSqliteAdapter(context) {
|
||||
ensureColumns('users', [
|
||||
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1'],
|
||||
['candidate_number', 'TEXT'], ['must_change_password', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['totp_enabled', 'INTEGER NOT NULL DEFAULT 0'], ['totp_secret_encrypted', 'TEXT'],
|
||||
['totp_recovery_codes', "TEXT NOT NULL DEFAULT '[]'"], ['totp_last_used_step', 'INTEGER'],
|
||||
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
||||
]);
|
||||
ensureColumns('schema_metadata', [['self_registration_enabled', 'INTEGER NOT NULL DEFAULT 0']]);
|
||||
ensureColumns('schools', [
|
||||
['is_source_school', 'INTEGER NOT NULL DEFAULT 1'], ['is_admission_school', 'INTEGER NOT NULL DEFAULT 1']
|
||||
]);
|
||||
ensureColumns('candidate_profiles', [
|
||||
['school_id', 'TEXT'], ['class_id', 'TEXT'], ['native_place', 'TEXT'], ['birth_date', 'TEXT'], ['ethnicity', 'TEXT'],
|
||||
['postal_code', 'TEXT'], ['guardian_name', 'TEXT'], ['guardian_phone', 'TEXT'], ['profile_completed', 'INTEGER NOT NULL DEFAULT 0'],
|
||||
['province_code', 'TEXT'], ['province_name', 'TEXT'], ['city_code', 'TEXT'], ['city_name', 'TEXT'],
|
||||
['district_code', 'TEXT'], ['district_name', 'TEXT']
|
||||
['district_code', 'TEXT'], ['district_name', 'TEXT'], ['specialty_types', "TEXT NOT NULL DEFAULT '[]'"],
|
||||
['specialty_category', 'TEXT'], ['specialty_type', 'TEXT'],
|
||||
['specialty_certificate', 'TEXT'], ['policy_eligibility', 'TEXT']
|
||||
]);
|
||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT'], ['feature_score', 'REAL NOT NULL DEFAULT 0']]);
|
||||
ensureColumns('exams', [
|
||||
['pass_policy', "TEXT NOT NULL DEFAULT 'rank_percent'"], ['pass_value', 'REAL NOT NULL DEFAULT 60'],
|
||||
['archived_at', 'TEXT'], ['archived_by', 'TEXT']
|
||||
@@ -68,6 +82,32 @@ export function createSqliteAdapter(context) {
|
||||
ensureColumns('admit_card_subjects', [
|
||||
['building', "TEXT NOT NULL DEFAULT ''"], ['floor', "TEXT NOT NULL DEFAULT ''"]
|
||||
]);
|
||||
if (tableExists('users')) {
|
||||
const usersSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'users'").get()?.sql || '';
|
||||
if (!usersSql.includes('admission_school')) {
|
||||
connection.exec(`
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE users_v18 (
|
||||
id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, candidate_number TEXT UNIQUE, password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate', 'admission_school')),
|
||||
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL, class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), must_change_password INTEGER NOT NULL DEFAULT 0 CHECK (must_change_password IN (0, 1)),
|
||||
totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1)), totp_secret_encrypted TEXT,
|
||||
totp_recovery_codes TEXT NOT NULL DEFAULT '[]', totp_last_used_step INTEGER, archived_at TEXT,
|
||||
archived_by TEXT REFERENCES users_v18(id) ON DELETE RESTRICT, display_name TEXT NOT NULL, created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
INSERT INTO users_v18 SELECT id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
|
||||
active, must_change_password, COALESCE(totp_enabled, 0), totp_secret_encrypted, COALESCE(totp_recovery_codes, '[]'),
|
||||
totp_last_used_step, archived_at, archived_by, display_name, created_at FROM users;
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_v18 RENAME TO users;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
}
|
||||
if (tableExists('workflow_definitions')) {
|
||||
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
|
||||
if (!definitionSql.includes('candidate_account_batch')) {
|
||||
@@ -246,12 +286,36 @@ export function createSqliteAdapter(context) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 16 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 17) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 17 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 18) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 18, app_version = 18 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 19) {
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 19, app_version = 19 WHERE id = 1').run();
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 20) {
|
||||
connection.exec(`
|
||||
ALTER TABLE users ADD COLUMN totp_enabled INTEGER NOT NULL DEFAULT 0 CHECK (totp_enabled IN (0, 1));
|
||||
ALTER TABLE users ADD COLUMN totp_secret_encrypted TEXT;
|
||||
ALTER TABLE users ADD COLUMN totp_recovery_codes TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE users ADD COLUMN totp_last_used_step INTEGER;
|
||||
UPDATE schema_metadata SET schema_version = 17 WHERE id = 1;
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN IMMEDIATE;
|
||||
CREATE TABLE admission_records_v20 (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('setting', 'plan', 'preference', 'placement', 'notification', 'indicator_qualification', 'qualification_publication', 'cutoff_publication')),
|
||||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
school_id TEXT REFERENCES schools(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
INSERT INTO admission_records_v20 SELECT id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at FROM admission_records;
|
||||
DROP TABLE admission_records;
|
||||
ALTER TABLE admission_records_v20 RENAME TO admission_records;
|
||||
CREATE INDEX idx_admission_records_lookup ON admission_records(kind, exam_id, school_id, user_id, status);
|
||||
UPDATE schema_metadata SET schema_version = 20, app_version = 20 WHERE id = 1;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
`);
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||
@@ -414,7 +478,7 @@ export function createSqliteAdapter(context) {
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 17, ?, ?, ?)
|
||||
VALUES (1, 18, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
@@ -427,7 +491,16 @@ export function createSqliteAdapter(context) {
|
||||
|
||||
synchronizeSqlitePartitions(connection);
|
||||
|
||||
const dataVersion = connection.prepare('PRAGMA data_version');
|
||||
const stateCache = createStateCache({
|
||||
load: () => stateFromRows(readSqliteRows(connection)),
|
||||
version: () => Number(dataVersion.get().data_version)
|
||||
});
|
||||
|
||||
const transaction = async operations => {
|
||||
// Call this before the first possible await so request-local mutations of
|
||||
// the previous snapshot can never be observed by another request.
|
||||
stateCache.invalidate();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
for (const item of operations) connection.prepare(item.sql).run(...item.params);
|
||||
@@ -436,12 +509,14 @@ export function createSqliteAdapter(context) {
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
stateCache.invalidate();
|
||||
}
|
||||
};
|
||||
return createRepository({
|
||||
client: 'sqlite',
|
||||
location: path,
|
||||
read: async () => stateFromRows(readSqliteRows(connection)),
|
||||
read: stateCache.read,
|
||||
transaction,
|
||||
close: async () => connection.close()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
function cacheDuration(value, fallback = 30000) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.min(Math.trunc(parsed), 3600000) : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the materialized application state in process instead of rebuilding it
|
||||
* from every relational table for every HTTP request. Writes explicitly
|
||||
* invalidate the snapshot; an optional version reader also detects changes made
|
||||
* by another database connection.
|
||||
*/
|
||||
export function createStateCache({ load, version, maxAgeMs = process.env.DATABASE_STATE_CACHE_TTL_MS }) {
|
||||
// A database-native version token is stronger than a timer, so SQLite can
|
||||
// keep the snapshot indefinitely while still observing external commits.
|
||||
const ttlMs = version ? 0 : cacheDuration(maxAgeMs);
|
||||
let snapshot = null;
|
||||
let snapshotVersion;
|
||||
let loadedAt = 0;
|
||||
let generation = 0;
|
||||
let pending = null;
|
||||
|
||||
function invalidate() {
|
||||
generation += 1;
|
||||
snapshot = null;
|
||||
snapshotVersion = undefined;
|
||||
loadedAt = 0;
|
||||
pending = null;
|
||||
}
|
||||
|
||||
async function read() {
|
||||
const currentVersion = version ? await version() : undefined;
|
||||
const freshByAge = !ttlMs || Date.now() - loadedAt < ttlMs;
|
||||
if (snapshot && freshByAge && (!version || currentVersion === snapshotVersion)) return snapshot;
|
||||
|
||||
if (pending && (!version || pending.version === currentVersion)) return pending.promise;
|
||||
|
||||
const startedGeneration = generation;
|
||||
const loading = Promise.resolve().then(load).then(state => {
|
||||
if (generation === startedGeneration) {
|
||||
snapshot = state;
|
||||
snapshotVersion = currentVersion;
|
||||
loadedAt = Date.now();
|
||||
}
|
||||
return state;
|
||||
});
|
||||
pending = { version: currentVersion, promise: loading };
|
||||
try {
|
||||
return await loading;
|
||||
} finally {
|
||||
if (pending?.promise === loading) pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
return { read, invalidate };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const CURRENT_SCHEMA_VERSION = 20;
|
||||
+733
-45
@@ -1,5 +1,8 @@
|
||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
|
||||
import { activePreference, admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionRoundPublications, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
export function createAdminRoutes(context) {
|
||||
const {
|
||||
@@ -55,7 +58,7 @@ export function createAdminRoutes(context) {
|
||||
randomBytes,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
authState,
|
||||
buildWorkbook,
|
||||
buildCenterMaterialsWorkbook,
|
||||
hasExcelResource,
|
||||
@@ -108,11 +111,217 @@ export function createAdminRoutes(context) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function admissionChoiceView(db, examId, choice) {
|
||||
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||
const categories = admissionRecords(db, 'plan', examId)
|
||||
.filter(plan => plan.schoolId === choice.schoolId)
|
||||
.flatMap(plan => plan.payload?.categories || []);
|
||||
const category = categories.find(item => item.code === choice.categoryCode)
|
||||
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||
return {
|
||||
...choice,
|
||||
schoolCode: choice.schoolCode || school?.code || '',
|
||||
schoolName: choice.schoolName || school?.name || '',
|
||||
categoryName: choice.categoryName || category?.name || ''
|
||||
};
|
||||
}
|
||||
|
||||
function admissionPreferenceSnapshotRows(db) {
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const statusLabel = { unfilled: '尚未填报', submitted: '已填报', locked: '已锁定', unavailable: '成绩未齐', ineligible: '不可补录' };
|
||||
const rows = [];
|
||||
for (const setting of admissionRecords(db, 'setting').filter(item => item.payload?.enabled)) {
|
||||
const exam = examById.get(setting.examId) || {};
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const maxSubmissions = Number(setting.payload?.maxSubmissions || 3);
|
||||
const registrations = db.registrations.filter(item => item.examId === setting.examId && item.status === 'approved');
|
||||
for (const registration of registrations) {
|
||||
const account = accountById.get(registration.userId);
|
||||
const profile = profileByUserId.get(registration.userId);
|
||||
if (!account?.active || account.role !== 'candidate' || !profile) continue;
|
||||
const preference = activePreference(db, setting.examId, registration.userId, round);
|
||||
const blockingPlacement = setting.status === 'supplementary'
|
||||
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === registration.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
|
||||
: null;
|
||||
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
||||
const status = blockingPlacement ? 'ineligible'
|
||||
: preference && submissionCount >= maxSubmissions ? 'locked'
|
||||
: preference ? 'submitted'
|
||||
: candidateTotalScore(db, setting.examId, registration.userId) == null ? 'unavailable' : 'unfilled';
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const sourceSchool = schoolById.get(profile.schoolId) || {};
|
||||
const schoolClass = classById.get(profile.classId) || {};
|
||||
const indicator = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === registration.userId && item.status === 'confirmed');
|
||||
rows.push({
|
||||
id: `${setting.examId}:${round}:${registration.userId}`,
|
||||
examId: setting.examId,
|
||||
examCode: exam.code || '',
|
||||
examName: exam.name || '',
|
||||
round,
|
||||
phase: setting.status,
|
||||
status,
|
||||
fillStatus: statusLabel[status],
|
||||
lockStatus: status === 'locked' ? '已锁定' : status === 'ineligible' ? '不可填报' : '未锁定',
|
||||
submissionCount,
|
||||
maxSubmissions,
|
||||
submittedAt: preference?.payload?.submittedAt || preference?.updatedAt || '',
|
||||
sourceSchoolId: sourceSchool.id || '',
|
||||
sourceSchoolCode: sourceSchool.code || '',
|
||||
sourceSchoolName: sourceSchool.name || '',
|
||||
className: schoolClass.name || profile.className || '',
|
||||
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生',
|
||||
indicatorStatus: indicator ? (indicator.payload?.eligible ? '有资格' : '无资格') : '未确认',
|
||||
candidate: { userId: registration.userId, registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '' },
|
||||
choices: (preference?.payload?.choices || []).map(choice => admissionChoiceView(db, setting.examId, choice))
|
||||
});
|
||||
}
|
||||
}
|
||||
return rows.sort((left, right) => left.examName.localeCompare(right.examName, 'zh-CN') || left.candidate.registrationNumber.localeCompare(right.candidate.registrationNumber));
|
||||
}
|
||||
|
||||
function admissionPlacementLedgerRows(db) {
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const reportingByPlacement = new Map();
|
||||
const reportingRecords = admissionRecords(db, 'notification')
|
||||
.filter(item => item.payload?.type === 'admission_reporting')
|
||||
.sort((left, right) => new Date(left.updatedAt || left.createdAt) - new Date(right.updatedAt || right.createdAt));
|
||||
for (const record of reportingRecords) for (const row of record.payload?.rows || []) reportingByPlacement.set(row.placementId, row);
|
||||
const admissionStatusLabels = { school_review: '学校审核中', admitted: '拟录取', withdrawal_pending: '退档待审', final: '正式录取', withdrawn: '已退档', forfeited: '未报到放弃' };
|
||||
const reportingStatusLabels = { reported: '已报到', not_reported: '未报到', pending: '待确认' };
|
||||
return admissionRecords(db, 'placement').map(placement => {
|
||||
const exam = examById.get(placement.examId) || {};
|
||||
const school = schoolById.get(placement.schoolId) || {};
|
||||
const account = accountById.get(placement.userId) || {};
|
||||
const profile = profileByUserId.get(placement.userId) || {};
|
||||
const sourceSchool = schoolById.get(profile.schoolId) || {};
|
||||
const schoolClass = classById.get(profile.classId) || {};
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const reporting = reportingByPlacement.get(placement.id);
|
||||
return {
|
||||
...placement,
|
||||
examName: exam.name || '', examCode: exam.code || '',
|
||||
schoolName: school.name || '', schoolCode: school.code || '',
|
||||
sourceSchoolId: sourceSchool.id || '', sourceSchoolName: sourceSchool.name || '', sourceSchoolCode: sourceSchool.code || '',
|
||||
className: schoolClass.name || profile.className || '',
|
||||
candidate: { registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '', idNumberMasked: maskId(profile.idNumber), specialtyLabel: specialtyLabel(qualification.category, qualification.type) || '普通生' },
|
||||
reportingStatus: reporting?.status || (placement.status === 'final' ? 'pending' : ''),
|
||||
reportingStatusLabel: reportingStatusLabels[reporting?.status] || (placement.status === 'final' ? '待确认' : '—'),
|
||||
admissionStatusLabel: admissionStatusLabels[placement.status] || placement.status
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function filterAdmissionLedgerRows(rows, searchParams) {
|
||||
const query = String(searchParams.get('q') || '').trim().toLowerCase();
|
||||
const filters = {
|
||||
examId: searchParams.get('examId') || '',
|
||||
schoolId: searchParams.get('schoolId') || '',
|
||||
sourceSchoolId: searchParams.get('sourceSchoolId') || '',
|
||||
status: searchParams.get('status') || '',
|
||||
round: searchParams.get('round') || ''
|
||||
};
|
||||
return rows.filter(item => {
|
||||
const haystack = JSON.stringify(item).toLowerCase();
|
||||
return Object.entries(filters).every(([key, value]) => !value || String(item[key] ?? item.payload?.[key] ?? '') === value)
|
||||
&& (!query || query.split(/\s+/).every(word => haystack.includes(word)));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAdmissionCategories(input) {
|
||||
return (Array.isArray(input) ? input : []).map((item, index) => ({
|
||||
code: cleanText(item.code || `category_${index + 1}`, 40), name: cleanText(item.name, 80),
|
||||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))), specialtyCategory: cleanText(item.specialtyCategory, 30), specialtyType: cleanText(item.specialtyType, 80),
|
||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||||
})).filter(item => item.sourceSchoolId && item.quota > 0)
|
||||
})).filter(item => item.code && item.name && item.quota > 0);
|
||||
}
|
||||
|
||||
function systemPublications(db) {
|
||||
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
|
||||
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
|
||||
const view = (record, sourceType, category, title, publishedAt, summary) => ({
|
||||
id: record.id,
|
||||
sourceType,
|
||||
category,
|
||||
title,
|
||||
summary,
|
||||
author: '系统自动发布',
|
||||
publishedAt,
|
||||
visible: record.payload?.publicVisible !== false,
|
||||
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
|
||||
});
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved').map(item => view(
|
||||
item,
|
||||
'plan',
|
||||
'招生计划',
|
||||
`${examName(item.examId)} · ${schoolName(item.schoolId)}招生计划公示`,
|
||||
item.payload?.reviewedAt || item.updatedAt,
|
||||
'审核通过后由系统生成,当前页面仅控制是否在公开通知目录显示。'
|
||||
));
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published').map(item => view(
|
||||
item,
|
||||
'qualification',
|
||||
'指标资格',
|
||||
`${examName(item.examId)} · ${schoolName(item.schoolId)}指标分配资格公示`,
|
||||
item.payload?.publishedAt || item.updatedAt,
|
||||
'资格确认完成后由系统生成,内容随资格确认结果更新。'
|
||||
));
|
||||
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
|
||||
{ ...item, id: item.sourceRecordId || item.id },
|
||||
'admission',
|
||||
'录取名单',
|
||||
`${examName(item.examId)}第 ${item.round} 轮录取名单公示`,
|
||||
item.publishedAt,
|
||||
`第 ${item.round} 轮录取通知书签发后由系统自动生成,共 ${item.rows.length} 人。`
|
||||
));
|
||||
const virtualSourceIds = new Set(admissionRoundPublications(db).filter(item => item.virtual).map(item => item.sourceRecordId));
|
||||
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && !virtualSourceIds.has(item.id)).map(item => view(
|
||||
item,
|
||||
'admission',
|
||||
'录取名单',
|
||||
`${examName(item.examId)}最终录取名单`,
|
||||
item.payload?.completedAt || item.updatedAt,
|
||||
'录取结束后由系统生成,内容取自最终录取结果。'
|
||||
));
|
||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
|
||||
item,
|
||||
'cutoff',
|
||||
'录取分数线',
|
||||
`${examName(item.examId)}录取分数线`,
|
||||
item.payload?.publishedAt || item.updatedAt,
|
||||
'录取结束后由系统生成,内容取自各招生类别最低录取分数。'
|
||||
));
|
||||
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting').map(item => ({
|
||||
id: item.id, sourceType: 'reporting', category: item.category, title: item.title, summary: item.summary,
|
||||
author: item.author, publishedAt: item.publishAt, visible: item.visible, status: item.status
|
||||
}));
|
||||
return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
|
||||
.sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
||||
}
|
||||
|
||||
async function handleAdmin(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admin/')) return false;
|
||||
const user = await requireUser(request, response, 'admin');
|
||||
if (!user) return true;
|
||||
const db = await readDb();
|
||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
|
||||
if (!requestedExamId) {
|
||||
if (!requirePermission(user, response, 'results.read')) return true;
|
||||
return sendJson(response, 200, { ok: true, selectedExamId: '', results: [], appeals: [], registrations: [], exams: [], resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||
}
|
||||
}
|
||||
const db = request.authDb || await readDb();
|
||||
if (request.method === 'DELETE' && /^\/api\/admin\/(?:admins|candidates|candidate-accounts)(?:\/|$)/.test(pathname)) {
|
||||
return sendError(response, 405, '账户不得删除;考生账户请由校方归档,管理员账户可停用');
|
||||
}
|
||||
@@ -128,6 +337,363 @@ export function createAdminRoutes(context) {
|
||||
classes: db.classes
|
||||
});
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/indicator-qualifications' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
|
||||
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isSourceSchool);
|
||||
if (!school) return sendError(response, 403, '当前学校未设置为生源学校');
|
||||
const exams = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
|
||||
const exam = db.exams.find(item => item.id === setting.examId);
|
||||
return { ...setting, exam: exam ? publicExam(exam) : null, qualificationStatus: sourceSchoolQualificationStatus(db, setting.examId, school.id) };
|
||||
}).filter(item => item.exam);
|
||||
return sendJson(response, 200, { ok: true, school, exams });
|
||||
}
|
||||
const qualificationBulkMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/bulk$/);
|
||||
if (qualificationBulkMatch && request.method === 'PUT') {
|
||||
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以批量确认指标分配资格');
|
||||
const setting = admissionSetting(db, qualificationBulkMatch[1]);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报');
|
||||
const body = await readJson(request);
|
||||
if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择批量设置为有资格或无资格');
|
||||
const userIds = [...new Set((Array.isArray(body.userIds) ? body.userIds : []).map(value => cleanText(value, 64)).filter(Boolean))];
|
||||
if (!userIds.length) return sendError(response, 400, '请至少选择一名考生');
|
||||
const profiles = userIds.map(userId => db.candidateProfiles.find(item => item.userId === userId && item.schoolId === user.schoolId && item.profileCompleted));
|
||||
if (profiles.some(item => !item) || profiles.some(profile => !db.users.some(item => item.id === profile.userId && item.role === 'candidate' && item.active))) return sendError(response, 403, '批量名单中包含不属于本校的有效考生');
|
||||
const now = nowIso();
|
||||
const existing = new Map(admissionRecords(db, 'indicator_qualification', setting.examId).map(item => [item.userId, item]));
|
||||
const qualifications = profiles.map(profile => {
|
||||
const qualification = existing.get(profile.userId) || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now };
|
||||
Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } });
|
||||
return qualification;
|
||||
});
|
||||
const replacementIds = new Set(qualifications.map(item => item.id));
|
||||
const nextDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !replacementIds.has(item.id)), ...qualifications] };
|
||||
const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId);
|
||||
const records = [...qualifications];
|
||||
if (status.complete) {
|
||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
||||
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
|
||||
records.push(publication);
|
||||
}
|
||||
await database.saveAdmissionRecords(records, logAction(db, user, '批量确认指标分配资格', `${qualifications.length} 人 · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||
return sendJson(response, 200, { ok: true, count: qualifications.length, qualificationStatus: status, published: status.complete });
|
||||
}
|
||||
const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/);
|
||||
if (qualificationMatch && request.method === 'PUT') {
|
||||
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
|
||||
const setting = admissionSetting(db, qualificationMatch[1]);
|
||||
const profile = db.candidateProfiles.find(item => item.userId === qualificationMatch[2] && item.schoolId === user.schoolId && item.profileCompleted);
|
||||
const account = db.users.find(item => item.id === profile?.userId && item.role === 'candidate' && item.active);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报');
|
||||
if (!profile || !account) return sendError(response, 404, '本校有效考生不存在');
|
||||
const body = await readJson(request);
|
||||
if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择有或无指标分配资格');
|
||||
const now = nowIso();
|
||||
const existing = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === profile.userId);
|
||||
const qualification = existing || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now };
|
||||
Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } });
|
||||
const nextRecords = [...db.admissionRecords.filter(item => item.id !== qualification.id), qualification];
|
||||
const nextDb = { ...db, admissionRecords: nextRecords };
|
||||
const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId);
|
||||
const records = [qualification];
|
||||
if (status.complete) {
|
||||
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
|
||||
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
|
||||
Object.assign(publication, { status: 'published', updatedAt: now, payload: { ...publication.payload, publishedAt: now, rows: status.rows } });
|
||||
records.push(publication);
|
||||
}
|
||||
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
|
||||
return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
|
||||
}
|
||||
|
||||
const admissionLedgerExportMatch = pathname.match(/^\/api\/admin\/admissions\/(preferences|placements)\/export$/);
|
||||
if (admissionLedgerExportMatch && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以导出志愿与录取台账');
|
||||
const searchParams = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams;
|
||||
const kind = admissionLedgerExportMatch[1];
|
||||
const selectedExam = db.exams.find(item => item.id === searchParams.get('examId'));
|
||||
const subtitle = `${selectedExam?.name || '全部考试'}|按当前筛选条件导出|生成时间 ${new Date().toLocaleString('zh-CN', { hour12: false })}`;
|
||||
if (kind === 'preferences') {
|
||||
const snapshots = filterAdmissionLedgerRows(admissionPreferenceSnapshotRows(db), searchParams);
|
||||
const rows = snapshots.flatMap(item => {
|
||||
const choices = item.choices.length ? item.choices : [null];
|
||||
return choices.map((choice, index) => ({
|
||||
examCode: item.examCode, examName: item.examName, round: item.round,
|
||||
fillStatus: item.fillStatus, lockStatus: item.lockStatus,
|
||||
submissionCount: item.submissionCount, maxSubmissions: item.maxSubmissions,
|
||||
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
|
||||
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
|
||||
specialty: item.specialty, indicatorStatus: item.indicatorStatus,
|
||||
preferenceOrder: choice ? Number(choice.order || index + 1) : '',
|
||||
preferenceType: choice ? (choice.preferenceType === 'indicator' ? '指标志愿' : '普通志愿') : '',
|
||||
targetSchoolCode: choice?.schoolCode || '', targetSchoolName: choice?.schoolName || '', categoryName: choice?.categoryName || choice?.categoryCode || '',
|
||||
submittedAt: item.submittedAt
|
||||
}));
|
||||
});
|
||||
const buffer = Buffer.from(await buildWorkbook('admission_preferences', rows, { subtitle }));
|
||||
return sendWorkbook(response, buffer, `志愿填报实时台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
const placements = filterAdmissionLedgerRows(admissionPlacementLedgerRows(db), searchParams);
|
||||
const rows = placements.map(item => ({
|
||||
examCode: item.examCode, examName: item.examName, round: Number(item.payload?.round || 1),
|
||||
candidateNumber: item.candidate.registrationNumber, candidateName: item.candidate.name,
|
||||
sourceSchoolCode: item.sourceSchoolCode, sourceSchoolName: item.sourceSchoolName, className: item.className,
|
||||
specialty: item.candidate.specialtyLabel,
|
||||
culturalScore: Number(item.payload?.culturalScore ?? item.payload?.totalScore ?? 0),
|
||||
featureScore: Number(item.payload?.featureScore || 0), totalScore: Number(item.payload?.totalScore || 0),
|
||||
preferenceOrder: Number(item.payload?.preferenceOrder || 0),
|
||||
admissionSchoolCode: item.schoolCode, admissionSchoolName: item.schoolName,
|
||||
categoryName: item.payload?.categoryName || '',
|
||||
quotaBucket: String(item.payload?.quotaBucket || '').startsWith('indicator') ? '指标分配' : '普通计划',
|
||||
admissionStatus: item.admissionStatusLabel, reportingStatus: item.reportingStatusLabel,
|
||||
noticeNumber: item.payload?.noticeNumber || '', withdrawalReason: item.payload?.withdrawalReason || item.payload?.reportingNote || '',
|
||||
updatedAt: item.updatedAt || item.payload?.finalizedAt || ''
|
||||
}));
|
||||
const buffer = Buffer.from(await buildWorkbook('admission_placements', rows, { subtitle }));
|
||||
return sendWorkbook(response, buffer, `招生录取情况台账-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const schoolById = new Map(db.schools.map(item => [item.id, item]));
|
||||
const userById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: examById.get(setting.examId) }));
|
||||
const plans = admissionRecords(db, 'plan').map(plan => ({ ...plan, schoolName: schoolById.get(plan.schoolId)?.name || '', examName: examById.get(plan.examId)?.name || '', remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
|
||||
const placements = admissionPlacementLedgerRows(db);
|
||||
const preferences = admissionRecords(db, 'preference').map(preference => {
|
||||
const account = userById.get(preference.userId) || {};
|
||||
const profile = profileByUserId.get(preference.userId) || {};
|
||||
const plansForExam = admissionRecords(db, 'plan', preference.examId);
|
||||
return { ...preference, candidate: { registrationNumber: account.candidateNumber, name: profile.name }, choices: (preference.payload?.choices || []).map(choice => {
|
||||
const school = schoolById.get(choice.schoolId);
|
||||
const categories = plansForExam.filter(plan => plan.schoolId === choice.schoolId).flatMap(plan => plan.payload?.categories || []);
|
||||
const category = categories.find(item => item.code === choice.categoryCode)
|
||||
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
|
||||
}) };
|
||||
});
|
||||
const preferenceRows = admissionPreferenceSnapshotRows(db);
|
||||
const schoolAccounts = db.users.filter(item => item.role === 'admission_school').map(item => {
|
||||
const school = schoolById.get(item.schoolId);
|
||||
return { ...safeUser(item), active: item.active !== false, createdAt: item.createdAt, schoolName: school?.name || '', schoolCode: school?.code || '' };
|
||||
});
|
||||
const reportingRequests = admissionRecords(db, 'notification').filter(item => item.userId == null && item.payload?.type === 'admission_reporting').map(item => ({ ...item, schoolName: schoolById.get(item.schoolId)?.name || '', examName: examById.get(item.examId)?.name || '', progress: admissionPlanProgress(db, admissionRecords(db, 'plan', item.examId).find(plan => plan.schoolId === item.schoolId) || { examId: item.examId, schoolId: item.schoolId, payload: { categories: [] } }) }));
|
||||
return sendJson(response, 200, { ok: true, settings, plans, preferences, preferenceRows, placements, reportingRequests, schoolAccounts, schools: db.schools.filter(item => item.active), admissionSchools: db.schools.filter(item => item.active && item.isAdmissionSchool), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool), exams: db.exams.filter(item => !item.archivedAt) });
|
||||
}
|
||||
if (pathname === '/api/admin/admission-school-accounts' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以创建招生学校账号');
|
||||
const body = await readJson(request);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
|
||||
const username = cleanText(body.username, 80);
|
||||
const password = String(body.password || '');
|
||||
if (!school || !username || password.length < 8) return sendError(response, 400, '请选择学校,并填写登录账号和至少 8 位密码');
|
||||
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '登录账号已存在');
|
||||
const account = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admission_school', schoolId: school.id, displayName: cleanText(body.displayName, 80) || `${school.name}招生办`, active: true, createdAt: nowIso() };
|
||||
await database.createAdmissionSchoolAccount(account, logAction(db, user, '创建招生学校账号', `${school.name} · ${username}`));
|
||||
return sendJson(response, 201, { ok: true, account: safeUser(account) });
|
||||
}
|
||||
const admissionAccountMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)$/);
|
||||
if (admissionAccountMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以维护招生学校账号');
|
||||
const target = db.users.find(item => item.id === admissionAccountMatch[1] && item.role === 'admission_school');
|
||||
if (!target) return sendError(response, 404, '招生学校账号不存在');
|
||||
const body = await readJson(request);
|
||||
target.active = body.active == null ? target.active : Boolean(body.active);
|
||||
target.displayName = cleanText(body.displayName || target.displayName, 80);
|
||||
await database.updateAdmin(target, false, logAction(db, user, target.active ? '启用招生学校账号' : '停用招生学校账号', `${target.displayName} · ${target.username}`));
|
||||
if (!target.active) await authState.deleteUserSessions(target.id);
|
||||
return sendJson(response, 200, { ok: true, account: { ...safeUser(target), active: target.active } });
|
||||
}
|
||||
const admissionAccountResetMatch = pathname.match(/^\/api\/admin\/admission-school-accounts\/([^/]+)\/reset-password$/);
|
||||
if (admissionAccountResetMatch && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以重置招生学校账号密码');
|
||||
const target = db.users.find(item => item.id === admissionAccountResetMatch[1] && item.role === 'admission_school');
|
||||
if (!target) return sendError(response, 404, '招生学校账号不存在');
|
||||
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
|
||||
target.passwordHash = hashPassword(temporaryPassword);
|
||||
target.active = true;
|
||||
await database.updateAdmin(target, true, logAction(db, user, '重置招生学校账号密码', `${target.displayName} · ${target.username}`));
|
||||
await authState.deleteUserSessions(target.id);
|
||||
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
|
||||
}
|
||||
const settingMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/setting$/);
|
||||
if (settingMatch && request.method === 'PUT') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以设置志愿填报');
|
||||
const exam = db.exams.find(item => item.id === settingMatch[1] && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||||
const body = await readJson(request);
|
||||
const now = nowIso();
|
||||
const setting = admissionSetting(db, exam.id) || { id: uid('admission_setting'), kind: 'setting', examId: exam.id, userId: user.id, schoolId: null, createdAt: now };
|
||||
const requestedStatus = admissionPhases.has(body.status) ? body.status : 'draft';
|
||||
const manualPhases = ['draft', 'filling', 'closed'];
|
||||
const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft');
|
||||
setting.status = status;
|
||||
setting.updatedAt = now;
|
||||
setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), maxSubmissions: Math.min(50, Math.max(1, Math.trunc(Number(body.maxSubmissions || 3)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
|
||||
if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间');
|
||||
await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`));
|
||||
return sendJson(response, 200, { ok: true, setting });
|
||||
}
|
||||
if (pathname === '/api/admin/admission-plans' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以代招生学校上传计划');
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isAdmissionSchool);
|
||||
const categories = normalizeAdmissionCategories(body.categories);
|
||||
if (!exam || !school || !categories.length) return sendError(response, 400, '请选择考试、招生学校并填写有效计划');
|
||||
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
|
||||
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
|
||||
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标');
|
||||
if (categories.some(item => item.indicatorAllocations.reduce((sum, allocation) => sum + allocation.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过类别计划人数');
|
||||
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
|
||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||
if (admissionRecords(db, 'placement', exam.id).some(item => item.schoolId === school.id && item.status !== 'withdrawn')) return sendError(response, 409, '已经产生投档记录,不能再修改该校本轮招生计划');
|
||||
const now = nowIso();
|
||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, schoolId: school.id, createdAt: now };
|
||||
Object.assign(plan, { userId: user.id, status: 'approved', updatedAt: now, payload: { ...plan.payload, categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewedBy: user.displayName, reviewedAt: now, reviewNote: '超级管理员代上传并审核通过' } });
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, '代上传招生计划', `${school.name} · ${exam.name}`));
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
const planReviewMatch = pathname.match(/^\/api\/admin\/admission-plans\/([^/]+)$/);
|
||||
if (planReviewMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核招生计划');
|
||||
const plan = admissionRecords(db, 'plan').find(item => item.id === planReviewMatch[1]);
|
||||
if (!plan) return sendError(response, 404, '招生计划不存在');
|
||||
const body = await readJson(request);
|
||||
if (!['approved', 'rejected'].includes(body.status)) return sendError(response, 400, '审核状态无效');
|
||||
plan.status = body.status;
|
||||
plan.updatedAt = nowIso();
|
||||
plan.payload = { ...plan.payload, reviewNote: cleanText(body.reviewNote, 500), reviewedBy: user.displayName, reviewedAt: plan.updatedAt };
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, body.status === 'approved' ? '审核通过招生计划' : '退回招生计划', plan.id));
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, plan });
|
||||
}
|
||||
const actionMatch = pathname.match(/^\/api\/admin\/admissions\/([^/]+)\/(match|finalize|supplementary)$/);
|
||||
if (actionMatch && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以执行投档与录取操作');
|
||||
const setting = admissionSetting(db, actionMatch[1]);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开启志愿填报');
|
||||
const action = actionMatch[2];
|
||||
if (action === 'match') {
|
||||
if (!['closed', 'supplementary'].includes(setting.status)) return sendError(response, 409, '请先结束当前填报阶段再投档');
|
||||
const placements = buildVolunteerPlacements(db, setting, { uid, nowIso });
|
||||
setting.status = 'school_review'; setting.updatedAt = nowIso(); setting.payload.progress = `第 ${setting.payload.round || 1} 轮投档完成,${placements.length} 人已发送招生学校审核`;
|
||||
await database.saveAdmissionRecords([setting, ...placements], logAction(db, user, '执行分数优先志愿投档', `${setting.examId} · ${placements.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, setting, placementCount: placements.length });
|
||||
}
|
||||
if (action === 'finalize') {
|
||||
if (setting.status !== 'school_review') return sendError(response, 409, '只有招生学校审核阶段可以签发录取通知书并开启报到');
|
||||
const placements = admissionRecords(db, 'placement', setting.examId);
|
||||
if (placements.some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有招生学校审核或退档申请未处理');
|
||||
const now = nowIso();
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const admitted = assignAdmissionNoticeNumbers(db, placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now, payload: { ...item.payload, finalizedRound: round } })));
|
||||
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
|
||||
const reportingRecords = approvedPlans(db, setting.examId).map(plan => {
|
||||
const existing = admissionReportingRecord(db, setting.examId, plan.schoolId, round);
|
||||
const schoolPlacements = admitted.filter(item => item.schoolId === plan.schoolId);
|
||||
const previousRows = existing?.payload?.rows || [];
|
||||
const previousIds = new Set(previousRows.map(item => item.placementId));
|
||||
const rows = [...previousRows, ...schoolPlacements.filter(item => !previousIds.has(item.id)).map(item => ({ placementId: item.id, status: 'pending', note: '', updatedAt: now, source: 'system' }))];
|
||||
const record = existing || { id: uid('admission_reporting'), kind: 'notification', examId: setting.examId, userId: null, schoolId: plan.schoolId, createdAt: now };
|
||||
return { ...record, status: 'draft', updatedAt: now, payload: { type: 'admission_reporting', round, rows, openedAt: now, openedBy: user.displayName } };
|
||||
});
|
||||
const publicationDb = { ...db, admissionRecords: db.admissionRecords.map(item => admitted.find(entry => entry.id === item.id) || item) };
|
||||
const existingPublication = admissionRecords(db, 'notification', setting.examId).find(item => item.userId == null && item.payload?.type === 'admission_round_publication' && Number(item.payload?.round || 1) === round);
|
||||
const admissionPublication = existingPublication || { id: uid('admission_round_publication'), kind: 'notification', examId: setting.examId, userId: null, schoolId: null, createdAt: now };
|
||||
Object.assign(admissionPublication, { status: 'published', updatedAt: now, payload: { ...admissionPublication.payload, type: 'admission_round_publication', round, publishedAt: now, publishedBy: user.displayName, rows: publicAdmissionRows(publicationDb, setting.examId, { round }) } });
|
||||
setting.status = 'reporting'; setting.updatedAt = now; setting.payload = { ...setting.payload, roundPublishedAt: now, progress: `第 ${round} 轮录取结束,${admitted.length} 名考生已签发通知书,录取名单已公示,招生学校正在登记报到` };
|
||||
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords, admissionPublication], logAction(db, user, '签发录取通知书并公示本轮录取名单', `${setting.examId} · 第 ${round} 轮 · ${admitted.length} 人`));
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length, publicationId: admissionPublication.id });
|
||||
}
|
||||
if (action === 'supplementary') return sendError(response, 409, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启');
|
||||
const body = await readJson(request);
|
||||
const now = nowIso();
|
||||
if (admissionRecords(db, 'placement', setting.examId).some(item => ['school_review', 'withdrawal_pending'].includes(item.status))) return sendError(response, 409, '仍有学校审核或退档申请待处理,暂不能开启补录');
|
||||
setting.status = 'supplementary'; setting.updatedAt = now; setting.payload = { ...setting.payload, round: Number(setting.payload.round || 1) + 1, preferenceStart: cleanText(body.preferenceStart, 35) || now, preferenceEnd: cleanText(body.preferenceEnd, 35), progress: '招生计划未满,补录志愿填报进行中' };
|
||||
await database.saveAdmissionRecord(setting, logAction(db, user, '开启补录', `${setting.examId} · 第 ${setting.payload.round} 轮`));
|
||||
return sendJson(response, 200, { ok: true, setting });
|
||||
}
|
||||
const withdrawalMatch = pathname.match(/^\/api\/admin\/admission-withdrawals\/([^/]+)$/);
|
||||
const reportingReviewMatch = pathname.match(/^\/api\/admin\/admission-reporting\/([^/]+)$/);
|
||||
if (reportingReviewMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审批学校报到与补录决定');
|
||||
const record = admissionRecords(db, 'notification').find(item => item.id === reportingReviewMatch[1] && item.userId == null && item.payload?.type === 'admission_reporting');
|
||||
if (!record || record.status !== 'pending_approval') return sendError(response, 404, '待审批的报到与补录决定不存在');
|
||||
const body = await readJson(request);
|
||||
const approvalNote = cleanText(body.approvalNote, 500);
|
||||
const now = nowIso();
|
||||
if (body.approved !== true) {
|
||||
record.status = 'rejected'; record.updatedAt = now; record.payload = { ...record.payload, approvalNote, rejectedAt: now, rejectedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '退回报到与补录决定', `${record.schoolId} · 第 ${record.payload?.round || 1} 轮`));
|
||||
return sendJson(response, 200, { ok: true, record });
|
||||
}
|
||||
const supplement = record.payload?.supplementDecision === 'supplement';
|
||||
const preferenceEnd = cleanText(body.preferenceEnd, 35);
|
||||
if (supplement && (!preferenceEnd || new Date(preferenceEnd).getTime() <= Date.now())) return sendError(response, 400, '批准补录时必须设置晚于当前时间的补录志愿截止时间');
|
||||
record.status = 'approved'; record.updatedAt = now; record.payload = { ...record.payload, approvalNote, approvedAt: now, approvedBy: user.displayName, approvedPreferenceEnd: supplement ? preferenceEnd : '' };
|
||||
const reportPlan = admissionRecords(db, 'plan', record.examId).find(item => item.schoolId === record.schoolId) || { examId: record.examId, schoolId: record.schoolId, payload: { categories: [] } };
|
||||
const reportDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
record.payload.statistics = admissionPlanProgress(reportDb, reportPlan);
|
||||
const changedPlacements = [];
|
||||
if (supplement) {
|
||||
const notReported = new Set((record.payload?.rows || []).filter(item => item.status === 'not_reported').map(item => item.placementId));
|
||||
for (const placement of admissionRecords(db, 'placement', record.examId).filter(item => notReported.has(item.id) && item.schoolId === record.schoolId && item.status === 'final')) {
|
||||
changedPlacements.push({ ...placement, status: 'forfeited', updatedAt: now, payload: { ...placement.payload, forfeitedAt: now, forfeitedReason: '未按规定完成报到,学校补录申请已获批准' } });
|
||||
}
|
||||
}
|
||||
const replacements = new Map([[record.id, record], ...changedPlacements.map(item => [item.id, item])]);
|
||||
let nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => replacements.get(item.id) || item) };
|
||||
replacements.set(record.id, record);
|
||||
nextDb = { ...nextDb, admissionRecords: nextDb.admissionRecords.map(item => replacements.get(item.id) || item) };
|
||||
const setting = admissionSetting(nextDb, record.examId);
|
||||
const round = Number(record.payload?.round || 1);
|
||||
const plans = approvedPlans(nextDb, record.examId);
|
||||
const currentRecords = plans.map(item => admissionReportingRecord(nextDb, record.examId, item.schoolId, round));
|
||||
const allApproved = currentRecords.length > 0 && currentRecords.every(item => item?.status === 'approved');
|
||||
const recordsToSave = [record, ...changedPlacements];
|
||||
let completed = false;
|
||||
if (allApproved && setting) {
|
||||
const supplementRecords = currentRecords.filter(item => item.payload?.supplementDecision === 'supplement');
|
||||
setting.updatedAt = now;
|
||||
if (supplementRecords.length) {
|
||||
const supplementEnd = supplementRecords.map(item => item.payload?.approvedPreferenceEnd).filter(Boolean).sort().at(-1);
|
||||
setting.status = 'supplementary';
|
||||
setting.payload = { ...setting.payload, round: round + 1, preferenceStart: now, preferenceEnd: supplementEnd, progress: `第 ${round + 1} 轮补录志愿填报进行中,截止 ${new Date(supplementEnd).toLocaleString('zh-CN')}` };
|
||||
} else {
|
||||
setting.status = 'completed';
|
||||
setting.payload = { ...setting.payload, completedAt: now, progress: '全部招生学校报到情况与补录决定已审批,录取工作完成' };
|
||||
const cutoffRows = admissionCutoffRows(nextDb, setting.examId);
|
||||
const existingCutoff = admissionRecords(nextDb, 'cutoff_publication', setting.examId)[0];
|
||||
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
|
||||
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { ...cutoffPublication.payload, publishedAt: now, rows: cutoffRows } });
|
||||
recordsToSave.push(cutoffPublication);
|
||||
completed = true;
|
||||
}
|
||||
recordsToSave.push(setting);
|
||||
}
|
||||
await database.saveAdmissionRecords(recordsToSave, logAction(db, user, supplement ? '批准补录申请并公开报到情况' : '批准不补录决定并公开报到情况', `${record.schoolId} · 第 ${round} 轮`));
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, record, forfeitedCount: changedPlacements.length, phase: setting?.status, completed });
|
||||
}
|
||||
if (withdrawalMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以审核退档');
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.id === withdrawalMatch[1] && item.status === 'withdrawal_pending');
|
||||
if (!placement) return sendError(response, 404, '待审核退档申请不存在');
|
||||
const body = await readJson(request);
|
||||
placement.status = body.approved === true ? 'withdrawn' : 'admitted';
|
||||
placement.updatedAt = nowIso();
|
||||
placement.payload.withdrawalReviewNote = cleanText(body.reviewNote, 500);
|
||||
await database.saveAdmissionRecord(placement, logAction(db, user, body.approved === true ? '批准退档' : '驳回退档', placement.id));
|
||||
return sendJson(response, 200, { ok: true, placement });
|
||||
}
|
||||
|
||||
const excelMatch = pathname.match(/^\/api\/admin\/excel\/(classes|class_admins|account_quotas|account_results|candidates|payments|centers|results)$/);
|
||||
if (excelMatch && request.method === 'GET') {
|
||||
@@ -140,7 +706,7 @@ export function createAdminRoutes(context) {
|
||||
if (resource === 'results' && !hasPermission(user, 'results.read')) return sendError(response, 403, '当前账号不能导出成绩');
|
||||
const requestUrl = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`);
|
||||
const template = requestUrl.searchParams.get('template') === '1';
|
||||
const rows = template ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
||||
const rows = template && resource !== 'results' ? [] : excelRowsForResource(db, user, resource, requestUrl.searchParams);
|
||||
const subtitle = user.adminLevel === 'super' ? '全部数据范围' : adminScopeLabel(db, user);
|
||||
const buffer = Buffer.from(await buildWorkbook(resource, rows, { template, subtitle }));
|
||||
return sendWorkbook(response, buffer, `${excelResourceNames[resource]}-${template ? '导入模板' : '导出'}-${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
@@ -174,11 +740,14 @@ export function createAdminRoutes(context) {
|
||||
const name = cleanText(body.name, 100);
|
||||
const code = cleanText(body.code, 40).toUpperCase();
|
||||
const address = cleanText(body.address, 200);
|
||||
const isSourceSchool = body.isSourceSchool !== false;
|
||||
const isAdmissionSchool = body.isAdmissionSchool !== false;
|
||||
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
|
||||
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
|
||||
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
|
||||
if (db.schools.some(item => item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
|
||||
if (db.schools.some(item => item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
|
||||
const school = { id: uid('school'), name, code, address, active: body.active !== false };
|
||||
const school = { id: uid('school'), name, code, address, isSourceSchool, isAdmissionSchool, active: body.active !== false };
|
||||
await database.saveSchool(school, true, logAction(db, user, '创建学校', `${name} · ${code}`));
|
||||
return sendJson(response, 201, { ok: true, school });
|
||||
}
|
||||
@@ -191,11 +760,14 @@ export function createAdminRoutes(context) {
|
||||
const name = cleanText(body.name ?? school.name, 100);
|
||||
const code = cleanText(body.code ?? school.code, 40).toUpperCase();
|
||||
const address = cleanText(body.address ?? school.address, 200);
|
||||
const isSourceSchool = body.isSourceSchool == null ? school.isSourceSchool : Boolean(body.isSourceSchool);
|
||||
const isAdmissionSchool = body.isAdmissionSchool == null ? school.isAdmissionSchool : Boolean(body.isAdmissionSchool);
|
||||
if (!name || !code) return sendError(response, 400, '学校名称和学校代码不能为空');
|
||||
if (!isSourceSchool && !isAdmissionSchool) return sendError(response, 400, '学校至少应设置为生源校或招生校');
|
||||
if (!/^[A-Z0-9_-]+$/.test(code)) return sendError(response, 400, '学校代码只能包含字母、数字、下划线和连字符');
|
||||
if (db.schools.some(item => item.id !== school.id && item.code.toLowerCase() === code.toLowerCase())) return sendError(response, 409, '学校代码已存在');
|
||||
if (db.schools.some(item => item.id !== school.id && item.name.toLowerCase() === name.toLowerCase())) return sendError(response, 409, '学校名称已存在');
|
||||
Object.assign(school, { name, code, address, active: body.active == null ? school.active : Boolean(body.active) });
|
||||
Object.assign(school, { name, code, address, isSourceSchool, isAdmissionSchool, active: body.active == null ? school.active : Boolean(body.active) });
|
||||
await database.saveSchool(school, false, logAction(db, user, '维护学校', `${name} · ${code} · ${school.active ? '启用' : '停用'}`));
|
||||
return sendJson(response, 200, { ok: true, school });
|
||||
}
|
||||
@@ -212,6 +784,7 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
if (pathname === '/api/admin/classes' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以新增本校班级');
|
||||
if (!db.schools.some(item => item.id === user.schoolId && item.active && item.isSourceSchool)) return sendError(response, 409, '当前学校未设置为已启用的生源校');
|
||||
const body = await readJson(request);
|
||||
const name = cleanText(body.name, 100); const grade = cleanText(body.grade, 60);
|
||||
if (!name || !grade) return sendError(response, 400, '年级和班级名称不能为空');
|
||||
@@ -243,7 +816,7 @@ export function createAdminRoutes(context) {
|
||||
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||
className: db.classes.find(schoolClass => schoolClass.id === item.classId)?.name || ''
|
||||
}));
|
||||
return sendJson(response, 200, { ok: true, admins, schools: db.schools, classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
||||
return sendJson(response, 200, { ok: true, admins, schools: db.schools.filter(item => item.isSourceSchool), classes: db.classes, selfRegistrationEnabled: db.settings.selfRegistrationEnabled });
|
||||
}
|
||||
if (pathname === '/api/admin/admins' && request.method === 'POST') {
|
||||
const body = await readJson(request);
|
||||
@@ -256,7 +829,7 @@ export function createAdminRoutes(context) {
|
||||
if (db.users.some(item => item.username.toLowerCase() === username.toLowerCase())) return sendError(response, 409, '该登录账号已存在');
|
||||
const schoolId = adminLevel === 'super' ? null : user.adminLevel === 'school' ? user.schoolId : cleanText(body.schoolId, 64);
|
||||
const classId = adminLevel === 'class' ? cleanText(body.classId, 64) : null;
|
||||
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId)) return sendError(response, 400, '校级和班级管理员必须绑定学校');
|
||||
if (adminLevel !== 'super' && !db.schools.some(item => item.id === schoolId && item.active && item.isSourceSchool)) return sendError(response, 400, '校级和班级管理员必须绑定已启用的生源校');
|
||||
if (adminLevel === 'class' && !db.classes.some(item => item.id === classId && item.schoolId === schoolId)) return sendError(response, 400, '请选择该学校下的有效班级');
|
||||
const created = { id: uid('usr'), username, passwordHash: hashPassword(password), role: 'admin', adminLevel, schoolId, classId, displayName, active: true, createdAt: nowIso() };
|
||||
const log = logAction(db, user, '创建管理员', `${displayName} · ${adminLevelNames[adminLevel]}`);
|
||||
@@ -265,19 +838,34 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
const adminMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)$/);
|
||||
if (adminMatch && request.method === 'PATCH') {
|
||||
if (user.adminLevel !== 'school') return sendError(response, 403, '只有校级管理员可以维护本校班级管理员');
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能维护管理员');
|
||||
const body = await readJson(request);
|
||||
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && item.adminLevel === 'class' && item.schoolId === user.schoolId);
|
||||
if (!target) return sendError(response, 404, '班级管理员不存在');
|
||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === user.schoolId);
|
||||
if (!schoolClass) return sendError(response, 400, '请选择本校有效班级');
|
||||
const target = db.users.find(item => item.id === adminMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId)));
|
||||
if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围');
|
||||
if (target.id === user.id && body.active === false) return sendError(response, 409, '不能停用当前正在使用的管理员账户');
|
||||
const schoolClass = target.adminLevel === 'class' ? db.classes.find(item => item.id === cleanText(body.classId || target.classId, 64) && item.schoolId === target.schoolId) : null;
|
||||
if (target.adminLevel === 'class' && !schoolClass) return sendError(response, 400, '请选择该管理员所属学校的有效班级');
|
||||
const password = String(body.password || '');
|
||||
if (password && password.length < 8) return sendError(response, 400, '重置密码至少 8 位');
|
||||
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass.id, active: body.active == null ? target.active : Boolean(body.active) });
|
||||
Object.assign(target, { displayName: cleanText(body.displayName || target.displayName, 50), classId: schoolClass?.id || target.classId || null, active: body.active == null ? target.active : Boolean(body.active) });
|
||||
if (password) target.passwordHash = hashPassword(password);
|
||||
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护班级管理员', `${target.displayName} · ${schoolClass.name}`));
|
||||
await database.updateAdmin(target, Boolean(password), logAction(db, user, '维护管理员账户', `${target.displayName} · ${adminLevelNames[target.adminLevel]} · ${target.active ? '启用' : '停用'}`));
|
||||
if (!target.active || password) await authState.deleteUserSessions(target.id);
|
||||
return sendJson(response, 200, { ok: true, admin: safeUser(target) });
|
||||
}
|
||||
const adminPasswordResetMatch = pathname.match(/^\/api\/admin\/admins\/([^/]+)\/reset-password$/);
|
||||
if (adminPasswordResetMatch && request.method === 'POST') {
|
||||
if (!['super', 'school'].includes(user.adminLevel)) return sendError(response, 403, '当前账号不能重置管理员密码');
|
||||
const target = db.users.find(item => item.id === adminPasswordResetMatch[1] && item.role === 'admin' && (user.adminLevel === 'super' || (item.adminLevel === 'class' && item.schoolId === user.schoolId)));
|
||||
if (!target) return sendError(response, 404, '管理员账户不存在或不在当前管理范围');
|
||||
if (target.id === user.id) return sendError(response, 409, '当前账号请在“账户安全”中修改自己的密码');
|
||||
const temporaryPassword = `Reset-${randomBytes(7).toString('base64url')}`;
|
||||
target.passwordHash = hashPassword(temporaryPassword);
|
||||
target.active = true;
|
||||
await database.updateAdmin(target, true, logAction(db, user, '重置管理员密码', `${target.displayName} · ${target.username}`));
|
||||
await authState.deleteUserSessions(target.id);
|
||||
return sendJson(response, 200, { ok: true, username: target.username, temporaryPassword });
|
||||
}
|
||||
if (pathname === '/api/admin/settings/self-registration' && request.method === 'PUT') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
@@ -294,7 +882,7 @@ export function createAdminRoutes(context) {
|
||||
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
|
||||
.map(item => candidateAccountBatchView(db, item));
|
||||
const classes = db.classes.filter(item => item.active && (user.adminLevel === 'super' || item.schoolId === user.schoolId));
|
||||
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active) });
|
||||
return sendJson(response, 200, { ok: true, batches, classes, schools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
||||
}
|
||||
if (pathname === '/api/admin/candidate-account-batches' && request.method === 'POST') {
|
||||
if (user.adminLevel !== 'school' || !requirePermission(user, response, 'candidates.write')) return user.adminLevel === 'school' ? true : sendError(response, 403, '批量报名号由校级管理员发起申领');
|
||||
@@ -629,6 +1217,10 @@ export function createAdminRoutes(context) {
|
||||
const candidates = db.candidateProfiles.filter(profile => profileInScope(user, profile)).map(profile => {
|
||||
const instance = pendingWorkflow(db, 'profile_change', profile.id) || db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||
const account = db.users.find(item => item.id === profile.userId);
|
||||
const registrations = db.registrations
|
||||
.filter(item => item.userId === profile.userId)
|
||||
.map(item => examRegistrationView(db, item))
|
||||
.sort((left, right) => new Date(right.createdAt || 0) - new Date(left.createdAt || 0));
|
||||
return {
|
||||
...profile,
|
||||
idNumberMasked: profile.idNumber.startsWith('PENDING-') ? '待考生补充' : maskId(profile.idNumber),
|
||||
@@ -638,6 +1230,7 @@ export function createAdminRoutes(context) {
|
||||
accountArchived: Boolean(account?.archivedAt),
|
||||
archivedAt: account?.archivedAt || null,
|
||||
archivedByName: db.users.find(item => item.id === account?.archivedBy)?.displayName || '',
|
||||
registrations,
|
||||
workflow: workflowView(db, instance)
|
||||
};
|
||||
});
|
||||
@@ -668,7 +1261,7 @@ export function createAdminRoutes(context) {
|
||||
await database.updateCandidateArchives(targets, logAction(db, user, archived ? '批量归档考生账户' : '批量恢复考生账户', `${scopeLabel} · ${targets.length} 个账户`));
|
||||
if (archived && targets.length) {
|
||||
const targetUserIds = new Set(targets.map(item => item.id));
|
||||
for (const [token, session] of sessions) if (targetUserIds.has(session.userId)) sessions.delete(token);
|
||||
await authState.deleteUsersSessions(targetUserIds);
|
||||
}
|
||||
return sendJson(response, 200, { ok: true, archived, count: targets.length, scopeLabel });
|
||||
}
|
||||
@@ -683,7 +1276,7 @@ export function createAdminRoutes(context) {
|
||||
target.passwordHash = hashPassword(temporaryPassword);
|
||||
target.mustChangePassword = true;
|
||||
await database.changePassword(target, logAction(db, user, '重置考生密码', `${target.candidateNumber} · ${profile.name}`));
|
||||
for (const [token, session] of sessions) if (session.userId === target.id) sessions.delete(token);
|
||||
await authState.deleteUserSessions(target.id);
|
||||
return sendJson(response, 200, { ok: true, candidateNumber: target.candidateNumber, temporaryPassword });
|
||||
}
|
||||
const candidateMatch = pathname.match(/^\/api\/admin\/candidates\/([^/]+)$/);
|
||||
@@ -1061,7 +1654,7 @@ export function createAdminRoutes(context) {
|
||||
const notices = db.notices
|
||||
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
|
||||
.map(noticeForClient);
|
||||
return sendJson(response, 200, { ok: true, notices });
|
||||
return sendJson(response, 200, { ok: true, notices, publications: systemPublications(db) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/notices') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
@@ -1073,8 +1666,26 @@ export function createAdminRoutes(context) {
|
||||
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
|
||||
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
|
||||
await database.createNotice(notice, log);
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
|
||||
}
|
||||
const publicationMatch = pathname.match(/^\/api\/admin\/publications\/(plan|qualification|admission|cutoff|reporting)\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && publicationMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' };
|
||||
const sourceType = publicationMatch[1];
|
||||
const record = sourceType === 'admission'
|
||||
? (db.admissionRecords || []).find(item => item.id === publicationMatch[2] && (item.kind === 'setting' || (item.kind === 'notification' && item.payload?.type === 'admission_round_publication')))
|
||||
: admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2] && (sourceType !== 'reporting' || item.payload?.type === 'admission_reporting'));
|
||||
if (!record) return sendError(response, 404, '系统公示不存在');
|
||||
const body = await readJson(request);
|
||||
if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示');
|
||||
record.payload = { ...record.payload, publicVisible: body.visible };
|
||||
record.updatedAt = nowIso();
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, body.visible ? '显示系统公示' : '隐藏系统公示', `${sourceType} · ${record.id}`));
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, publication: systemPublications({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }).find(item => item.id === record.id && item.sourceType === sourceType) });
|
||||
}
|
||||
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && noticeMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
@@ -1094,6 +1705,7 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
|
||||
await database.updateNotice(notice, log);
|
||||
await cache.invalidate('public');
|
||||
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results/cache/refresh') {
|
||||
@@ -1108,53 +1720,67 @@ export function createAdminRoutes(context) {
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/results') {
|
||||
if (!requirePermission(user, response, 'results.read')) return true;
|
||||
const requestedExamId = new URL(request.url, `http://${request.headers.host || '127.0.0.1'}`).searchParams.get('examId');
|
||||
const selectedExam = db.exams.find(item => item.id === requestedExamId);
|
||||
if (!selectedExam) return sendError(response, 404, '请选择有效的考试后再读取成绩');
|
||||
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
|
||||
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
|
||||
const registration = db.registrations.find(item => item.id === result.registrationId);
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
|
||||
const account = db.users.find(item => item.id === registration?.userId);
|
||||
const exam = db.exams.find(item => item.id === registration?.examId);
|
||||
const subject = exam?.subjects.find(item => item.id === result.subjectId);
|
||||
const pass = subjectPassEvaluation(db, result, subject);
|
||||
const rank = resultRankInfo(db, result);
|
||||
const selectedScopedRegistrations = scopedRegistrations.filter(item => item.examId === selectedExam.id);
|
||||
if (user.adminLevel !== 'super' && !selectedScopedRegistrations.length) return sendError(response, 404, '该考试不在当前管理范围内');
|
||||
const approved = selectedScopedRegistrations.filter(item => item.status === 'approved');
|
||||
const selectedRegistrationIds = new Set(selectedScopedRegistrations.map(item => item.id));
|
||||
const examRegistrations = db.registrations.filter(item => item.examId === selectedExam.id && item.status === 'approved');
|
||||
const examRegistrationIds = new Set(examRegistrations.map(item => item.id));
|
||||
const examResults = db.results.filter(result => examRegistrationIds.has(result.registrationId));
|
||||
const scoreDb = { ...db, registrations: examRegistrations, results: examResults };
|
||||
const registrationById = new Map(selectedScopedRegistrations.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const classById = new Map(db.classes.map(item => [item.id, item]));
|
||||
const rawScopedResults = examResults.filter(result => selectedRegistrationIds.has(result.registrationId));
|
||||
const results = rawScopedResults.map(result => {
|
||||
const registration = registrationById.get(result.registrationId);
|
||||
const profile = profileByUserId.get(registration?.userId);
|
||||
const account = accountById.get(registration?.userId);
|
||||
const subject = selectedExam.subjects.find(item => item.id === result.subjectId);
|
||||
const pass = subjectPassEvaluation(scoreDb, result, subject);
|
||||
return {
|
||||
...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent,
|
||||
...result, grade: result.published ? pass.grade : result.grade, rank: pass.rank, cohortSize: pass.cohortSize, rankPercent: pass.rankPercent,
|
||||
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
|
||||
schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
|
||||
examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name,
|
||||
schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '',
|
||||
examId: selectedExam.id, examCode: selectedExam.code, examName: selectedExam.name, subjectName: subject?.name,
|
||||
fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
|
||||
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject),
|
||||
qualified: pass.qualified
|
||||
};
|
||||
});
|
||||
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => {
|
||||
const result = results.find(item => item.id === instance.businessId);
|
||||
const resultById = new Map(results.map(result => [result.id, result]));
|
||||
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && resultById.has(instance.businessId)).map(instance => {
|
||||
const result = resultById.get(instance.businessId);
|
||||
const workflow = workflowView(db, instance);
|
||||
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
|
||||
});
|
||||
const approved = scopedRegistrations.filter(item => item.status === 'approved');
|
||||
const exams = db.exams.map(exam => {
|
||||
const examRegistrations = approved.filter(item => item.examId === exam.id);
|
||||
const examResults = results.filter(item => item.examId === exam.id);
|
||||
const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean);
|
||||
const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0);
|
||||
const scored = examResults.length;
|
||||
if (exam.id !== selectedExam.id) return publicExam(exam);
|
||||
const summaries = approved.map(item => examResultSummary(scoreDb, item)).filter(Boolean);
|
||||
const enrolledSubjects = approved.reduce((sum, item) => sum + item.subjectIds.length, 0);
|
||||
const scored = rawScopedResults.length;
|
||||
return {
|
||||
...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored,
|
||||
published: examResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
|
||||
...publicExam(exam), registrationCount: approved.length, enrolledSubjects, scored,
|
||||
published: rawScopedResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
|
||||
complete: summaries.filter(item => item.complete).length,
|
||||
qualified: summaries.filter(item => item.complete && item.qualified === true).length,
|
||||
unqualified: summaries.filter(item => item.complete && item.qualified === false).length,
|
||||
appeals: appeals.filter(item => item.result?.examId === exam.id).length
|
||||
appeals: appeals.length
|
||||
};
|
||||
}).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super');
|
||||
});
|
||||
const registrations = user.adminLevel === 'super' ? approved.map(item => {
|
||||
const view = examRegistrationView(db, item);
|
||||
const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId);
|
||||
const account = db.users.find(accountItem => accountItem.id === item.userId);
|
||||
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' };
|
||||
const profile = profileByUserId.get(item.userId);
|
||||
const account = accountById.get(item.userId);
|
||||
const specialty = resolveProfileSpecialty(profile || {});
|
||||
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: classById.get(profile?.classId)?.name || profile?.grade || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' };
|
||||
}) : [];
|
||||
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||
return sendJson(response, 200, { ok: true, selectedExamId: selectedExam.id, results, appeals, registrations, exams, resultCache: { enabled: cache.enabled, status: cache.status } });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
@@ -1162,6 +1788,68 @@ export function createAdminRoutes(context) {
|
||||
const result = await commitResultImport(db, user, body.rows);
|
||||
return sendJson(response, 200, { ok: true, ...result });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results/bulk') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
const subject = exam?.subjects.find(item => item.id === cleanText(body.subjectId, 64));
|
||||
if (!exam || !subject) return sendError(response, 400, '请选择有效且未归档的考试科目');
|
||||
const sourceRows = [];
|
||||
const seen = new Set();
|
||||
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
|
||||
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved' && item.subjectIds.includes(subject.id));
|
||||
if (!registration || seen.has(registration.id)) return sendError(response, 400, `第 ${index + 1} 条考生成绩无效或重复`);
|
||||
seen.add(registration.id);
|
||||
const account = db.users.find(item => item.id === registration.userId);
|
||||
sourceRows.push({
|
||||
__row: index + 3,
|
||||
candidateNumber: account?.candidateNumber || registration.registrationNumber || '',
|
||||
examCode: exam.code,
|
||||
subjectName: subject.name,
|
||||
score: row.score,
|
||||
published: body.published === true
|
||||
});
|
||||
}
|
||||
if (!sourceRows.length) return sendError(response, 400, '没有需要保存的成绩');
|
||||
const result = await commitResultImport(db, user, sourceRows);
|
||||
return sendJson(response, 200, { ok: true, published: body.published === true, ...result });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/feature-scores/bulk') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 400, '请选择有效且未归档的考试');
|
||||
const entries = [];
|
||||
const seen = new Set();
|
||||
for (const [index, row] of (Array.isArray(body.rows) ? body.rows : []).entries()) {
|
||||
const registration = db.registrations.find(item => item.id === row.registrationId && item.examId === exam.id && item.status === 'approved');
|
||||
const featureScore = Number(row.featureScore);
|
||||
if (!registration || seen.has(registration.id)) return sendError(response, 400, `第 ${index + 1} 条考生记录无效或重复`);
|
||||
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, `第 ${index + 1} 条特征分必须在 0—1000 之间`);
|
||||
seen.add(registration.id);
|
||||
registration.featureScore = Number(featureScore.toFixed(2));
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
entries.push({ registration, log: logAction(db, user, '批量登记特征分', `${profile?.name || registration.userId} · ${exam.name} · ${registration.featureScore}`) });
|
||||
}
|
||||
if (!entries.length) return sendError(response, 400, '没有需要保存的特征分');
|
||||
await database.updateFeatureScores(entries);
|
||||
return sendJson(response, 200, { ok: true, count: entries.length });
|
||||
}
|
||||
const featureScoreMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/feature-score$/);
|
||||
if (request.method === 'PATCH' && featureScoreMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const registration = db.registrations.find(item => item.id === featureScoreMatch[1] && item.status === 'approved');
|
||||
if (!registration) return sendError(response, 404, '已通过的报名记录不存在');
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
if (exam?.archivedAt) return sendError(response, 409, '该考试已归档,特征分已永久锁定');
|
||||
const body = await readJson(request);
|
||||
const featureScore = Number(body.featureScore);
|
||||
if (!Number.isFinite(featureScore) || featureScore < 0 || featureScore > 1000) return sendError(response, 400, '特征分必须在 0—1000 之间');
|
||||
registration.featureScore = Number(featureScore.toFixed(2));
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
await database.updateFeatureScore(registration, logAction(db, user, '登记特征分', `${profile?.name || registration.userId} · ${exam?.name || registration.examId} · ${registration.featureScore}`));
|
||||
return sendJson(response, 200, { ok: true, registration });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admin/results') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const body = await readJson(request);
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import { admissionPlanProgress, admissionRecords, admissionReportingRecord, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
|
||||
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
function normalizeCategories(input, cleanText) {
|
||||
const source = Array.isArray(input) ? input : [];
|
||||
return source.map((item, index) => ({
|
||||
code: cleanText(item.code || `category_${index + 1}`, 40),
|
||||
name: cleanText(item.name, 80),
|
||||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
|
||||
specialtyCategory: cleanText(item.specialtyCategory, 30),
|
||||
specialtyType: cleanText(item.specialtyType, 80),
|
||||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||||
})).filter(item => item.sourceSchoolId && item.quota > 0)
|
||||
})).filter(item => item.code && item.name && item.quota > 0);
|
||||
}
|
||||
|
||||
export function createAdmissionRoutes(context) {
|
||||
const { database, readDb, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, buildWorkbook, parseWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction, documentVerificationSecret, admissionNoticeCode, safeCodeEqual } = context;
|
||||
|
||||
const reportingStatusByCode = { Y: 'reported', N: 'not_reported', P: 'pending' };
|
||||
const reportingCodeByStatus = { reported: 'Y', not_reported: 'N', pending: 'P' };
|
||||
|
||||
function reportingRows(db, plan, record) {
|
||||
const exam = db.exams.find(item => item.id === plan.examId) || {};
|
||||
const school = db.schools.find(item => item.id === plan.schoolId) || {};
|
||||
const rowByPlacement = new Map((record?.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
const placementIds = new Set((record?.payload?.rows || []).map(item => item.placementId));
|
||||
const round = Number(record?.payload?.round || 1);
|
||||
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.status === 'final' && (placementIds.has(item.id) || (!record && Number(item.payload?.finalizedRound || 1) === round)));
|
||||
return placements.map(placement => {
|
||||
const account = db.users.find(item => item.id === placement.userId) || {};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||
const row = rowByPlacement.get(placement.id) || {};
|
||||
return {
|
||||
placementId: placement.id,
|
||||
noticeNumber: placement.payload?.noticeNumber || '',
|
||||
candidateNumber: account.candidateNumber || '',
|
||||
name: profile.name || account.displayName || '',
|
||||
idNumberMasked: maskId(profile.idNumber),
|
||||
examCode: exam.code || '',
|
||||
schoolCode: school.code || '',
|
||||
categoryName: placement.payload?.categoryName || '',
|
||||
status: row.status || 'pending',
|
||||
statusCode: reportingCodeByStatus[row.status] || 'P',
|
||||
note: row.note || '',
|
||||
updatedAt: row.updatedAt || null
|
||||
};
|
||||
}).sort((left, right) => left.candidateNumber.localeCompare(right.candidateNumber));
|
||||
}
|
||||
|
||||
function reportingBatch(db, plan, record) {
|
||||
const exam = db.exams.find(item => item.id === plan.examId) || {};
|
||||
return { id: record?.id || '', exam: { id: exam.id, code: exam.code, name: exam.name }, round: Number(record?.payload?.round || 1), status: record?.status || 'not_started', rows: reportingRows(db, plan, record), progress: admissionPlanProgress(db, plan), supplementDecision: record?.payload?.supplementDecision || '', decisionNote: record?.payload?.decisionNote || '', approvalNote: record?.payload?.approvalNote || '', updatedAt: record?.updatedAt || null };
|
||||
}
|
||||
|
||||
function editableReportingRecord(db, examId, schoolId) {
|
||||
const setting = admissionRecords(db, 'setting', examId)[0];
|
||||
const record = admissionReportingRecord(db, examId, schoolId, Number(setting?.payload?.round || 1)) || admissionReportingRecord(db, examId, schoolId);
|
||||
return { setting, record };
|
||||
}
|
||||
|
||||
function reportingScanTarget(db, school, rawCode) {
|
||||
const match = String(rawCode || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
|
||||
if (!match) return { error: [400, '未识别到有效的录取通知书防伪码'] };
|
||||
const code = match[0];
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {})));
|
||||
if (!placement) return { error: [404, '该二维码不属于本校有效录取通知书'] };
|
||||
const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, placement.examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return { error: [409, '该考生不在当前可维护的报到批次'] };
|
||||
return { code, placement, plan, record };
|
||||
}
|
||||
|
||||
async function handleAdmission(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/admission/')) return false;
|
||||
const user = await requireUser(request, response, 'admission_school');
|
||||
if (!user) return true;
|
||||
const db = request.authDb || await readDb();
|
||||
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
||||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||||
|
||||
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
||||
const plans = approvedPlans(db).filter(item => item.schoolId === school.id).map(item => ({ ...item, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', progress: admissionPlanProgress(db, item) }));
|
||||
const notifications = systemNotificationItems(db).filter(item => item.visible && (!item.schoolId || item.schoolId === school.id)).slice(0, 6).map(item => ({ ...item, id: item.noticeId }));
|
||||
return sendJson(response, 200, { ok: true, school, plans, notifications, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/plans') {
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
|
||||
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/plans') {
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||||
const categories = normalizeCategories(body.categories, cleanText);
|
||||
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
|
||||
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
|
||||
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
|
||||
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标');
|
||||
if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过该类别计划人数');
|
||||
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
|
||||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||||
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
|
||||
const now = nowIso();
|
||||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now };
|
||||
Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } });
|
||||
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
|
||||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/notice-template') {
|
||||
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template');
|
||||
const template = record?.payload?.template || {
|
||||
eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书',
|
||||
body: '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。',
|
||||
footer: '请妥善保管本通知书,报到时出示。', primaryColor: '#8d2028', accentColor: '#c9a45b'
|
||||
};
|
||||
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt), template, updatedAt: record?.updatedAt || null });
|
||||
}
|
||||
if (request.method === 'PUT' && pathname === '/api/admission/notice-template') {
|
||||
const body = await readJson(request);
|
||||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64)) || db.exams.find(item => !item.archivedAt) || db.exams[0];
|
||||
if (!exam) return sendError(response, 409, '系统中还没有可关联的考试,暂时无法保存模板');
|
||||
const template = {
|
||||
eyebrow: cleanText(body.eyebrow || 'ADMISSION NOTICE', 60),
|
||||
title: cleanText(body.title || '录 取 通 知 书', 80),
|
||||
body: cleanText(body.body, 1600), footer: cleanText(body.footer, 300),
|
||||
primaryColor: /^#[0-9a-f]{6}$/i.test(body.primaryColor) ? body.primaryColor : '#8d2028',
|
||||
accentColor: /^#[0-9a-f]{6}$/i.test(body.accentColor) ? body.accentColor : '#c9a45b'
|
||||
};
|
||||
if (!template.body) return sendError(response, 400, '请填写录取通知书正文');
|
||||
const now = nowIso();
|
||||
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template')
|
||||
|| { id: uid('notice_template'), kind: 'notification', examId: exam.id, userId: null, schoolId: school.id, status: 'template', createdAt: now };
|
||||
Object.assign(record, { examId: exam.id, updatedAt: now, payload: { template, updatedBy: user.displayName } });
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '保存录取通知书模板', school.name));
|
||||
return sendJson(response, 200, { ok: true, template, updatedAt: now });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/reporting') {
|
||||
const plans = approvedPlans(db).filter(item => item.schoolId === school.id);
|
||||
const batches = plans.map(plan => reportingBatch(db, plan, admissionReportingRecord(db, plan.examId, school.id)));
|
||||
return sendJson(response, 200, { ok: true, school, batches });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/reporting/export') {
|
||||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record) return sendError(response, 404, '当前考试还没有可维护的报到批次');
|
||||
const rows = reportingRows(db, plan, record).map(item => ({
|
||||
noticeNumber: item.noticeNumber, candidateNumber: item.candidateNumber, name: item.name,
|
||||
examCode: item.examCode, schoolCode: item.schoolCode, categoryName: item.categoryName,
|
||||
reportingStatusCode: item.statusCode, reportingNote: item.note
|
||||
}));
|
||||
const buffer = Buffer.from(await buildWorkbook('admission_reporting', rows, { subtitle: `${record.payload?.round || 1} 轮|${school.name}` }));
|
||||
return sendWorkbook(response, buffer, `${record.payload?.round || 1}轮-${school.name}-考生报到状态.xlsx`);
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/import') {
|
||||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能导入暂存数据');
|
||||
const imported = await parseWorkbook('admission_reporting', await readBodyBuffer(request));
|
||||
const available = reportingRows(db, plan, record);
|
||||
const byNotice = new Map(available.map(item => [item.noticeNumber, item]));
|
||||
const byCandidate = new Map(available.map(item => [item.candidateNumber, item]));
|
||||
const seen = new Set();
|
||||
const updates = [];
|
||||
const changes = [];
|
||||
let unchangedCount = 0;
|
||||
const importedAt = nowIso();
|
||||
for (const item of imported) {
|
||||
const noticeNumber = cleanText(item.noticeNumber, 100);
|
||||
const candidateNumber = cleanText(item.candidateNumber, 100);
|
||||
const target = byNotice.get(noticeNumber);
|
||||
if (!target || byCandidate.get(candidateNumber)?.placementId !== target.placementId) return sendError(response, 400, `Excel 第 ${item.__row} 行的通知书编号与报名号不属于本校当前报到批次`);
|
||||
if (seen.has(target.placementId)) return sendError(response, 400, `Excel 第 ${item.__row} 行重复填写同一考生`);
|
||||
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
|
||||
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
|
||||
seen.add(target.placementId);
|
||||
const status = reportingStatusByCode[code];
|
||||
const note = cleanText(item.reportingNote, 300);
|
||||
if (target.status === status && target.note === note) { unchangedCount += 1; continue; }
|
||||
updates.push({ placementId: target.placementId, status, note, updatedAt: importedAt, source: 'excel' });
|
||||
changes.push({ placementId: target.placementId, name: target.name, candidateNumber: target.candidateNumber, noticeNumber: target.noticeNumber, from: target.status, to: status, fromCode: target.statusCode, toCode: code, noteChanged: target.note !== note });
|
||||
}
|
||||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
updates.forEach(item => merged.set(item.placementId, item));
|
||||
if (updates.length) {
|
||||
record.status = 'draft'; record.updatedAt = importedAt; record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · 实际更新 ${updates.length} 人`));
|
||||
}
|
||||
const nextDb = updates.length ? { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) } : db;
|
||||
return sendJson(response, 200, { ok: true, count: imported.length, changedCount: updates.length, unchangedCount, changes, batch: reportingBatch(nextDb, plan, record) });
|
||||
}
|
||||
if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') {
|
||||
const body = await readJson(request);
|
||||
const examId = cleanText(body.examId, 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能修改暂存状态');
|
||||
const available = new Set(reportingRows(db, plan, record).map(item => item.placementId));
|
||||
const updates = (Array.isArray(body.rows) ? body.rows : []).map(item => ({ placementId: cleanText(item.placementId, 64), status: cleanText(item.status, 30), note: cleanText(item.note, 300), updatedAt: nowIso(), source: 'manual' }));
|
||||
if (!updates.length || updates.some(item => !available.has(item.placementId) || !['pending', 'reported', 'not_reported'].includes(item.status))) return sendError(response, 400, '报到暂存数据无效');
|
||||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
updates.forEach(item => merged.set(item.placementId, item));
|
||||
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan-preview') {
|
||||
const body = await readJson(request);
|
||||
const target = reportingScanTarget(db, school, body.code);
|
||||
if (target.error) return sendError(response, ...target.error);
|
||||
const { code, placement, plan, record } = target;
|
||||
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||
const row = reportingRows(db, plan, record).find(item => item.placementId === placement.id);
|
||||
return sendJson(response, 200, { ok: true, code, examId: placement.examId, row });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
|
||||
const body = await readJson(request);
|
||||
const target = reportingScanTarget(db, school, body.code);
|
||||
if (target.error) return sendError(response, ...target.error);
|
||||
const { placement, plan, record } = target;
|
||||
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||||
const status = cleanText(body.status, 30);
|
||||
if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态');
|
||||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||||
const fallbackNote = status === 'reported' ? '扫描录取通知书二维码确认报到' : status === 'not_reported' ? '扫描录取通知书二维码确认未报到' : '扫描录取通知书二维码后暂待确认';
|
||||
merged.set(placement.id, { placementId: placement.id, status, note: cleanText(body.note, 300) || fallbackNote, updatedAt: nowIso(), source: 'qr_scan' });
|
||||
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '扫码确认并暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id} · ${reportingCodeByStatus[status]}`));
|
||||
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/submit') {
|
||||
const body = await readJson(request);
|
||||
const examId = cleanText(body.examId, 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能提交');
|
||||
const rows = reportingRows(db, plan, record);
|
||||
if (rows.some(item => item.status === 'pending')) return sendError(response, 409, `仍有 ${rows.filter(item => item.status === 'pending').length} 名考生待确认,请全部标记后提交`);
|
||||
record.status = 'submitted'; record.updatedAt = nowIso(); record.payload = { ...record.payload, submittedAt: record.updatedAt, submittedBy: user.displayName };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, '提交考生报到情况', `${school.name} · ${rows.length} 人`));
|
||||
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
return sendJson(response, 200, { ok: true, batch: reportingBatch(nextDb, plan, record) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/reporting/decision') {
|
||||
const body = await readJson(request);
|
||||
const examId = cleanText(body.examId, 64);
|
||||
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
|
||||
const { record } = editableReportingRecord(db, examId, school.id);
|
||||
if (!plan || !record || record.status !== 'submitted') return sendError(response, 409, '请先提交本轮考生报到情况');
|
||||
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
|
||||
const progress = admissionPlanProgress(nextDb, plan);
|
||||
const supplement = body.supplement === true && progress.reportingGap > 0;
|
||||
const decisionNote = cleanText(body.decisionNote, 500);
|
||||
if (supplement && decisionNote.length < 4) return sendError(response, 400, '申请补录时请填写至少 4 个字的补录说明');
|
||||
record.status = 'pending_approval'; record.updatedAt = nowIso(); record.payload = { ...record.payload, supplementDecision: supplement ? 'supplement' : 'no_supplement', decisionNote: decisionNote || (progress.reportingGap ? '经学校研究决定,本轮不进行补录。' : '本校招生计划已完成。'), decisionSubmittedAt: record.updatedAt, decisionSubmittedBy: user.displayName, statistics: progress };
|
||||
await database.saveAdmissionRecord(record, logAction(db, user, supplement ? '提交补录申请' : '提交不补录决定', `${school.name} · 缺额 ${progress.reportingGap} 人`));
|
||||
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/placements') {
|
||||
const accountById = new Map(db.users.map(item => [item.id, item]));
|
||||
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));
|
||||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||||
const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item]));
|
||||
const publishedResultsByRegistration = new Map();
|
||||
for (const result of db.results) {
|
||||
if (!result.published) continue;
|
||||
const rows = publishedResultsByRegistration.get(result.registrationId) || [];
|
||||
rows.push(result);
|
||||
publishedResultsByRegistration.set(result.registrationId, rows);
|
||||
}
|
||||
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
||||
const account = accountById.get(item.userId) || {};
|
||||
const profile = profileByUserId.get(item.userId) || {};
|
||||
const exam = examById.get(item.examId);
|
||||
const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`);
|
||||
const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||||
});
|
||||
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
||||
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admission/placements/export') {
|
||||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||||
const exam = db.exams.find(item => item.id === examId);
|
||||
const setting = admissionRecords(db, 'setting', examId)[0];
|
||||
if (!exam || setting?.status !== 'completed') return sendError(response, 409, '录取工作结束后才能下载正式录取名单');
|
||||
const rows = admissionRecords(db, 'placement', examId).filter(item => item.schoolId === school.id && item.status === 'final').map(item => {
|
||||
const account = db.users.find(entry => entry.id === item.userId) || {};
|
||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||
const registration = db.registrations.find(entry => entry.examId === examId && entry.userId === item.userId) || {};
|
||||
const sourceSchool = db.schools.find(entry => entry.id === profile.schoolId) || {};
|
||||
const schoolClass = db.classes.find(entry => entry.id === profile.classId) || {};
|
||||
const qualification = resolveProfileSpecialty(profile);
|
||||
const scoreRows = db.results.filter(entry => entry.registrationId === registration.id && entry.published).map(result => ({ name: exam.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||||
return {
|
||||
candidateNumber: account.candidateNumber || registration.registrationNumber || '', name: profile.name || account.displayName || '', gender: profile.gender || '',
|
||||
idNumber: profile.idNumber || '', phone: profile.phone || '', email: profile.email || '', birthDate: profile.birthDate || '', ethnicity: profile.ethnicity || '', nativePlace: profile.nativePlace || '',
|
||||
sourceSchool: sourceSchool.name || profile.school || '', sourceSchoolCode: sourceSchool.code || '', className: schoolClass.name || profile.grade || '',
|
||||
address: [profile.provinceName, profile.cityName, profile.districtName, profile.address].filter(Boolean).join(' '), guardianName: profile.guardianName || profile.emergencyContact || '', guardianPhone: profile.guardianPhone || profile.emergencyPhone || '',
|
||||
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生', specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '',
|
||||
featureScore: Number(registration.featureScore || 0), subjectScores: scoreRows.map(score => `${score.name} ${score.score}`).join(';'), totalScore: Number(item.payload?.totalScore || 0),
|
||||
admittedSchool: school.name, categoryName: item.payload?.categoryName || '', preferenceOrder: Number(item.payload?.preferenceOrder || 0)
|
||||
};
|
||||
});
|
||||
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
||||
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
|
||||
const body = await readJson(request);
|
||||
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
|
||||
const decision = cleanText(body.decision, 30);
|
||||
const note = cleanText(body.note, 500);
|
||||
if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
|
||||
if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
|
||||
if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
|
||||
const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
|
||||
if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
|
||||
const now = nowIso();
|
||||
for (const placement of placements) {
|
||||
placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
|
||||
placement.payload.schoolDecisionNote = note;
|
||||
if (decision === 'withdraw') placement.payload.withdrawalReason = note;
|
||||
placement.updatedAt = now;
|
||||
}
|
||||
await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length} 人`));
|
||||
return sendJson(response, 200, { ok: true, count: placements.length, decision });
|
||||
}
|
||||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && placementMatch) {
|
||||
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
||||
if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在');
|
||||
const body = await readJson(request);
|
||||
const decision = cleanText(body.decision, 30);
|
||||
const note = cleanText(body.note, 500);
|
||||
if (decision === 'accept') placement.status = 'admitted';
|
||||
else if (decision === 'withdraw') {
|
||||
if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由');
|
||||
placement.status = 'withdrawal_pending';
|
||||
placement.payload.withdrawalReason = note;
|
||||
} else return sendError(response, 400, '请选择接收或申请退档');
|
||||
placement.payload.schoolDecisionNote = note;
|
||||
placement.updatedAt = nowIso();
|
||||
await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`));
|
||||
return sendJson(response, 200, { ok: true, placement });
|
||||
}
|
||||
return sendError(response, 404, '招生学校功能接口不存在');
|
||||
}
|
||||
|
||||
return handleAdmission;
|
||||
}
|
||||
+22
-37
@@ -56,7 +56,7 @@ export function createAuthRoutes(context) {
|
||||
randomBytes,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
authState,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
@@ -64,21 +64,11 @@ export function createAuthRoutes(context) {
|
||||
permissionsByLevel
|
||||
} = context;
|
||||
|
||||
const loginChallenges = new Map();
|
||||
const totpSetups = new Map();
|
||||
const challengeLifetime = 5 * 60 * 1000;
|
||||
|
||||
function pruneTemporaryState() {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of loginChallenges) if (value.expiresAt < now) loginChallenges.delete(key);
|
||||
for (const [key, value] of totpSetups) if (value.expiresAt < now) totpSetups.delete(key);
|
||||
}
|
||||
|
||||
function issueSession(user) {
|
||||
async function issueSession(user) {
|
||||
const token = randomBytes(32).toString('hex');
|
||||
sessions.set(token, { userId: user.id, expiresAt: Date.now() + 8 * 60 * 60 * 1000 });
|
||||
await authState.createSession(token, user.id);
|
||||
const secure = process.env.NODE_ENV === 'production' ? '; Secure' : '';
|
||||
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=28800` };
|
||||
return { token, cookie: `hz_session=${token}; Path=/; HttpOnly; SameSite=Strict${secure}; Max-Age=${authState.sessionTtlSeconds}` };
|
||||
}
|
||||
|
||||
function sessionToken(request) {
|
||||
@@ -115,7 +105,7 @@ export function createAuthRoutes(context) {
|
||||
if (!db.settings.selfRegistrationEnabled) return sendError(response, 403, '当前未开放自主注册,请使用学校下发的报名号和初始密码登录');
|
||||
const schoolId = cleanText(body.schoolId, 64);
|
||||
const classId = cleanText(body.classId, 64);
|
||||
const school = db.schools.find(item => item.id === schoolId && item.active);
|
||||
const school = db.schools.find(item => item.id === schoolId && item.active && item.isSourceSchool);
|
||||
const schoolClass = db.classes.find(item => item.id === classId && item.schoolId === schoolId && item.active);
|
||||
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||
const draftProfile = { schoolId, classId, gender };
|
||||
@@ -127,7 +117,6 @@ export function createAuthRoutes(context) {
|
||||
return sendJson(response, 201, { ok: true, registrationNumber: generated.number, message: '报名号已生成,请使用该号码登录并补全个人信息' });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login') {
|
||||
pruneTemporaryState();
|
||||
const body = await readJson(request);
|
||||
const db = await readDb();
|
||||
const account = cleanText(body.username, 120).toLowerCase();
|
||||
@@ -135,40 +124,38 @@ export function createAuthRoutes(context) {
|
||||
if (!user || user.active === false || user.archivedAt || !verifyPassword(String(body.password || ''), user.passwordHash)) return sendError(response, 401, '账号或密码不正确');
|
||||
if (user.totpEnabled) {
|
||||
const challenge = randomBytes(32).toString('base64url');
|
||||
loginChallenges.set(challenge, { userId: user.id, expiresAt: Date.now() + challengeLifetime, attempts: 0 });
|
||||
await authState.createLoginChallenge(challenge, user.id);
|
||||
return sendJson(response, 200, { ok: true, requiresTotp: true, challenge });
|
||||
}
|
||||
const session = issueSession(user);
|
||||
const session = await issueSession(user);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) }, { 'Set-Cookie': session.cookie });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/login/totp') {
|
||||
pruneTemporaryState();
|
||||
const body = await readJson(request);
|
||||
const challengeKey = String(body.challenge || '');
|
||||
const challenge = loginChallenges.get(challengeKey);
|
||||
if (!challenge || challenge.expiresAt < Date.now() || challenge.attempts >= 5) {
|
||||
loginChallenges.delete(challengeKey);
|
||||
const challenge = await authState.getLoginChallenge(challengeKey);
|
||||
if (!challenge || challenge.attempts >= 5) {
|
||||
await authState.deleteLoginChallenge(challengeKey);
|
||||
return sendError(response, 401, '验证请求已过期,请重新输入账号和密码');
|
||||
}
|
||||
const db = await readDb();
|
||||
const user = db.users.find(item => item.id === challenge.userId);
|
||||
if (!user || !user.totpEnabled || user.active === false || user.archivedAt) {
|
||||
loginChallenges.delete(challengeKey);
|
||||
await authState.deleteLoginChallenge(challengeKey);
|
||||
return sendError(response, 401, '验证请求已失效,请重新登录');
|
||||
}
|
||||
let verified = null;
|
||||
try { verified = verifySecondFactor(user, body.code); } catch {}
|
||||
if (!verified) {
|
||||
challenge.attempts += 1;
|
||||
if (challenge.attempts >= 5) loginChallenges.delete(challengeKey);
|
||||
return sendError(response, 401, challenge.attempts >= 5 ? '验证失败次数过多,请重新登录' : '验证码或恢复码不正确');
|
||||
const failure = await authState.recordLoginChallengeFailure(challengeKey, 5);
|
||||
return sendError(response, 401, failure?.exhausted ? '验证失败次数过多,请重新登录' : failure ? '验证码或恢复码不正确' : '验证请求已过期,请重新输入账号和密码');
|
||||
}
|
||||
if (verified.type === 'totp') user.totpLastUsedStep = verified.step;
|
||||
else user.totpRecoveryCodes = verified.recoveryCodes;
|
||||
const log = verified.type === 'recovery' ? logAction(db, user, '使用 TOTP 恢复码登录', user.username) : null;
|
||||
await database.updateTotpSecurity(user, log);
|
||||
loginChallenges.delete(challengeKey);
|
||||
const session = issueSession(user);
|
||||
await authState.deleteLoginChallenge(challengeKey);
|
||||
const session = await issueSession(user);
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user), usedRecoveryCode: verified.type === 'recovery' }, { 'Set-Cookie': session.cookie });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/change-password') {
|
||||
@@ -197,7 +184,6 @@ export function createAuthRoutes(context) {
|
||||
});
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/setup') {
|
||||
pruneTemporaryState();
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
if (user.mustChangePassword) return sendError(response, 400, '请先修改初始密码,再启用二次验证');
|
||||
@@ -209,18 +195,17 @@ export function createAuthRoutes(context) {
|
||||
const secret = createTotpSecret();
|
||||
const uri = buildOtpAuthUri({ secret, account: user.candidateNumber || user.username, issuer });
|
||||
const token = sessionToken(request);
|
||||
totpSetups.set(token, { userId: user.id, secret, expiresAt: Date.now() + 10 * 60 * 1000 });
|
||||
await authState.createTotpSetup(token, user.id, secret);
|
||||
const qrCode = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 1, width: 240 });
|
||||
return sendJson(response, 200, { ok: true, secret, uri, qrCode, expiresIn: 600 });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/enable') {
|
||||
pruneTemporaryState();
|
||||
const user = await requireUser(request, response);
|
||||
if (!user) return true;
|
||||
const token = sessionToken(request);
|
||||
const setup = totpSetups.get(token);
|
||||
if (!setup || setup.userId !== user.id || setup.expiresAt < Date.now()) {
|
||||
totpSetups.delete(token);
|
||||
const setup = await authState.getTotpSetup(token);
|
||||
if (!setup || setup.userId !== user.id) {
|
||||
await authState.deleteTotpSetup(token);
|
||||
return sendError(response, 400, '绑定信息已过期,请重新开始');
|
||||
}
|
||||
const body = await readJson(request);
|
||||
@@ -234,7 +219,7 @@ export function createAuthRoutes(context) {
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '启用 TOTP 二次验证', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
totpSetups.delete(token);
|
||||
await authState.deleteTotpSetup(token);
|
||||
return sendJson(response, 200, { ok: true, recoveryCodes, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/totp/recovery-codes') {
|
||||
@@ -270,12 +255,12 @@ export function createAuthRoutes(context) {
|
||||
const db = await readDb();
|
||||
const log = logAction(db, user, '关闭 TOTP 二次验证', user.username);
|
||||
await database.updateTotpSecurity(user, log);
|
||||
totpSetups.delete(sessionToken(request));
|
||||
await authState.deleteTotpSetup(sessionToken(request));
|
||||
return sendJson(response, 200, { ok: true, user: safeUser(user) });
|
||||
}
|
||||
if (request.method === 'POST' && pathname === '/api/auth/logout') {
|
||||
const token = parseCookies(request).hz_session;
|
||||
if (token) sessions.delete(token);
|
||||
if (token) await authState.deleteSession(token);
|
||||
return sendJson(response, 200, { ok: true }, { 'Set-Cookie': 'hz_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' });
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota, supplementarySchoolIds } from '../services/volunteer-admission.mjs';
|
||||
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
|
||||
import QRCode from 'qrcode';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
export function createCandidateRoutes(context) {
|
||||
const {
|
||||
@@ -38,6 +42,9 @@ export function createCandidateRoutes(context) {
|
||||
examResultSummary,
|
||||
subjectPassText,
|
||||
resultRankInfo,
|
||||
documentVerificationSecret,
|
||||
scoreReportCode,
|
||||
admissionNoticeCode,
|
||||
subjectPassEvaluation,
|
||||
logAction,
|
||||
excelResourceNames,
|
||||
@@ -48,7 +55,6 @@ export function createCandidateRoutes(context) {
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
@@ -56,6 +62,13 @@ export function createCandidateRoutes(context) {
|
||||
resolveRegion
|
||||
} = context;
|
||||
|
||||
const verificationUrl = (request, code) => {
|
||||
const protocol = String(request.headers['x-forwarded-proto'] || '').split(',')[0].trim() || (process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
||||
const host = request.headers.host || `${process.env.HOST || '127.0.0.1'}:${process.env.PORT || 4173}`;
|
||||
return `${protocol}://${host}/#verify/${encodeURIComponent(code)}`;
|
||||
};
|
||||
const verificationQr = (request, code) => QRCode.toDataURL(verificationUrl(request, code), { errorCorrectionLevel: 'M', margin: 1, width: 320 });
|
||||
|
||||
async function handleCandidate(request, response, pathname) {
|
||||
if (!pathname.startsWith('/api/candidate/')) return false;
|
||||
const user = await requireUser(request, response, 'candidate');
|
||||
@@ -69,24 +82,38 @@ export function createCandidateRoutes(context) {
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
|
||||
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
|
||||
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
|
||||
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5).map(noticeForClient);
|
||||
const notices = [
|
||||
...db.notices.filter(item => item.status === 'published').map(noticeForClient),
|
||||
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
|
||||
].sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
|
||||
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/notices') {
|
||||
const notices = [
|
||||
...db.notices.filter(item => item.status === 'published').map(noticeForClient),
|
||||
...systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }))
|
||||
].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||
return sendJson(response, 200, { ok: true, notices });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/profile') {
|
||||
const instance = pendingWorkflow(db, 'profile_change', profile.id)
|
||||
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
|
||||
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active) });
|
||||
return sendJson(response, 200, { ok: true, profile, workflow: workflowView(db, instance), schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active) });
|
||||
}
|
||||
if (request.method === 'PUT' && pathname === '/api/candidate/profile') {
|
||||
const body = await readJson(request);
|
||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone'];
|
||||
const fields = ['name', 'gender', 'idNumber', 'phone', 'email', 'address', 'emergencyContact', 'emergencyPhone', 'nativePlace', 'birthDate', 'ethnicity', 'postalCode', 'guardianName', 'guardianPhone', 'specialtyCertificate', 'policyEligibility'];
|
||||
for (const field of fields) profile[field] = cleanText(body[field], field === 'address' ? 160 : 80);
|
||||
profile.specialtyCategory = cleanText(body.specialtyCategory, 30);
|
||||
profile.specialtyType = cleanText(body.specialtyType, 40);
|
||||
if (!isValidSpecialty(profile.specialtyCategory, profile.specialtyType)) return sendError(response, 400, '请选择对应的特长生大类和小类');
|
||||
profile.specialtyTypes = profile.specialtyType ? [profile.specialtyType] : [];
|
||||
const region = resolveRegion(body);
|
||||
if (!region) return sendError(response, 400, '请选择有效的省、市和区县');
|
||||
Object.assign(profile, region);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active);
|
||||
const school = db.schools.find(item => item.id === cleanText(body.schoolId, 64) && item.active && item.isSourceSchool);
|
||||
const schoolClass = db.classes.find(item => item.id === cleanText(body.classId, 64) && item.schoolId === school?.id && item.active);
|
||||
if (!school || !schoolClass) return sendError(response, 400, '请选择有效的学校和班级');
|
||||
profile.schoolId = school.id;
|
||||
@@ -146,11 +173,140 @@ export function createCandidateRoutes(context) {
|
||||
appeal: appeal ? { ...appeal, reason: appeal.actions.find(action => action.action === 'submit')?.note || '' } : null
|
||||
};
|
||||
});
|
||||
const summaries = registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects);
|
||||
return { ok: true, results, summaries };
|
||||
const summaries = await Promise.all(registrations.map(registration => examResultSummary(db, registration)).filter(summary => summary?.publishedSubjects).map(async summary => {
|
||||
const registration = registrations.find(item => item.examId === summary.examId);
|
||||
const exam = db.exams.find(item => item.id === summary.examId);
|
||||
const reportResults = db.results.filter(item => item.registrationId === registration?.id && item.published);
|
||||
const verificationCode = registration && exam ? scoreReportCode(documentVerificationSecret, registration, exam, reportResults) : '';
|
||||
return { ...summary, verificationCode, verificationQr: verificationCode ? await verificationQr(request, verificationCode) : '' };
|
||||
}));
|
||||
return { ok: true, results, summaries, candidate: { name: profile.name || user.displayName, candidateNumber: user.candidateNumber || '' } };
|
||||
}, { ttlSeconds: resultsCacheTtlSeconds });
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/candidate/admissions') {
|
||||
const settings = (await Promise.all(admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(async setting => {
|
||||
const exam = db.exams.find(item => item.id === setting.examId);
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const preference = activePreference(db, setting.examId, user.id, round);
|
||||
const preferenceView = preference ? {
|
||||
...preference,
|
||||
payload: {
|
||||
...preference.payload,
|
||||
choices: (preference.payload?.choices || []).map(choice => {
|
||||
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||
const categories = admissionRecords(db, 'plan', setting.examId)
|
||||
.filter(item => item.schoolId === choice.schoolId)
|
||||
.flatMap(item => item.payload?.categories || []);
|
||||
const category = categories.find(item => item.code === choice.categoryCode)
|
||||
|| (choice.categoryCode === 'general' ? categories.find(item => !item.specialtyCategory && !item.specialtyType) : null)
|
||||
|| (['sport', 'sports'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'sports') : null)
|
||||
|| (['art', 'arts'].includes(choice.categoryCode) ? categories.find(item => item.specialtyCategory === 'arts') : null);
|
||||
return { ...choice, schoolCode: choice.schoolCode || school?.code || '', schoolName: choice.schoolName || school?.name || '', categoryName: choice.categoryName || category?.name || '' };
|
||||
})
|
||||
}
|
||||
} : null;
|
||||
const qualification = indicatorQualification(db, setting.examId, user.id);
|
||||
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
|
||||
const blockingPlacement = setting.status === 'supplementary'
|
||||
? admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status))
|
||||
: null;
|
||||
const supplementEligible = !blockingPlacement;
|
||||
const supplementIneligibilityReason = blockingPlacement?.status === 'forfeited'
|
||||
? '因本轮未按规定完成报到,不能再次参加补录。'
|
||||
: blockingPlacement
|
||||
? '你已被录取,本轮补录无需且不能再次填报。'
|
||||
: '';
|
||||
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||
const plans = (supplementEligible ? approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)) : []).map(plan => {
|
||||
const school = db.schools.find(item => item.id === plan.schoolId);
|
||||
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
|
||||
return {
|
||||
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
|
||||
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
|
||||
const indicatorAllocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
|
||||
const indicatorUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
|
||||
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
|
||||
const generalUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === 'general').length;
|
||||
const indicatorRemaining = Math.max(0, Number(indicatorAllocation?.quota || 0) - indicatorUsed);
|
||||
const generalRemaining = Math.max(0, generalQuota - generalUsed);
|
||||
const preferenceTypes = [generalRemaining > 0 ? 'general' : '', qualification?.payload?.eligible && indicatorRemaining > 0 ? 'indicator' : ''].filter(Boolean);
|
||||
return { ...category, generalRemaining, indicatorRemaining, preferenceTypes };
|
||||
}).filter(category => category.preferenceTypes.length)
|
||||
};
|
||||
}).filter(plan => plan.categories.length);
|
||||
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
|
||||
const submissionCount = Number(preference?.payload?.submissionCount || 0);
|
||||
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
|
||||
const school = placement ? db.schools.find(item => item.id === placement.schoolId) : null;
|
||||
const templateRecord = placement ? admissionRecords(db, 'notification').find(item => item.schoolId === placement.schoolId && item.status === 'template') : null;
|
||||
const noticeTemplate = templateRecord?.payload?.template || null;
|
||||
const noticeVerificationCode = placement?.status === 'final' && exam ? admissionNoticeCode(documentVerificationSecret, placement, exam) : '';
|
||||
const noticeVerificationQr = noticeVerificationCode ? await verificationQr(request, noticeVerificationCode) : '';
|
||||
return { ...setting, exam: exam ? publicExam(exam) : null, preference: preferenceView, placement, placementSchool: school ? { id: school.id, name: school.name, code: school.code } : null, noticeTemplate, noticeVerificationCode, noticeVerificationQr, noticeNumber: placement?.payload?.noticeNumber || '', plans, supplementEligible, supplementIneligibilityReason, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
|
||||
}))).filter(item => item.exam);
|
||||
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id).map(item => {
|
||||
const placement = admissionRecords(db, 'placement', item.examId).find(entry => entry.id === item.payload?.placementId);
|
||||
const school = db.schools.find(entry => entry.id === (placement?.schoolId || item.schoolId));
|
||||
const exam = db.exams.find(entry => entry.id === item.examId);
|
||||
return { ...item, examName: exam?.name || '', schoolName: school?.name || '', schoolCode: school?.code || '', categoryName: placement?.payload?.categoryName || '', noticeNumber: placement?.payload?.noticeNumber || '', placementStatus: placement?.status || '' };
|
||||
}).sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
|
||||
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
|
||||
}
|
||||
const preferenceMatch = pathname.match(/^\/api\/candidate\/admissions\/([^/]+)\/preferences$/);
|
||||
if (request.method === 'PUT' && preferenceMatch) {
|
||||
const setting = admissionSetting(db, preferenceMatch[1]);
|
||||
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未开放志愿填报');
|
||||
if (!['filling', 'supplementary'].includes(setting.status)) return sendError(response, 409, '当前不在志愿填报阶段');
|
||||
if (setting.status === 'supplementary') {
|
||||
const blockingPlacement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(item.status));
|
||||
if (blockingPlacement?.status === 'forfeited') return sendError(response, 403, '因未按规定完成报到,本轮不能再次参加补录');
|
||||
if (blockingPlacement) return sendError(response, 403, '你已被录取,本轮补录不能再次填报');
|
||||
}
|
||||
const now = Date.now();
|
||||
if (setting.payload.preferenceStart && now < new Date(setting.payload.preferenceStart).getTime()) return sendError(response, 409, '志愿填报尚未开始');
|
||||
if (setting.payload.preferenceEnd && now > new Date(setting.payload.preferenceEnd).getTime()) return sendError(response, 409, '志愿填报已经截止');
|
||||
if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
|
||||
const body = await readJson(request);
|
||||
const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
|
||||
const round = Number(setting.payload.round || 1);
|
||||
const currentPreference = activePreference(db, setting.examId, user.id, round);
|
||||
const maxSubmissions = Math.max(1, Number(setting.payload.maxSubmissions || 3));
|
||||
const submissionCount = Number(currentPreference?.payload?.submissionCount || 0);
|
||||
if (submissionCount >= maxSubmissions) return sendError(response, 409, `志愿已达到 ${maxSubmissions} 次提交上限,现已自动锁定`);
|
||||
const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices + 1).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40), preferenceType: item.preferenceType === 'indicator' ? 'indicator' : 'general' }));
|
||||
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
|
||||
const indicatorChoices = choices.filter(item => item.preferenceType === 'indicator');
|
||||
const generalChoices = choices.filter(item => item.preferenceType === 'general');
|
||||
if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
|
||||
if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
|
||||
if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
|
||||
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||
const plans = approvedPlans(db, setting.examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
|
||||
const indicator = indicatorQualification(db, setting.examId, user.id);
|
||||
const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
|
||||
if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
|
||||
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status));
|
||||
if (choice.preferenceType === 'indicator') {
|
||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
|
||||
const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
|
||||
return indicator?.payload?.eligible === true && Number(allocation?.quota || 0) > used;
|
||||
}
|
||||
const quota = Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
|
||||
return quota > placements.filter(item => item.payload?.quotaBucket === 'general').length;
|
||||
})));
|
||||
if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
|
||||
const nowValue = nowIso();
|
||||
const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
|
||||
const storedChoices = choices.map(choice => {
|
||||
const school = db.schools.find(item => item.id === choice.schoolId);
|
||||
const category = plans.find(plan => plan.schoolId === choice.schoolId)?.payload?.categories?.find(item => item.code === choice.categoryCode);
|
||||
return { ...choice, schoolCode: school?.code || '', schoolName: school?.name || '', categoryName: category?.name || '' };
|
||||
});
|
||||
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices: storedChoices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
|
||||
await database.saveAdmissionRecord(preference);
|
||||
return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
|
||||
}
|
||||
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
|
||||
if (request.method === 'POST' && scoreAppealMatch) {
|
||||
const result = db.results.find(item => item.id === scoreAppealMatch[1] && item.published);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { noticeForClient } from '../security/notice-content.mjs';
|
||||
import { admissionRecords, admissionRoundPublications, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
|
||||
import { specialtyLabel } from '../data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../services/system-notifications.mjs';
|
||||
|
||||
export function createPublicRoutes(context) {
|
||||
const {
|
||||
@@ -44,20 +47,84 @@ export function createPublicRoutes(context) {
|
||||
verifyPassword,
|
||||
uid,
|
||||
nowIso,
|
||||
sessions,
|
||||
buildWorkbook,
|
||||
hasExcelResource,
|
||||
parseWorkbook,
|
||||
adminLevelNames
|
||||
, documentVerificationSecret, scoreReportCode, admissionNoticeCode, safeCodeEqual
|
||||
} = context;
|
||||
|
||||
async function handlePublic(pathname, response) {
|
||||
const verificationMatch = pathname.match(/^\/api\/public\/verifications\/([^/]+)$/);
|
||||
if (verificationMatch) {
|
||||
const db = await readDb();
|
||||
const code = decodeURIComponent(verificationMatch[1]).toUpperCase();
|
||||
const hideName = value => value ? `${value.slice(0, 1)}${'*'.repeat(Math.max(1, value.length - 1))}` : '';
|
||||
if (code.startsWith('SR-')) {
|
||||
for (const registration of db.registrations) {
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
|
||||
if (!exam || !results.length || !safeCodeEqual(code, scoreReportCode(documentVerificationSecret, registration, exam, results))) continue;
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId) || {};
|
||||
const user = db.users.find(item => item.id === registration.userId) || {};
|
||||
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'score-report', typeName: '考生成绩单', candidateName: hideName(profile.name || user.displayName), candidateNumber: String(user.candidateNumber || registration.registrationNumber || '').replace(/^(.{3}).+(.{3})$/, '$1****$2'), examName: exam.name, subjectCount: results.length, totalScore: Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)), issuedAt: [...results].sort((a, b) => new Date(b.publishedAt || b.updatedAt) - new Date(a.publishedAt || a.updatedAt))[0]?.publishedAt } });
|
||||
}
|
||||
}
|
||||
if (code.startsWith('AN-')) {
|
||||
for (const placement of admissionRecords(db, 'placement').filter(item => item.status === 'final')) {
|
||||
const exam = db.exams.find(item => item.id === placement.examId);
|
||||
if (!exam || !safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, placement, exam))) continue;
|
||||
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
|
||||
const school = db.schools.find(item => item.id === placement.schoolId) || {};
|
||||
return sendJson(response, 200, { ok: true, verified: true, document: { type: 'admission-notice', typeName: '录取通知书', noticeNumber: placement.payload?.noticeNumber || '', candidateName: hideName(profile.name), examName: exam.name, schoolName: school.name, categoryName: placement.payload?.categoryName || '', issuedAt: placement.updatedAt } });
|
||||
}
|
||||
}
|
||||
return sendError(response, 404, '未查询到有效文书,请核对防伪码');
|
||||
}
|
||||
if (pathname === '/api/public/home') {
|
||||
const payload = await cache.remember('public', 'home', async () => {
|
||||
const db = await readDb();
|
||||
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
|
||||
const manualNotices = db.notices.filter(item => item.status === 'published').map(noticeForClient);
|
||||
const automaticNotices = systemNotificationItems(db).filter(item => item.visible).map(item => ({ ...item, id: item.noticeId }));
|
||||
const publishedNotices = [...manualNotices, ...automaticNotices].sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
|
||||
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
|
||||
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
|
||||
});
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
if (pathname === '/api/public/announcements') {
|
||||
const payload = await cache.remember('public', 'admission-announcements', async () => {
|
||||
const db = await readDb();
|
||||
const plans = admissionRecords(db, 'plan').filter(item => item.status === 'approved' && item.payload?.publicVisible !== false).map(item => ({
|
||||
id: item.id,
|
||||
examId: item.examId,
|
||||
examName: db.exams.find(exam => exam.id === item.examId)?.name || '',
|
||||
schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '',
|
||||
publishedAt: item.payload?.reviewedAt || item.updatedAt,
|
||||
note: item.payload?.note || '',
|
||||
rows: (item.payload?.categories || []).map(category => ({
|
||||
code: category.code,
|
||||
name: category.name,
|
||||
quota: Number(category.quota || 0),
|
||||
specialtyCategory: category.specialtyCategory || '',
|
||||
specialtyType: category.specialtyType || '',
|
||||
specialtyLabel: specialtyLabel(category.specialtyCategory, category.specialtyType) || '普通 / 政策类',
|
||||
indicatorQuota: (category.indicatorAllocations || []).reduce((sum, allocation) => sum + Number(allocation.quota || 0), 0),
|
||||
indicatorAllocations: (category.indicatorAllocations || []).map(allocation => ({
|
||||
sourceSchoolName: db.schools.find(school => school.id === allocation.sourceSchoolId)?.name || allocation.sourceSchoolId,
|
||||
quota: Number(allocation.quota || 0)
|
||||
}))
|
||||
}))
|
||||
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
|
||||
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
|
||||
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
|
||||
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', round: item.round, title: `${db.exams.find(exam => exam.id === item.examId)?.name || ''}第 ${item.round} 轮录取名单公示`, publishedAt: item.publishedAt, rows: item.rows }));
|
||||
const admissions = [...roundAdmissions, ...admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', title: `${db.exams.find(item => item.id === setting.examId)?.name || ''}最终录取名单`, publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }))].sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
|
||||
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting' && item.visible).map(item => ({ id: item.id, examId: item.examId, schoolId: item.schoolId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', title: item.title, summary: item.summary, publishedAt: item.publishAt, statistics: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.statistics || {}, supplementDecision: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.supplementDecision || '', decisionNote: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.decisionNote || '' }));
|
||||
return { ok: true, plans, qualifications, admissions, cutoffs, reports };
|
||||
});
|
||||
return sendJson(response, 200, payload);
|
||||
}
|
||||
@@ -66,7 +133,9 @@ export function createPublicRoutes(context) {
|
||||
const notice = await cache.remember('public', `notice:${encodeURIComponent(noticeMatch[1])}`, async () => {
|
||||
const db = await readDb();
|
||||
const found = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
|
||||
return found ? noticeForClient(found) : null;
|
||||
if (found) return noticeForClient(found);
|
||||
const systemNotice = systemNotificationItems(db).find(item => item.noticeId === noticeMatch[1] && item.visible);
|
||||
return systemNotice ? { ...systemNotice, id: systemNotice.noticeId } : null;
|
||||
});
|
||||
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { createClient } from 'redis';
|
||||
|
||||
function positiveInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? Math.min(parsed, maximum) : fallback;
|
||||
}
|
||||
|
||||
function nonNegativeInteger(value, fallback, maximum = Number.MAX_SAFE_INTEGER) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? Math.min(parsed, maximum) : fallback;
|
||||
}
|
||||
|
||||
function redisDatabase(url) {
|
||||
try {
|
||||
const pathname = new URL(url).pathname.replace(/^\//, '');
|
||||
const parsed = Number(pathname || 0);
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function redisEndpoint(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.protocol}//${parsed.hostname}:${parsed.port || '6379'}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function memoryAuthState({ sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds }) {
|
||||
const sessions = new Map();
|
||||
const loginChallenges = new Map();
|
||||
const totpSetups = new Map();
|
||||
|
||||
function liveEntry(map, key) {
|
||||
const entry = map.get(key);
|
||||
if (!entry || entry.expiresAt <= Date.now()) {
|
||||
map.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'disabled',
|
||||
backend: 'memory',
|
||||
database: null,
|
||||
sessionTtlSeconds,
|
||||
async createSession(token, userId) {
|
||||
sessions.set(token, { userId, expiresAt: Date.now() + sessionTtlSeconds * 1000 });
|
||||
},
|
||||
async getSession(token) {
|
||||
const entry = liveEntry(sessions, token);
|
||||
return entry ? { userId: entry.userId } : null;
|
||||
},
|
||||
async deleteSession(token) {
|
||||
return sessions.delete(token);
|
||||
},
|
||||
async deleteUserSessions(userId) {
|
||||
let deleted = 0;
|
||||
for (const [token, session] of sessions) {
|
||||
if (session.userId === userId) {
|
||||
sessions.delete(token);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
},
|
||||
async deleteUsersSessions(userIds) {
|
||||
const targets = userIds instanceof Set ? userIds : new Set(userIds);
|
||||
let deleted = 0;
|
||||
for (const [token, session] of sessions) {
|
||||
if (targets.has(session.userId)) {
|
||||
sessions.delete(token);
|
||||
deleted += 1;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
},
|
||||
async createLoginChallenge(key, userId) {
|
||||
loginChallenges.set(key, { userId, attempts: 0, expiresAt: Date.now() + loginChallengeTtlSeconds * 1000 });
|
||||
},
|
||||
async getLoginChallenge(key) {
|
||||
const entry = liveEntry(loginChallenges, key);
|
||||
return entry ? { userId: entry.userId, attempts: entry.attempts } : null;
|
||||
},
|
||||
async recordLoginChallengeFailure(key, maximumAttempts) {
|
||||
const entry = liveEntry(loginChallenges, key);
|
||||
if (!entry) return null;
|
||||
entry.attempts += 1;
|
||||
const exhausted = entry.attempts >= maximumAttempts;
|
||||
if (exhausted) loginChallenges.delete(key);
|
||||
return { attempts: entry.attempts, exhausted };
|
||||
},
|
||||
async deleteLoginChallenge(key) {
|
||||
return loginChallenges.delete(key);
|
||||
},
|
||||
async createTotpSetup(token, userId, secret) {
|
||||
totpSetups.set(token, { userId, secret, expiresAt: Date.now() + totpSetupTtlSeconds * 1000 });
|
||||
},
|
||||
async getTotpSetup(token) {
|
||||
const entry = liveEntry(totpSetups, token);
|
||||
return entry ? { userId: entry.userId, secret: entry.secret } : null;
|
||||
},
|
||||
async deleteTotpSetup(token) {
|
||||
return totpSetups.delete(token);
|
||||
},
|
||||
async close() {
|
||||
sessions.clear();
|
||||
loginChallenges.clear();
|
||||
totpSetups.clear();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const recordFailureScript = `
|
||||
if redis.call('EXISTS', KEYS[1]) == 0 then
|
||||
return -1
|
||||
end
|
||||
local attempts = redis.call('HINCRBY', KEYS[1], 'attempts', 1)
|
||||
if attempts >= tonumber(ARGV[1]) then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return attempts
|
||||
`;
|
||||
|
||||
export async function createAuthStateStore({ env = process.env, logger = console, clientFactory = createClient } = {}) {
|
||||
const cacheUrl = String(env.REDIS_URL || '').trim();
|
||||
const explicitSessionUrl = String(env.REDIS_SESSION_URL || '').trim();
|
||||
const sessionUrl = explicitSessionUrl || cacheUrl;
|
||||
const sessionTtlSeconds = positiveInteger(env.AUTH_SESSION_TTL_SECONDS, 8 * 60 * 60, 30 * 24 * 60 * 60);
|
||||
const loginChallengeTtlSeconds = positiveInteger(env.AUTH_LOGIN_CHALLENGE_TTL_SECONDS, 5 * 60, 60 * 60);
|
||||
const totpSetupTtlSeconds = positiveInteger(env.AUTH_TOTP_SETUP_TTL_SECONDS, 10 * 60, 60 * 60);
|
||||
const lifetimes = { sessionTtlSeconds, loginChallengeTtlSeconds, totpSetupTtlSeconds };
|
||||
|
||||
if (!sessionUrl) return memoryAuthState(lifetimes);
|
||||
|
||||
const cacheDatabase = redisDatabase(cacheUrl);
|
||||
const configuredDatabase = String(env.REDIS_SESSION_DB || '').trim();
|
||||
const sessionDatabase = configuredDatabase
|
||||
? nonNegativeInteger(configuredDatabase, cacheDatabase === 0 ? 1 : 0, 1024)
|
||||
: explicitSessionUrl
|
||||
? redisDatabase(explicitSessionUrl)
|
||||
: cacheDatabase === 0 ? 1 : 0;
|
||||
|
||||
if (cacheUrl && redisEndpoint(cacheUrl) === redisEndpoint(sessionUrl) && cacheDatabase === sessionDatabase) {
|
||||
throw new Error('Redis 认证状态必须使用与普通缓存不同的逻辑数据库;请配置 REDIS_SESSION_DB 或 REDIS_SESSION_URL');
|
||||
}
|
||||
|
||||
const prefix = String(env.REDIS_SESSION_PREFIX || 'exam-information:auth')
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9:_-]/g, '-') || 'exam-information:auth';
|
||||
const connectTimeout = positiveInteger(env.REDIS_CONNECT_TIMEOUT_MS, 1500, 30000);
|
||||
const client = clientFactory({
|
||||
url: sessionUrl,
|
||||
database: sessionDatabase,
|
||||
socket: { connectTimeout }
|
||||
});
|
||||
client.on('error', error => logger.error(`Redis 认证状态存储错误:${error?.message || error}`));
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
} catch (error) {
|
||||
if (client.isOpen) client.destroy();
|
||||
throw new Error(`Redis 认证状态存储连接失败:${error?.message || error}`, { cause: error });
|
||||
}
|
||||
|
||||
const sessionKey = token => `${prefix}:session:${token}`;
|
||||
const userSessionsKey = userId => `${prefix}:user-sessions:${userId}`;
|
||||
const loginChallengeKey = key => `${prefix}:login-challenge:${key}`;
|
||||
const totpSetupKey = token => `${prefix}:totp-setup:${token}`;
|
||||
|
||||
async function deleteUserSessions(userId) {
|
||||
const indexKey = userSessionsKey(userId);
|
||||
const tokens = await client.sMembers(indexKey);
|
||||
if (!tokens.length) {
|
||||
await client.del(indexKey);
|
||||
return 0;
|
||||
}
|
||||
const transaction = client.multi();
|
||||
for (const token of tokens) transaction.del(sessionKey(token));
|
||||
transaction.del(indexKey);
|
||||
await transaction.exec();
|
||||
return tokens.length;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'ready',
|
||||
backend: 'redis',
|
||||
database: sessionDatabase,
|
||||
sessionTtlSeconds,
|
||||
async createSession(token, userId) {
|
||||
const indexKey = userSessionsKey(userId);
|
||||
const transaction = client.multi();
|
||||
transaction.set(sessionKey(token), userId, { EX: sessionTtlSeconds });
|
||||
transaction.sAdd(indexKey, token);
|
||||
transaction.expire(indexKey, sessionTtlSeconds);
|
||||
await transaction.exec();
|
||||
},
|
||||
async getSession(token) {
|
||||
const userId = await client.get(sessionKey(token));
|
||||
return userId ? { userId } : null;
|
||||
},
|
||||
async deleteSession(token) {
|
||||
const key = sessionKey(token);
|
||||
const userId = await client.get(key);
|
||||
const transaction = client.multi();
|
||||
transaction.del(key);
|
||||
if (userId) transaction.sRem(userSessionsKey(userId), token);
|
||||
await transaction.exec();
|
||||
return Boolean(userId);
|
||||
},
|
||||
deleteUserSessions,
|
||||
async deleteUsersSessions(userIds) {
|
||||
const counts = await Promise.all([...userIds].map(deleteUserSessions));
|
||||
return counts.reduce((sum, count) => sum + count, 0);
|
||||
},
|
||||
async createLoginChallenge(key, userId) {
|
||||
const redisKey = loginChallengeKey(key);
|
||||
const transaction = client.multi();
|
||||
transaction.hSet(redisKey, { userId, attempts: '0' });
|
||||
transaction.expire(redisKey, loginChallengeTtlSeconds);
|
||||
await transaction.exec();
|
||||
},
|
||||
async getLoginChallenge(key) {
|
||||
const entry = await client.hGetAll(loginChallengeKey(key));
|
||||
return entry.userId ? { userId: entry.userId, attempts: Number(entry.attempts || 0) } : null;
|
||||
},
|
||||
async recordLoginChallengeFailure(key, maximumAttempts) {
|
||||
const attempts = Number(await client.eval(recordFailureScript, {
|
||||
keys: [loginChallengeKey(key)],
|
||||
arguments: [String(maximumAttempts)]
|
||||
}));
|
||||
return attempts < 0 ? null : { attempts, exhausted: attempts >= maximumAttempts };
|
||||
},
|
||||
async deleteLoginChallenge(key) {
|
||||
return Boolean(await client.del(loginChallengeKey(key)));
|
||||
},
|
||||
async createTotpSetup(token, userId, secret) {
|
||||
const key = totpSetupKey(token);
|
||||
const transaction = client.multi();
|
||||
transaction.hSet(key, { userId, secret });
|
||||
transaction.expire(key, totpSetupTtlSeconds);
|
||||
await transaction.exec();
|
||||
},
|
||||
async getTotpSetup(token) {
|
||||
const entry = await client.hGetAll(totpSetupKey(token));
|
||||
return entry.userId && entry.secret ? { userId: entry.userId, secret: entry.secret } : null;
|
||||
},
|
||||
async deleteTotpSetup(token) {
|
||||
return Boolean(await client.del(totpSetupKey(token)));
|
||||
},
|
||||
async close() {
|
||||
if (client.isOpen) await client.quit();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
function signature(secret, type, parts) {
|
||||
return createHmac('sha256', secret).update([type, ...parts].join('\u001f')).digest('hex').slice(0, 24).toUpperCase();
|
||||
}
|
||||
|
||||
export function resolveDocumentVerificationSecret(env = process.env) {
|
||||
const configured = String(env.DOCUMENT_VERIFICATION_SECRET || '');
|
||||
if (env.NODE_ENV === 'production' && configured.length < 32) {
|
||||
throw new Error('生产环境必须设置至少 32 个字符的 DOCUMENT_VERIFICATION_SECRET');
|
||||
}
|
||||
return configured || String(env.SESSION_SECRET || '') || 'development-document-verification-secret';
|
||||
}
|
||||
|
||||
export function scoreReportCode(secret, registration, exam, results = []) {
|
||||
const scores = [...results].sort((a, b) => String(a.subjectId).localeCompare(String(b.subjectId))).map(item => `${item.subjectId}:${Number(item.score)}:${item.publishedAt || item.updatedAt || ''}`);
|
||||
return `SR-${signature(secret, 'score-report', [registration.id, registration.userId, exam.id, ...scores])}`;
|
||||
}
|
||||
|
||||
export function admissionNoticeCode(secret, placement, exam) {
|
||||
return `AN-${signature(secret, 'admission-notice', [placement.id, placement.userId, placement.schoolId, exam.id, placement.payload?.categoryCode || '', placement.payload?.noticeNumber || '', placement.updatedAt || ''])}`;
|
||||
}
|
||||
|
||||
export function safeCodeEqual(left, right) {
|
||||
const a = Buffer.from(String(left || '').toUpperCase());
|
||||
const b = Buffer.from(String(right || '').toUpperCase());
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
export function createSessionManager({ authState, readDb, sendError }) {
|
||||
function normalizeUser(user) {
|
||||
if (user?.role === 'admin' && !user.adminLevel) return { ...user, adminLevel: 'super' };
|
||||
return user;
|
||||
@@ -13,12 +13,10 @@ export function createSessionManager({ sessions, readDb, sendError }) {
|
||||
|
||||
async function currentUser(request) {
|
||||
const token = parseCookies(request).hz_session;
|
||||
const session = token && sessions.get(token);
|
||||
if (!session || session.expiresAt < Date.now()) {
|
||||
if (token) sessions.delete(token);
|
||||
return null;
|
||||
}
|
||||
const session = token ? await authState.getSession(token) : null;
|
||||
if (!session) return null;
|
||||
const db = await readDb();
|
||||
request.authDb = db;
|
||||
const user = db.users.find(item => item.id === session.userId) || null;
|
||||
return user?.active === false || user?.archivedAt ? null : normalizeUser(user);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { admissionRecords, admissionRoundPublications, admissionSetting } from './volunteer-admission.mjs';
|
||||
|
||||
const h = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char]));
|
||||
|
||||
export function systemNotificationItems(db) {
|
||||
const examName = examId => db.exams.find(item => item.id === examId)?.name || '未知考试';
|
||||
const schoolName = schoolId => db.schools.find(item => item.id === schoolId)?.name || '未知学校';
|
||||
const item = (record, sourceType, category, title, summary, publishAt, content) => ({
|
||||
id: record.id,
|
||||
noticeId: `system-${sourceType}-${record.id}`,
|
||||
sourceType,
|
||||
schoolId: record.schoolId || null,
|
||||
examId: record.examId,
|
||||
category,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
author: '系统自动发布',
|
||||
publishAt,
|
||||
publishedAt: publishAt,
|
||||
pinned: false,
|
||||
visible: record.payload?.publicVisible !== false,
|
||||
status: record.payload?.publicVisible === false ? 'hidden' : 'visible'
|
||||
});
|
||||
const plans = admissionRecords(db, 'plan').filter(record => record.status === 'approved').map(record => item(
|
||||
record, 'plan', '招生计划', `${examName(record.examId)} · ${schoolName(record.schoolId)}招生计划公示`,
|
||||
'招生计划审核通过,类别人数与指标分配已经公开。', record.payload?.reviewedAt || record.updatedAt,
|
||||
`<p>${h(schoolName(record.schoolId))}招生计划已经审核通过。</p><ul>${(record.payload?.categories || []).map(category => `<li>${h(category.name)}:${Number(category.quota || 0)} 人</li>`).join('')}</ul>`
|
||||
));
|
||||
const qualifications = admissionRecords(db, 'qualification_publication').filter(record => record.status === 'published').map(record => item(
|
||||
record, 'qualification', '指标资格', `${examName(record.examId)} · ${schoolName(record.schoolId)}指标分配资格公示`,
|
||||
'生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt,
|
||||
`<p>${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。</p>`
|
||||
));
|
||||
const roundAdmissions = admissionRoundPublications(db).filter(record => admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
|
||||
record, 'admission', '录取名单', `${examName(record.examId)}第 ${record.round} 轮录取名单公示`,
|
||||
`第 ${record.round} 轮录取通知书已签发,共 ${record.rows.length} 名考生进入本轮录取公示。`, record.publishedAt,
|
||||
`<p>${h(examName(record.examId))}第 ${record.round} 轮录取工作已经完成,共 ${record.rows.length} 名考生正式录取。</p>`
|
||||
));
|
||||
const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item(
|
||||
record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`,
|
||||
'录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt,
|
||||
`<p>${h(examName(record.examId))}录取工作已经完成,请在招生录取公示中查询脱敏结果。</p>`
|
||||
));
|
||||
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(record => record.status === 'published' && admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
|
||||
record, 'cutoff', '录取分数线', `${examName(record.examId)}录取分数线`,
|
||||
'各招生学校和类别录取分数线已经由系统汇总发布。', record.payload?.publishedAt || record.updatedAt,
|
||||
`<p>系统已汇总 ${Number(record.payload?.rows?.length || 0)} 个学校招生类别的录取分数线。</p>`
|
||||
));
|
||||
const reports = admissionRecords(db, 'notification').filter(record => record.userId == null && record.status === 'approved' && record.payload?.type === 'admission_reporting').map(record => {
|
||||
const stats = record.payload?.statistics || {};
|
||||
const supplement = record.payload?.supplementDecision === 'supplement';
|
||||
const title = supplement
|
||||
? `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况及补录说明`
|
||||
: `${examName(record.examId)} · ${schoolName(record.schoolId)}考生报到情况公示`;
|
||||
const summary = `计划 ${Number(stats.totalQuota || 0)} 人,已报到 ${Number(stats.reportedCount || 0)} 人,完成率 ${Number(stats.reportingRate || 0)}%。`;
|
||||
const decision = supplement ? '学校申请补录并已获批准。' : (record.payload?.decisionNote || '本轮不进行补录。');
|
||||
return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt,
|
||||
`<p>${h(summary)}</p><p>${h(decision)}</p><ul><li>正式录取:${Number(stats.finalCount || 0)} 人</li><li>已报到:${Number(stats.reportedCount || 0)} 人</li><li>未报到:${Number(stats.notReportedCount || 0)} 人</li><li>计划缺额:${Number(stats.reportingGap || 0)} 人</li></ul><p>${h(record.payload?.approvalNote || '')}</p>`);
|
||||
});
|
||||
return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
|
||||
.sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt));
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'reporting', 'supplementary', 'completed']);
|
||||
|
||||
export function admissionRecords(db, kind, examId = null) {
|
||||
return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId));
|
||||
}
|
||||
|
||||
export function admissionSetting(db, examId) {
|
||||
return admissionRecords(db, 'setting', examId)[0] || null;
|
||||
}
|
||||
|
||||
export function candidateTotalScore(db, examId, userId) {
|
||||
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
|
||||
if (!registration) return null;
|
||||
const results = db.results.filter(item => item.registrationId === registration.id && item.published);
|
||||
if (!registration.subjectIds.length || registration.subjectIds.some(id => !results.some(result => result.subjectId === id))) return null;
|
||||
return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2));
|
||||
}
|
||||
|
||||
export function candidateAdmissionScore(db, examId, userId, category = {}) {
|
||||
const culturalScore = candidateTotalScore(db, examId, userId);
|
||||
if (culturalScore == null) return null;
|
||||
const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved');
|
||||
const featureScore = Number(registration?.featureScore || 0);
|
||||
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
|
||||
return Number((culturalScore + (usesFeatureScore ? featureScore : 0)).toFixed(2));
|
||||
}
|
||||
|
||||
export function activePreference(db, examId, userId, round) {
|
||||
return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null;
|
||||
}
|
||||
|
||||
export function indicatorQualification(db, examId, userId) {
|
||||
return admissionRecords(db, 'indicator_qualification', examId).find(item => item.userId === userId) || null;
|
||||
}
|
||||
|
||||
export function sourceSchoolQualificationStatus(db, examId, schoolId) {
|
||||
const profiles = db.candidateProfiles.filter(profile => profile.schoolId === schoolId && profile.profileCompleted && db.users.some(user => user.id === profile.userId && user.role === 'candidate' && user.active));
|
||||
const qualifications = admissionRecords(db, 'indicator_qualification', examId).filter(item => item.schoolId === schoolId && item.status === 'confirmed');
|
||||
const byUser = new Map(qualifications.map(item => [item.userId, item]));
|
||||
const rows = profiles.map(profile => {
|
||||
const account = db.users.find(item => item.id === profile.userId) || {};
|
||||
const specialty = resolveProfileSpecialty(profile);
|
||||
const qualification = byUser.get(profile.userId) || null;
|
||||
return {
|
||||
userId: profile.userId,
|
||||
registrationNumber: account.candidateNumber || '',
|
||||
name: profile.name || account.displayName || '',
|
||||
eligible: qualification?.payload?.eligible === true,
|
||||
confirmed: Boolean(qualification),
|
||||
confirmedAt: qualification?.payload?.confirmedAt || qualification?.updatedAt || '',
|
||||
specialtyCategory: specialty.category,
|
||||
specialtyType: specialty.type,
|
||||
specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生'
|
||||
};
|
||||
}).sort((left, right) => left.registrationNumber.localeCompare(right.registrationNumber));
|
||||
return { total: rows.length, confirmed: rows.filter(item => item.confirmed).length, complete: rows.length > 0 && rows.every(item => item.confirmed), rows };
|
||||
}
|
||||
|
||||
export function approvedPlans(db, examId) {
|
||||
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
|
||||
}
|
||||
|
||||
export function supplementarySchoolIds(db, setting) {
|
||||
if (setting?.status !== 'supplementary') return null;
|
||||
const sourceRound = Math.max(1, Number(setting.payload?.round || 1) - 1);
|
||||
const schoolIds = admissionRecords(db, 'notification', setting.examId)
|
||||
.filter(item => item.userId == null
|
||||
&& item.status === 'approved'
|
||||
&& item.payload?.type === 'admission_reporting'
|
||||
&& Number(item.payload?.round || 1) === sourceRound
|
||||
&& item.payload?.supplementDecision === 'supplement')
|
||||
.map(item => item.schoolId)
|
||||
.filter(Boolean);
|
||||
// Older data could enter the supplementary phase without reporting decisions.
|
||||
// Preserve that legacy behavior, while new rounds are restricted to approved schools.
|
||||
return schoolIds.length ? new Set(schoolIds) : null;
|
||||
}
|
||||
|
||||
export function planSummary(plan) {
|
||||
const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : [];
|
||||
return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) };
|
||||
}
|
||||
|
||||
export function admissionReportingRecords(db, examId, schoolId) {
|
||||
return admissionRecords(db, 'notification', examId)
|
||||
.filter(item => item.schoolId === schoolId && item.userId == null && item.payload?.type === 'admission_reporting')
|
||||
.sort((left, right) => Number(right.payload?.round || 1) - Number(left.payload?.round || 1) || new Date(right.updatedAt) - new Date(left.updatedAt));
|
||||
}
|
||||
|
||||
export function admissionReportingRecord(db, examId, schoolId, round = null) {
|
||||
return admissionReportingRecords(db, examId, schoolId).find(item => round == null || Number(item.payload?.round || 1) === Number(round)) || null;
|
||||
}
|
||||
|
||||
export function admissionPlanProgress(db, plan) {
|
||||
const totalQuota = (plan.payload?.categories || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
|
||||
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status));
|
||||
const finalPlacements = placements.filter(item => item.status === 'final');
|
||||
const reportingRecords = admissionReportingRecords(db, plan.examId, plan.schoolId);
|
||||
const reporting = reportingRecords[0] || null;
|
||||
const reportingRows = new Map();
|
||||
for (const record of [...reportingRecords].reverse()) for (const row of record.payload?.rows || []) reportingRows.set(row.placementId, row);
|
||||
const reportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'reported').length;
|
||||
const notReportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'not_reported').length;
|
||||
const pendingReportingCount = Math.max(0, finalPlacements.length - reportedCount - notReportedCount);
|
||||
const percent = value => totalQuota ? Number((value / totalQuota * 100).toFixed(1)) : 0;
|
||||
return {
|
||||
examId: plan.examId,
|
||||
schoolId: plan.schoolId,
|
||||
totalQuota,
|
||||
placedCount: placements.length,
|
||||
finalCount: finalPlacements.length,
|
||||
reportedCount,
|
||||
notReportedCount,
|
||||
pendingReportingCount,
|
||||
admissionRate: percent(finalPlacements.length),
|
||||
reportingRate: percent(reportedCount),
|
||||
remainingQuota: Math.max(0, totalQuota - finalPlacements.length),
|
||||
reportingGap: Math.max(0, totalQuota - reportedCount),
|
||||
reportingStatus: reporting?.status || 'not_started',
|
||||
supplementDecision: reporting?.payload?.supplementDecision || '',
|
||||
reportingUpdatedAt: reporting?.updatedAt || null
|
||||
};
|
||||
}
|
||||
|
||||
function documentCodePart(value, fallback) {
|
||||
const normalized = String(value || '').trim().toUpperCase().replace(/[^A-Z0-9-]+/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export function assignAdmissionNoticeNumbers(db, placements) {
|
||||
const counters = new Map();
|
||||
for (const item of admissionRecords(db, 'placement')) {
|
||||
const serial = Number(item.payload?.noticeSerial || String(item.payload?.noticeNumber || '').match(/(\d{6})$/)?.[1] || 0);
|
||||
if (!serial) continue;
|
||||
const key = `${item.schoolId}\u0000${item.examId}`;
|
||||
counters.set(key, Math.max(counters.get(key) || 0, serial));
|
||||
}
|
||||
const accountNumber = userId => db.users.find(item => item.id === userId)?.candidateNumber || userId;
|
||||
const output = [];
|
||||
const grouped = new Map();
|
||||
for (const placement of placements) {
|
||||
const key = `${placement.schoolId}\u0000${placement.examId}`;
|
||||
const rows = grouped.get(key) || [];
|
||||
rows.push(placement);
|
||||
grouped.set(key, rows);
|
||||
}
|
||||
for (const [key, rows] of grouped) {
|
||||
let serial = counters.get(key) || 0;
|
||||
rows.sort((left, right) => String(accountNumber(left.userId)).localeCompare(String(accountNumber(right.userId))));
|
||||
for (const placement of rows) {
|
||||
if (placement.payload?.noticeNumber) {
|
||||
output.push(placement);
|
||||
continue;
|
||||
}
|
||||
serial += 1;
|
||||
const school = db.schools.find(item => item.id === placement.schoolId) || {};
|
||||
const exam = db.exams.find(item => item.id === placement.examId) || {};
|
||||
const noticeSerial = serial;
|
||||
const noticeNumber = `${documentCodePart(school.code, 'SCHOOL')}-${documentCodePart(exam.code, 'EXAM')}-${String(noticeSerial).padStart(6, '0')}`;
|
||||
output.push({ ...placement, payload: { ...placement.payload, noticeSerial, noticeNumber } });
|
||||
}
|
||||
counters.set(key, serial);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function publicAdmissionRows(db, examId, options = {}) {
|
||||
const round = Math.max(0, Number(options.round || 0));
|
||||
return admissionRecords(db, 'placement', examId).filter(item => round
|
||||
? Number(item.payload?.finalizedRound || 1) === round && ['final', 'forfeited'].includes(item.status)
|
||||
: item.status === 'final').map(item => {
|
||||
const user = db.users.find(entry => entry.id === item.userId) || {};
|
||||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||||
const school = db.schools.find(entry => entry.id === item.schoolId) || {};
|
||||
return {
|
||||
registrationNumber: user.candidateNumber || '',
|
||||
name: profile.name || user.displayName || '',
|
||||
totalScore: Number(item.payload?.totalScore || 0),
|
||||
admittedSchool: school.name || '',
|
||||
categoryName: item.payload?.categoryName || '',
|
||||
idNumberMasked: profile.idNumber ? `${profile.idNumber.slice(0, 3)}***********${profile.idNumber.slice(-2)}` : '',
|
||||
phoneMasked: profile.phone ? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}` : ''
|
||||
};
|
||||
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
|
||||
}
|
||||
|
||||
export function admissionRoundPublications(db) {
|
||||
const stored = admissionRecords(db, 'notification')
|
||||
.filter(item => item.userId == null && item.status === 'published' && item.payload?.type === 'admission_round_publication')
|
||||
.map(item => ({ ...item, round: Number(item.payload?.round || 1), publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [], virtual: false }));
|
||||
const keys = new Set(stored.map(item => `${item.examId}:${item.round}`));
|
||||
const fallback = admissionRecords(db, 'setting').filter(item => ['reporting', 'supplementary', 'completed'].includes(item.status)).flatMap(setting => {
|
||||
const rounds = admissionRecords(db, 'placement', setting.examId)
|
||||
.filter(item => ['final', 'forfeited'].includes(item.status))
|
||||
.map(item => Number(item.payload?.finalizedRound || 1));
|
||||
const round = rounds.length ? Math.max(...rounds) : 0;
|
||||
if (!round || keys.has(`${setting.examId}:${round}`)) return [];
|
||||
return [{
|
||||
id: `${setting.id}-round-${round}`,
|
||||
sourceRecordId: setting.id,
|
||||
kind: 'notification',
|
||||
examId: setting.examId,
|
||||
schoolId: null,
|
||||
userId: null,
|
||||
status: 'published',
|
||||
createdAt: setting.updatedAt,
|
||||
updatedAt: setting.updatedAt,
|
||||
round,
|
||||
publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt,
|
||||
rows: publicAdmissionRows(db, setting.examId, { round }),
|
||||
virtual: true,
|
||||
payload: { type: 'admission_round_publication', round, publicVisible: setting.payload?.publicVisible, publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt }
|
||||
}];
|
||||
});
|
||||
return [...stored, ...fallback].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
|
||||
}
|
||||
|
||||
export function admissionCutoffRows(db, examId) {
|
||||
const groups = new Map();
|
||||
for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) {
|
||||
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
|
||||
const row = groups.get(key) || {
|
||||
schoolId: placement.schoolId,
|
||||
schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '',
|
||||
categoryCode: placement.payload?.categoryCode || '',
|
||||
categoryName: placement.payload?.categoryName || '',
|
||||
admittedCount: 0,
|
||||
planQuota: 0,
|
||||
highestScore: null,
|
||||
cutoffScore: null
|
||||
};
|
||||
const score = Number(placement.payload?.totalScore || 0);
|
||||
row.admittedCount += 1;
|
||||
row.highestScore = row.highestScore == null ? score : Math.max(row.highestScore, score);
|
||||
row.cutoffScore = row.cutoffScore == null ? score : Math.min(row.cutoffScore, score);
|
||||
groups.set(key, row);
|
||||
}
|
||||
for (const plan of approvedPlans(db, examId)) for (const category of plan.payload?.categories || []) {
|
||||
const row = groups.get(categoryKey(plan.schoolId, category.code));
|
||||
if (row) row.planQuota = Number(category.quota || 0);
|
||||
}
|
||||
return [...groups.values()].sort((left, right) => left.schoolName.localeCompare(right.schoolName) || left.categoryName.localeCompare(right.categoryName));
|
||||
}
|
||||
|
||||
function categoryKey(schoolId, code) {
|
||||
return `${schoolId}|${code}`;
|
||||
}
|
||||
|
||||
export function buildVolunteerPlacements(db, setting, { uid, nowIso }) {
|
||||
const examId = setting.examId;
|
||||
const round = Number(setting.payload?.round || 1);
|
||||
const supplementarySchools = supplementarySchoolIds(db, setting);
|
||||
const plans = approvedPlans(db, examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId));
|
||||
const categories = new Map();
|
||||
for (const plan of plans) for (const category of plan.payload?.categories || []) {
|
||||
categories.set(categoryKey(plan.schoolId, category.code), { plan, category });
|
||||
}
|
||||
|
||||
const allExisting = admissionRecords(db, 'placement', examId);
|
||||
const existing = allExisting.filter(item => !['withdrawn', 'forfeited'].includes(item.status));
|
||||
const occupiedIndicators = new Map();
|
||||
const occupiedGeneral = new Map();
|
||||
for (const placement of existing) {
|
||||
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
|
||||
if (placement.payload?.quotaBucket?.startsWith('indicator:')) {
|
||||
const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`;
|
||||
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
|
||||
} else occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
const preferences = admissionRecords(db, 'preference', examId).filter(item => Number(item.payload?.round || 1) === round && item.status === 'submitted');
|
||||
const candidates = preferences.map(preference => {
|
||||
const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {};
|
||||
const account = db.users.find(item => item.id === preference.userId) || {};
|
||||
const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved');
|
||||
return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 };
|
||||
}).filter(item => item.culturalScore != null && !allExisting.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(entry.status)));
|
||||
|
||||
const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || ''));
|
||||
const acceptedByBucket = new Map();
|
||||
const queue = [...candidates].sort((left, right) => right.culturalScore - left.culturalScore || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
|
||||
while (queue.length) {
|
||||
const candidate = queue.shift();
|
||||
const choices = candidate.preference.payload?.choices || [];
|
||||
while (candidate.nextChoiceIndex < choices.length) {
|
||||
const index = candidate.nextChoiceIndex;
|
||||
const choice = choices[candidate.nextChoiceIndex++];
|
||||
const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode));
|
||||
if (!target) continue;
|
||||
const { category } = target;
|
||||
if (!candidateEligibleForCategory(candidate.profile, category)) continue;
|
||||
const key = categoryKey(choice.schoolId, choice.categoryCode);
|
||||
let quotaBucket = null;
|
||||
let bucketKey = '';
|
||||
let capacity = 0;
|
||||
let occupiedCount = 0;
|
||||
if (choice.preferenceType === 'indicator') {
|
||||
const qualification = indicatorQualification(db, examId, candidate.preference.userId);
|
||||
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
|
||||
if (!qualification?.payload?.eligible || !allocation) continue;
|
||||
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
|
||||
quotaBucket = `indicator:${candidate.profile.schoolId}`;
|
||||
bucketKey = quotaBucket + '|' + key;
|
||||
capacity = Number(allocation.quota || 0);
|
||||
occupiedCount = occupiedIndicators.get(indicatorKey) || 0;
|
||||
} else {
|
||||
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
|
||||
quotaBucket = 'general';
|
||||
bucketKey = `general|${key}`;
|
||||
capacity = generalQuota;
|
||||
occupiedCount = occupiedGeneral.get(key) || 0;
|
||||
}
|
||||
const available = Math.max(0, capacity - occupiedCount);
|
||||
if (!available) continue;
|
||||
const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType);
|
||||
const proposal = {
|
||||
candidate, choice, category, index, quotaBucket,
|
||||
culturalScore: candidate.culturalScore,
|
||||
featureScore: candidate.featureScore,
|
||||
totalScore: Number((candidate.culturalScore + (usesFeatureScore ? candidate.featureScore : 0)).toFixed(2))
|
||||
};
|
||||
const accepted = acceptedByBucket.get(bucketKey) || [];
|
||||
accepted.push(proposal);
|
||||
accepted.sort(compareProposals);
|
||||
const rejected = accepted.length > available ? accepted.pop() : null;
|
||||
acceptedByBucket.set(bucketKey, accepted);
|
||||
if (rejected && rejected !== proposal) queue.push(rejected.candidate);
|
||||
if (rejected === proposal) continue;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const accepted = [...acceptedByBucket.values()].flat().sort(compareProposals);
|
||||
return accepted.map(({ candidate, choice, category, index, quotaBucket, culturalScore, featureScore, totalScore }) => ({
|
||||
id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId,
|
||||
status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: {
|
||||
round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1,
|
||||
culturalScore, totalScore, featureScore,
|
||||
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
export function remainingPlanQuota(db, plan) {
|
||||
return (plan.payload?.categories || []).map(category => {
|
||||
const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status)).length;
|
||||
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
|
||||
});
|
||||
}
|
||||
import { candidateEligibleForCategory, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
|
||||
+529
-2
@@ -152,10 +152,10 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.overline { margin: 0; color: #8c96ae; font-family: Consolas, ui-monospace, monospace; font-size: 10px; font-weight: 700; letter-spacing: 2px; }
|
||||
.status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 8px; border-radius: 6px; font-size: 10px; font-style: normal; font-weight: 700; white-space: nowrap; }
|
||||
.status::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||||
.status-open, .status-published, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
|
||||
.status-open, .status-published, .status-visible, .status-approved, .status-paid { color: #247a5c; background: #e3f3ed; }
|
||||
.status-pending, .status-upcoming, .status-unpaid { color: #9b6817; background: #fff1d3; }
|
||||
.status-rejected, .status-closed { color: #af443d; background: #fbe6e4; }
|
||||
.status-draft { color: #6d7589; background: #eef0f4; }
|
||||
.status-draft, .status-hidden { color: #6d7589; background: #eef0f4; }
|
||||
.exam-code { color: #6f7b98; font-family: Consolas, monospace; font-size: 10px; letter-spacing: .8px; }
|
||||
|
||||
/* Public site */
|
||||
@@ -237,6 +237,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.auth-story { position: relative; display: flex; flex-direction: column; justify-content: space-between; padding: 55px max(45px,8vw) 50px; color: #fff; background-color: var(--navy); background-image: radial-gradient(circle at 20% 85%,rgba(49,95,186,.3),transparent 35%), linear-gradient(rgba(255,255,255,.025) 1px,transparent 1px), linear-gradient(90deg,rgba(255,255,255,.025) 1px,transparent 1px); background-size: auto,26px 26px,26px 26px; overflow:hidden; }
|
||||
.auth-story::after { content:"准"; position:absolute; right:-55px; bottom:-105px; color:rgba(255,255,255,.03); font-family:"STKaiti"; font-size:420px; }.auth-story .brand { color:#fff; }.auth-story .brand small { color:#7e8db9; }.auth-story .overline { margin-top: 110px; color:#6f82b7; }.auth-story h1 { margin:16px 0 20px; font-family:"STKaiti",serif; font-size:clamp(42px,5vw,66px); font-weight:400; line-height:1.22; }.auth-story h1 em { color:#f07b70; font-style:normal; }.auth-story > div > p:last-child { max-width:470px; color:#aab5d4; font-size:12px; line-height:1.9; }.auth-quote { position:relative; z-index:1; padding-top:20px; border-top:1px solid rgba(255,255,255,.12); }.auth-quote span { color:#7484b1; font-size:9px; }.auth-quote p { margin:8px 0 0; color:#d4daeb; font-family:"STKaiti"; font-size:18px; }
|
||||
.auth-panel { display:grid; place-items:center; padding:60px 28px; position:relative; }.back-link { position:absolute; top:28px; right:34px; border:0; color:#778096; background:transparent; font-size:10px; }.auth-card { width:min(480px,100%); }.auth-card h2 { margin:8px 0 7px; font-family:"STKaiti"; font-size:34px; font-weight:400; }.auth-card > p:not(.overline) { margin:0 0 30px; color:#848c9d; font-size:11px; }.stack-form { display:grid; gap:15px; }.stack-form label,.modal-form label,.profile-form label,.result-entry label { display:grid; gap:7px; }.stack-form label > span,.modal-form label > span,.profile-form label > span,.result-entry label > span { color:#555f75; font-size:10px; font-weight:700; }.stack-form input,.stack-form select,.modal-form input,.modal-form select,.modal-form textarea,.profile-form input,.profile-form select,.result-entry input,.result-entry select { width:100%; min-height:44px; padding:10px 12px; border:1px solid #dce1ea; border-radius:8px; color:var(--ink); background:#fff; font-size:11px; outline:0; }.stack-form textarea,.modal-form textarea { resize:vertical; }.stack-form input:focus,.stack-form select:focus,.modal-form input:focus,.modal-form select:focus,.modal-form textarea:focus,.profile-form input:focus,.profile-form select:focus,.result-entry input:focus,.result-entry select:focus { border-color:#8999c0; box-shadow:0 0 0 3px rgba(49,95,186,.08); }.field-row { display:grid; grid-template-columns:1fr 1fr; gap:13px; }.region-selects { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:13px; }.agreement { display:flex !important; grid-template-columns:auto 1fr; align-items:center; gap:8px !important; color:#767f91; font-size:9px; }.agreement input { width:15px !important; min-height:auto !important; height:15px; }.agreement span { color:#767f91 !important; font-weight:400 !important; }.auth-switch { margin-top:20px; color:#858d9d; font-size:10px; text-align:center; }.auth-switch button { border:0; color:var(--blue); background:transparent; font-weight:700; }.demo-accounts { display:grid; gap:6px; margin-top:25px; padding:14px; border-radius:8px; background:#f5f7fa; }.demo-accounts strong { color:#70798d; font-size:9px; }.demo-accounts button { border:0; color:#6a748a; background:transparent; font-family:Consolas,monospace; font-size:9px; text-align:left; }
|
||||
.auth-session-notice { display:grid; gap:4px; margin:-12px 0 18px; padding:12px 14px; border-left:3px solid var(--red); border-radius:7px; color:#814b47; background:#fceceb; }.auth-session-notice strong { font-size:10px; }.auth-session-notice span { font-size:9px; line-height:1.7; }
|
||||
|
||||
/* Portal shell */
|
||||
.portal { min-height:100vh; }.portal-sidebar { position:fixed; inset:0 auto 0 0; z-index:35; width:238px; display:flex; flex-direction:column; padding:24px 17px 18px; color:#fff; background:var(--navy); overflow:hidden; }.portal-sidebar::after { content:""; position:absolute; width:260px; height:260px; left:-130px; bottom:-80px; border:1px solid rgba(255,255,255,.06); border-radius:50%; box-shadow:0 0 0 45px rgba(255,255,255,.02),0 0 0 90px rgba(255,255,255,.015); }.portal-brand { z-index:1; display:flex; align-items:center; justify-content:space-between; padding:0 9px 22px; border-bottom:1px solid rgba(255,255,255,.1); }.portal-brand .brand { color:#fff; }.portal-brand .brand small { color:#7e8db9; }.portal-brand > button { display:none; border:0; color:#fff; background:transparent; font-size:25px; }.portal-role { margin:20px 12px 9px; color:#6f7fae; font-family:Consolas,monospace; font-size:9px; letter-spacing:1.5px; }.portal-sidebar nav { z-index:1; display:grid; gap:4px; }.portal-sidebar nav button { width:100%; min-height:42px; display:flex; align-items:center; gap:12px; padding:0 13px; border:0; border-radius:8px; color:#aab5d4; background:transparent; font-size:11px; text-align:left; transition:.18s; }.portal-sidebar nav button > span { width:20px; display:grid; place-items:center; }.portal-sidebar nav button svg { width:17px; }.portal-sidebar nav button:hover { color:#fff; background:rgba(255,255,255,.05); }.portal-sidebar nav button.active { color:#fff; background:var(--navy-soft); box-shadow:inset 3px 0 var(--red); }.portal-sidebar nav button em { margin-left:auto; padding:2px 5px; border-radius:8px; color:#fff; background:var(--red); font-size:7px; font-style:normal; }.sidebar-help { z-index:1; display:grid; gap:4px; margin-top:auto; padding:15px 12px; border:1px solid rgba(255,255,255,.08); border-radius:9px; background:rgba(255,255,255,.03); }.sidebar-help span { color:#7484b1; font-size:8px; }.sidebar-help strong { font-family:Consolas,monospace; font-size:11px; }.sidebar-help small { color:#8897bd; font-size:8px; }.portal-main { min-height:100vh; margin-left:238px; }.portal-topbar { height:70px; display:flex; align-items:center; gap:20px; padding:0 30px; border-bottom:1px solid var(--line); background:rgba(255,255,255,.9); backdrop-filter:blur(16px); }.portal-topbar > div:first-of-type { display:flex; gap:8px; align-items:center; font-size:10px; }.portal-topbar > div:first-of-type span,.portal-topbar > div:first-of-type b { color:#9aa1b0; font-weight:400; }.portal-user { display:flex; align-items:center; gap:9px; margin-left:auto; }.portal-user > span:nth-of-type(2) { display:grid; }.portal-user > span strong { font-size:10px; }.portal-user > span small { color:#8c94a6; font-size:8px; }.user-avatar { width:32px; height:32px; display:grid; place-items:center; flex:0 0 auto; border-radius:9px; color:#43578b; background:#e4e9f5; font-size:11px; font-weight:700; }.notification-button,.logout-button,.sidebar-toggle { width:36px; height:36px; display:grid; place-items:center; border:1px solid var(--line); border-radius:8px; color:#687287; background:#fff; }.notification-button { position:relative; }.notification-button i { position:absolute; top:8px; right:8px; width:5px; height:5px; border-radius:50%; background:var(--red); }.notification-button svg,.logout-button svg,.sidebar-toggle svg { width:15px; }.logout-button { border:0; background:transparent; }.sidebar-toggle { display:none; }.portal-content { padding:31px; }.portal-heading { display:flex; align-items:flex-end; justify-content:space-between; gap:25px; margin-bottom:24px; }.portal-heading h1 { margin:5px 0 5px; font-family:"STKaiti"; font-size:31px; font-weight:400; }.portal-heading > div > p:last-child { margin:0; color:#81899a; font-size:11px; }.heading-status { color:#7f8798; font-size:10px; }.heading-status .status { margin-left:6px; }
|
||||
@@ -254,6 +255,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
/* Admin */
|
||||
.admin-metrics { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin-bottom:16px; }.admin-metrics article { display:flex; align-items:center; gap:13px; padding:20px; border:1px solid var(--line); border-radius:12px; background:#fff; }.admin-metrics article > span { width:40px; height:40px; display:grid; place-items:center; border-radius:9px; color:var(--blue); background:#ecf1fb; }.admin-metrics article:nth-child(2)>span { color:var(--amber); background:#fff3dd; }.admin-metrics article:nth-child(3)>span { color:var(--red); background:#fbe9e7; }.admin-metrics article:nth-child(4)>span { color:var(--jade); background:#e4f3ee; }.admin-metrics article > span svg { width:18px; }.admin-metrics div { display:grid; }.admin-metrics small { color:#8d95a6; font-size:8px; }.admin-metrics strong { margin:2px 0; font-family:Georgia,serif; font-size:23px; font-weight:400; }.admin-metrics em { color:#818a9c; font-size:7px; font-style:normal; }.admin-dashboard-grid { display:grid; grid-template-columns:1.05fr 1fr; gap:16px; }.admin-todos { overflow:hidden; }.admin-todos > button { width:100%; display:grid; grid-template-columns:36px 1fr 18px; align-items:center; gap:11px; padding:14px 18px; border:0; border-bottom:1px solid var(--line); color:var(--ink); background:#fff; text-align:left; }.admin-todos > button:last-child { border-bottom:0; }.admin-todos > button:hover { background:#fafbfc; }.admin-todos > button > i { width:34px; height:34px; display:grid; place-items:center; border-radius:9px; color:#65728d; background:#edf0f6; font-size:10px; font-style:normal; font-weight:700; }.admin-todos > button > i.urgent { color:#a8463f; background:#fbe8e6; }.admin-todos button > span { display:grid; gap:3px; }.admin-todos strong { font-size:9px; }.admin-todos small { color:#9299aa; font-size:7px; }.admin-todos button > svg { color:#9ba2b1; }.audit-feed > div { display:grid; grid-template-columns:32px 1fr auto; align-items:center; gap:10px; padding:14px 18px; border-bottom:1px solid var(--line); }.audit-feed > div:last-child { border-bottom:0; }.audit-feed p { display:grid; gap:3px; margin:0; }.audit-feed p strong { font-size:9px; }.audit-feed p small { color:#9098a8; font-size:7px; }.audit-feed time { color:#8d95a5; font-size:7px; }
|
||||
.data-panel { overflow:hidden; }.data-toolbar { min-height:64px; display:flex; align-items:center; justify-content:space-between; gap:15px; padding:13px 17px; border-bottom:1px solid var(--line); }.data-toolbar > p { margin:0; color:#878f9f; font-size:8px; }.search-box { width:min(330px,40%); min-height:36px; display:flex; align-items:center; gap:8px; padding:0 11px; border:1px solid var(--line); border-radius:8px; }.search-box svg { width:14px; color:#9098a9; }.search-box input { width:100%; border:0; outline:0; background:transparent; font-size:9px; }.filter-pills { display:flex; gap:4px; }.filter-pills button { min-height:31px; padding:0 11px; border:1px solid var(--line); border-radius:7px; color:#778094; background:#fff; font-size:8px; }.filter-pills button.active { border-color:var(--navy); color:#fff; background:var(--navy); }.table-scroll { overflow-x:auto; }table { width:100%; border-collapse:collapse; white-space:nowrap; }th { padding:11px 14px; color:#858d9f; background:#fafbfc; font-size:8px; font-weight:600; text-align:left; }td { padding:13px 14px; border-top:1px solid #edf0f5; color:#5b657a; font-size:9px; }tbody tr { transition:background .15s; }tbody tr:hover { background:#fafbfe; }td > strong,td > small { display:block; }td > strong { color:var(--ink); font-size:9px; }td > small { max-width:230px; margin-top:3px; overflow:hidden; color:#9299a9; font-size:7px; text-overflow:ellipsis; }.person-cell { display:flex; align-items:center; gap:9px; }.person-cell > span { width:31px; height:31px; display:grid; place-items:center; border-radius:8px; color:#536691; background:#e8edf7; font-size:10px; font-weight:700; }.person-cell > div { display:grid; gap:2px; }.person-cell strong { color:var(--ink); font-size:9px; }.person-cell small { color:#9299a9; font-size:7px; }.mono { font-family:Consolas,monospace; }.row-action { border:0; color:var(--blue); background:transparent; font-size:8px; font-weight:700; }.row-action.primary { padding:6px 9px; border-radius:6px; color:#fff; background:var(--navy); }.table-chips { display:flex; gap:3px; }.table-chips span { padding:3px 5px; border-radius:4px; color:#5e6980; background:#eef1f6; font-size:7px; }.pin-label { color:var(--red); font-size:8px; }
|
||||
.notice-admin-toolbar { flex-wrap:wrap; }.notice-admin-toolbar > p { flex:1 0 100%; }.notice-row-actions { display:flex; align-items:center; gap:8px; }
|
||||
.registration-toolbar,.payment-toolbar { flex-wrap:wrap; }.table-filter-selects { flex:1 0 100%; display:grid; grid-template-columns:repeat(4,minmax(130px,1fr)); gap:8px; }.table-filter-selects select { width:100%; min-height:35px; padding:7px 10px; border:1px solid var(--line); border-radius:7px; color:#59647a; background:#fff; font-size:8px; }.registration-bulk-bar { min-height:54px; display:flex; align-items:center; justify-content:space-between; gap:14px; padding:10px 17px; border-bottom:1px solid #dce4f1; background:#f4f7fc; }.registration-bulk-bar > span { color:#6e788d; font-size:8px; }.registration-bulk-bar > span strong { margin:0 3px; color:var(--navy); font-size:13px; }.registration-bulk-bar > div { display:flex; gap:7px; }.registration-bulk-bar .row-action { padding:7px 10px; border:1px solid #ccd6e7; border-radius:7px; background:#fff; }.registration-bulk-bar .row-action.primary { border-color:var(--navy); background:var(--navy); }.registration-bulk-bar .row-action:disabled { border-color:#e1e5ec; color:#aab1bf; background:#f8f9fb; }.selection-cell { width:42px; padding-right:8px; text-align:center; }.selection-cell input { width:15px; height:15px; accent-color:var(--navy); }.row-action.danger { color:#a64b48; }
|
||||
.admin-exam-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:15px; }.admin-exam-card { position:relative; padding:22px; border:1px solid var(--line); border-radius:var(--radius); background:#fff; overflow:hidden; }.admin-exam-card.editable { cursor:pointer; transition:border-color .18s,box-shadow .18s,transform .18s; }.admin-exam-card.editable:hover { border-color:#bdc8df; box-shadow:var(--shadow); transform:translateY(-2px); }.admin-exam-card.published::before { content:""; position:absolute; top:0; bottom:0; left:0; width:4px; background:var(--jade); }.admin-exam-card header { display:flex; align-items:center; justify-content:space-between; }.admin-exam-card h2 { margin:16px 0 7px; font-family:"STKaiti"; font-size:21px; font-weight:400; }.admin-exam-card > p { min-height:34px; margin:0; color:#828a9a; font-size:9px; line-height:1.8; }.admin-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin:19px 0; }.admin-exam-card dl div:last-child { grid-column:1/-1; }.admin-exam-card dt { color:#999fac; font-size:7px; }.admin-exam-card dd { margin:3px 0 0; color:#5e687d; font-size:8px; }.admin-subjects { display:flex; flex-wrap:wrap; gap:5px; padding:12px; border-radius:8px; background:#f7f8fb; }.admin-subjects span { display:grid; gap:2px; padding:6px 8px; border:1px solid #e3e7ef; border-radius:5px; background:#fff; }.admin-subjects b { font-size:8px; }.admin-subjects small { color:#969dac; font-size:6px; }.admin-exam-card footer { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-top:15px; padding-top:13px; border-top:1px solid var(--line); }.admin-exam-card footer > span { color:#858d9e; font-size:8px; }.exam-card-actions { display:flex; align-items:center; gap:7px; }.results-admin-grid { display:grid; grid-template-columns:.8fr 1.2fr; gap:16px; }.result-entry form { display:grid; gap:14px; padding:20px; }.publish-switch { justify-content:flex-start; }.published-results > div:not(.panel-title) { display:grid; grid-template-columns:32px 1fr auto auto; align-items:center; gap:10px; padding:12px 18px; border-bottom:1px solid var(--line); }.published-results > div:last-child { border-bottom:0; }.published-results p { display:grid; gap:3px; margin:0; }.published-results p strong { font-size:9px; }.published-results p small { color:#9098a9; font-size:7px; }.published-results b { font-family:Georgia,serif; font-size:17px; font-weight:400; }
|
||||
.exam-score-band { display:grid; grid-template-columns:140px 1fr; margin:17px -22px 0; color:#fff; background:var(--navy); }
|
||||
@@ -267,6 +269,7 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
|
||||
/* Modals and feedback */
|
||||
.modal-layer { position:fixed; inset:0; z-index:100; display:grid; place-items:center; padding:22px; background:rgba(12,22,48,.55); backdrop-filter:blur(5px); animation:fadeIn .18s ease; }.modal-card { width:min(620px,100%); max-height:90vh; border-radius:16px; background:#fff; box-shadow:0 30px 90px rgba(13,23,51,.3); overflow-y:auto; animation:modalIn .22s ease; }.modal-head { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; padding:22px 24px 18px; border-bottom:1px solid var(--line); }.modal-head span { color:#8b94a8; font-family:Consolas,monospace; font-size:8px; letter-spacing:1.5px; }.modal-head h2 { margin:5px 0; font-family:"STKaiti"; font-size:23px; font-weight:400; }.modal-head p { margin:0; color:#8c94a5; font-size:8px; }.modal-head > button { border:0; color:#8a92a2; background:transparent; font-size:23px; }.notice-content { padding:25px; color:#525d73; overflow-wrap:anywhere; }.notice-content p,.notice-content li { color:#525d73; font-size:11px; line-height:2; }.notice-content p { margin:0 0 13px; }.notice-content h2,.notice-content h3,.notice-content h4 { margin:22px 0 10px; color:var(--navy); font-family:"STKaiti"; font-weight:400; }.notice-content h2 { font-size:22px; }.notice-content h3 { font-size:18px; }.notice-content h4 { font-size:15px; }.notice-content ul,.notice-content ol { margin:0 0 14px; padding-left:24px; }.notice-content blockquote { margin:15px 0; padding:10px 14px; border-left:3px solid var(--blue); background:#f4f7fc; }.notice-content blockquote p { margin:0; }.notice-content a { color:var(--blue); text-decoration:underline; text-underline-offset:2px; }.modal-form { display:grid; gap:14px; padding:22px 24px 0; }.modal-foot { display:flex; justify-content:flex-end; gap:8px; margin:20px -24px 0; padding:15px 24px; border-top:1px solid var(--line); background:#fafbfc; }.modal-card > .modal-foot { margin:0; }.review-profile,.registration-review,.admit-preview { padding:22px 24px 0; }.review-profile dl { display:grid; grid-template-columns:1fr 1fr; gap:13px; margin:0; }.review-profile dl div,.registration-review dl div,.admit-preview dl div { display:grid; gap:4px; padding:10px; border-radius:7px; background:#f7f8fb; }.review-profile dt,.registration-review dt,.admit-preview dt { color:#969dac; font-size:7px; }.review-profile dd,.registration-review dd,.admit-preview dd { margin:0; color:#525d73; font-size:9px; }.registration-review > div > span { color:#9199a9; font-size:8px; }.registration-review > div p { display:flex; flex-wrap:wrap; gap:5px; }.registration-review > div b { padding:5px 8px; border-radius:5px; color:#54617a; background:#eef1f6; font-size:8px; }.registration-review dl,.admit-preview dl { display:grid; grid-template-columns:repeat(3,1fr); gap:9px; }.admit-preview > strong { display:block; margin:5px 0 18px; color:var(--navy); font-family:Consolas,monospace; font-size:26px; letter-spacing:2px; }.admit-preview > p { margin:16px 0 0; padding:11px; border-radius:7px; color:#7a5a24; background:#fff3dc; font-size:8px; }.toast { position:fixed; right:24px; bottom:24px; z-index:130; min-width:245px; display:flex; align-items:center; gap:11px; padding:13px 15px; border:1px solid #dfe7e3; border-radius:10px; background:#fff; box-shadow:0 17px 50px rgba(18,39,30,.17); opacity:0; transform:translateY(25px); pointer-events:none; transition:.25s; }.toast.show { opacity:1; transform:none; }.toast-icon { width:29px; height:29px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--jade); font-size:11px; }.toast div { display:grid; gap:2px; }.toast strong { font-size:9px; }.toast small { color:#858d9e; font-size:8px; }.fatal-error { min-height:100vh; display:grid; place-content:center; justify-items:center; padding:25px; text-align:center; }.fatal-error > span { width:55px; height:55px; display:grid; place-items:center; border-radius:50%; color:#fff; background:var(--red); font-family:Georgia,serif; font-size:28px; }.fatal-error h1 { margin:18px 0 8px; font-family:"STKaiti"; font-size:28px; font-weight:400; }.fatal-error p { margin:0 0 18px; color:#7f8799; font-size:10px; }.empty-state { padding:35px; color:#8b93a4; font-size:9px; text-align:center; }
|
||||
.fatal-error-actions { display:flex; gap:9px; }
|
||||
.modal-card:has(.exam-config-form) { width:min(980px,100%); }
|
||||
.modal-card:has(.notice-editor-form) { width:min(820px,100%); }
|
||||
.notice-editor-field { display:grid; gap:7px; }
|
||||
@@ -370,6 +373,51 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.result-metric-grid strong { display:block; margin:7px 0 5px; color:#173f60; font:400 25px Georgia,serif; }
|
||||
.result-metric-grid strong em { color:#8c96aa; font-size:13px; font-style:normal; }
|
||||
.result-excel-toolbar { margin-bottom:14px; }
|
||||
.result-entry-workbench { margin-bottom:14px; overflow:hidden; border-color:#cbd6e6; box-shadow:0 14px 34px rgba(20,36,81,.07); }
|
||||
.result-workbench-head { display:grid; grid-template-columns:minmax(280px,1fr) minmax(430px,1.1fr); align-items:end; gap:28px; padding:22px 24px; color:#fff; background:linear-gradient(112deg,#12244d 0%,#1c4269 72%,#236b78 100%); }
|
||||
.result-workbench-head > div:first-child > span { color:#8ed0d4; font:700 9px Consolas,monospace; letter-spacing:.18em; }
|
||||
.result-workbench-head h2 { margin:6px 0 4px; font-family:"STKaiti","KaiTi",serif; font-size:25px; font-weight:400; }
|
||||
.result-workbench-head p { margin:0; color:#c6d4e3; font-size:10px; line-height:1.7; }
|
||||
.result-workbench-selectors { display:grid; grid-template-columns:1.25fr .75fr; gap:10px; }
|
||||
.result-workbench-selectors label { display:grid; gap:6px; color:#dbe6f2; font-size:9px; font-weight:700; }
|
||||
.result-workbench-selectors select { width:100%; min-height:42px; padding:8px 11px; border:1px solid rgba(255,255,255,.3); border-radius:8px; color:#15264b; background:#fff; font-size:11px; outline:0; }
|
||||
.result-workbench-selectors select:focus { box-shadow:0 0 0 3px rgba(142,208,212,.24); }
|
||||
.result-workbench-summary { display:grid; grid-template-columns:repeat(4,110px) 1fr; align-items:center; min-height:69px; padding:10px 24px; border-bottom:1px solid #e2e8f0; background:#f5f8fc; }
|
||||
.result-workbench-summary > span { display:grid; gap:3px; border-right:1px solid #dce4ee; }
|
||||
.result-workbench-summary small { color:#8a94a7; font-size:8px; }
|
||||
.result-workbench-summary strong { color:#143a5b; font:400 19px Georgia,serif; }
|
||||
.result-workbench-summary p { justify-self:end; margin:0; padding-left:18px; color:#60708a; font-size:10px; }
|
||||
.result-entry-toolbar { min-height:62px; border-bottom:1px solid #e5eaf1; }
|
||||
.result-entry-table-wrap { max-height:560px; overflow:auto; }
|
||||
.result-entry-table-wrap table { min-width:1050px; }
|
||||
.result-entry-table-wrap thead { position:sticky; top:0; z-index:3; }
|
||||
.result-entry-table-wrap th { color:#56647c; background:#eef3f8; box-shadow:0 1px #dbe3ed; }
|
||||
.result-entry-table-wrap tbody tr { transition:background .12s; }
|
||||
.result-entry-table-wrap tbody tr:focus-within { background:#f1f7ff; }
|
||||
.result-row-index { color:#97a1b1; text-align:center; }
|
||||
.result-score-input-cell { min-width:170px; }
|
||||
.result-score-input-cell input { width:126px; height:38px; padding:7px 11px; border:1px solid #cbd5e3; border-radius:7px; color:#102c4d; background:#fff; font:700 14px Consolas,monospace; outline:0; }
|
||||
.result-score-input-cell input:focus { border-color:#315fba; box-shadow:0 0 0 3px rgba(49,95,186,.1); }
|
||||
.result-score-input-cell input[data-dirty="true"] { border-color:#bd7d19; background:#fffaf0; }
|
||||
.result-score-input-cell small { display:block; min-height:12px; margin-top:3px; color:#b23838; font-size:8px; }
|
||||
.score-row-invalid { background:#fff6f5 !important; }
|
||||
.score-row-invalid input { border-color:#c74646; }
|
||||
.result-missing { display:inline-flex; padding:4px 8px; border-radius:99px; color:#7b8495; background:#eef1f5; font-size:8px; }
|
||||
.result-workbench-actions { display:flex; align-items:center; justify-content:flex-end; gap:9px; min-height:74px; padding:13px 24px; border-top:1px solid #dfe6ef; background:#fff; box-shadow:0 -8px 20px rgba(20,36,81,.03); }
|
||||
.result-workbench-actions > div { display:grid; gap:4px; margin-right:auto; }
|
||||
.result-workbench-actions > div strong { color:#32435f; font-size:10px; }
|
||||
.result-workbench-actions > div small { color:#8b95a7; font-size:8px; }
|
||||
.feature-score-workbench { margin-bottom:14px; overflow:hidden; border-color:#bcd9dc; box-shadow:0 14px 34px rgba(25,86,95,.07); }
|
||||
.feature-score-workbench > form > header { display:grid; grid-template-columns:minmax(300px,1fr) auto; align-items:center; gap:24px; padding:21px 24px; color:#133d48; background:linear-gradient(110deg,#eef9f8,#dceff0); border-left:4px solid #287486; }
|
||||
.feature-score-workbench header > div:first-child > span { color:#287486; font:700 9px Consolas,monospace; letter-spacing:.18em; }
|
||||
.feature-score-workbench h2 { margin:5px 0; font-family:"STKaiti","KaiTi",serif; font-size:24px; font-weight:400; }
|
||||
.feature-score-workbench header p { max-width:720px; margin:0; color:#5b7780; font-size:10px; line-height:1.7; }
|
||||
.feature-score-rule { display:grid; gap:5px; min-width:265px; padding:12px 15px; border:1px solid #b9d9da; border-radius:9px; background:rgba(255,255,255,.72); }
|
||||
.feature-score-rule strong { color:#245b65; font-size:10px; }
|
||||
.feature-score-rule span { color:#607a81; font-size:9px; }
|
||||
.feature-score-input-cell input { width:140px; }
|
||||
.feature-score-workbench td > small { display:block; margin-top:3px; color:#8a95a6; }
|
||||
.feature-score-summary { grid-template-columns:repeat(4,125px) 1fr; }
|
||||
.result-entry-console { margin-bottom:14px; overflow:hidden; }
|
||||
.result-entry-console .panel-title { height:auto; min-height:74px; }
|
||||
.result-entry-console .panel-title p { margin:4px 0 0; color:#8791a4; font-size:10px; }
|
||||
@@ -581,3 +629,482 @@ button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
@media (max-width: 620px) {
|
||||
.scope-banner { align-items:flex-start; }.candidate-flow-note { grid-template-columns:1fr; }.segment-option { grid-template-columns:18px 52px 1fr; }.segment-value,.segment-width { grid-column:2/-1; }.workflow-step-row { grid-template-columns:28px 1fr 26px; }.workflow-step-row > i { width:28px;height:28px; }.workflow-step-row select { grid-column:2/3; }.workflow-owner { grid-template-columns:1fr; }.center-card dl { grid-template-columns:1fr; }.number-rule-layout { display:block; }.rule-preview { margin-top:14px; }.center-summary,.center-metrics,.center-profile,.batch-number-panel form,.room-editor-grid,.flow-center-snapshot dl { grid-template-columns:1fr; }.center-dossier > header,.center-dossier > footer,.center-form-section > header,.approval-callout { align-items:flex-start; flex-direction:column; }.center-dossier > header > div:last-child { flex-wrap:wrap; }.room-editor-grid .room-notes { grid-column:auto; }.onboarding-page { display:block; }.onboarding-identity { position:relative; min-height:auto; padding:25px 20px; }.onboarding-identity > span { margin-top:35px; }.onboarding-identity > p { display:none; }.onboarding-identity > button { position:absolute; top:26px; right:20px; }.onboarding-steps { margin-top:30px; }.onboarding-steps > div { min-height:58px; }.onboarding-work { padding:30px 14px 45px; }.profile-form .wide-field { grid-column:auto; }.registration-policy { align-items:flex-start; flex-direction:column; }.registration-policy form { width:100%; justify-content:space-between; }.account-number-principle { grid-template-columns:1fr; }.account-number-principle span { grid-row:auto; writing-mode:horizontal-tb; }
|
||||
}
|
||||
|
||||
/* 中考志愿与招生录取:唯一强调元素是贯穿全流程的进度轨道。 */
|
||||
.admission-command-banner { display:flex; justify-content:space-between; gap:28px; margin-bottom:18px; padding:26px 30px; border-radius:14px; color:#fff; background:linear-gradient(118deg,#17375f 0%,#245783 62%,#2b7180 100%); box-shadow:0 16px 34px rgba(23,55,95,.18); }
|
||||
.admission-command-banner > div span { color:#9fcad5; font:700 10px/1.2 Consolas,monospace; letter-spacing:1.8px; }.admission-command-banner h2 { margin:8px 0 6px; font-size:24px; }.admission-command-banner p { max-width:650px; margin:0; color:#dceaf0; line-height:1.7; }.admission-command-banner dl { display:grid; grid-template-columns:repeat(4,minmax(70px,1fr)); gap:10px; margin:0; }.admission-command-banner dl div { padding:12px; border:1px solid rgba(255,255,255,.16); border-radius:9px; background:rgba(255,255,255,.07); }.admission-command-banner dt { color:#b8d6df; }.admission-command-banner dd { margin:4px 0 0; font-size:22px; font-weight:800; }
|
||||
.admission-admin-grid { display:grid; grid-template-columns:1.35fr .85fr; gap:16px; margin-bottom:16px; }.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { display:grid; gap:12px; }.admission-control-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:18px; padding-top:16px; border-top:1px solid var(--line); }
|
||||
.admission-admin-grid.single { grid-template-columns:1fr; }
|
||||
.admission-candidate-list { display:grid; gap:18px; }.admission-candidate-card { padding:24px; }.admission-candidate-card > header { display:flex; justify-content:space-between; gap:18px; }.admission-candidate-card > header span { color:var(--muted); font:700 10px Consolas,monospace; }.admission-candidate-card h2 { margin:5px 0 0; }
|
||||
.admission-progress-track { position:relative; display:grid; grid-template-columns:repeat(4,1fr); margin:26px 0; }.admission-progress-track::before { content:""; position:absolute; top:15px; left:10%; right:10%; height:2px; background:#dbe4ec; }.admission-progress-track div { position:relative; z-index:1; display:grid; justify-items:center; gap:7px; color:#8190a0; }.admission-progress-track i { display:grid; place-items:center; width:32px; height:32px; border:2px solid #dbe4ec; border-radius:50%; background:#fff; font-style:normal; font-weight:800; }.admission-progress-track .done i,.admission-progress-track .current i { border-color:#287486; color:#fff; background:#287486; }.admission-progress-track .current i { box-shadow:0 0 0 6px rgba(40,116,134,.12); }.admission-progress-track .done,.admission-progress-track .current { color:#214d5a; font-weight:700; }
|
||||
.admission-score-strip { display:flex; align-items:center; gap:12px; padding:14px 16px; border-radius:10px; background:#f2f7f8; }.admission-score-strip strong { margin-right:auto; font-size:18px; }.admission-score-strip em { color:#287486; font-style:normal; font-weight:700; }.admission-progress-copy { color:var(--muted); }.admission-result-banner { display:grid; gap:4px; margin:14px 0; padding:16px; border-left:4px solid #287486; border-radius:8px; background:#eef7f8; }.admission-result-banner strong { font-size:17px; }
|
||||
.preference-form { margin-top:18px; padding-top:18px; border-top:1px solid var(--line); }.preference-form-head { display:flex; justify-content:space-between; gap:12px; margin-bottom:12px; }.preference-form-head small { display:block; margin-top:4px; color:var(--muted); }.preference-choice-list { display:grid; gap:9px; margin-bottom:14px; }.preference-choice-list label { display:grid; grid-template-columns:34px 1fr; align-items:center; gap:9px; }.preference-choice-list b { display:grid; place-items:center; width:30px; height:30px; border-radius:50%; color:#fff; background:#244e72; }.preference-choice-list select,.placement-review-form select,.placement-review-form input { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }.locked-preferences { display:grid; gap:8px; margin-top:16px; }.locked-preferences > strong { margin-bottom:2px; color:#27384e; }.locked-preferences > span,.admin-preference-choices > span { display:flex; align-items:center; gap:12px; padding:11px 13px; border:1px solid #e2e8ee; border-radius:9px; background:#f7f9fb; }.locked-preferences b,.admin-preference-choices b { flex:0 0 35px; color:#287486; font-size:11px; text-align:center; }.locked-preferences i,.admin-preference-choices i { display:grid; gap:3px; font-style:normal; }.locked-preferences small,.admin-preference-choices small { color:#7d8998; }.admin-preference-choices { display:grid; gap:6px; min-width:300px; }
|
||||
.read-only-callout.warning { border-left:3px solid #b27b2d; color:#75531f; background:#fff8e9; }
|
||||
|
||||
/* 录取结果通知沿用正式通知书的“签发栏”语汇,以状态与校名为阅读主线。 */
|
||||
.admission-notification-stack { display:grid; gap:12px; margin-bottom:18px; }
|
||||
.admission-notification { position:relative; display:grid; grid-template-columns:92px minmax(0,1fr) auto; overflow:hidden; border:1px solid #cad9df; border-radius:14px; color:#24354b; background:linear-gradient(105deg,#f5fafb 0 92px,#fff 92px); box-shadow:0 14px 34px rgba(23,55,95,.08); }
|
||||
.admission-notification::after { content:""; position:absolute; inset:0 0 auto 92px; height:4px; background:linear-gradient(90deg,#287486,#d2ad62 70%,transparent); }
|
||||
.admission-notification-mark { display:grid; align-content:space-between; justify-items:center; padding:20px 12px; color:#dcebed; background:#17375f; }.admission-notification-mark span { writing-mode:vertical-rl; color:#9fc8cd; font:700 10px Consolas,monospace; letter-spacing:.18em; }.admission-notification-mark strong { display:grid; place-items:center; width:34px; height:34px; border:1px solid rgba(255,255,255,.35); border-radius:50%; font:400 18px Georgia,serif; }
|
||||
.admission-notification-copy { min-width:0; padding:23px 26px 20px; }.admission-notification-copy header { display:flex; align-items:flex-start; justify-content:space-between; gap:20px; }.admission-notification-copy header span { color:#287486; font-size:11px; font-weight:800; letter-spacing:.08em; }.admission-notification-copy h2 { margin:5px 0 0; font:700 24px/1.25 STKaiti,KaiTi,serif; }.admission-notification-copy time { color:#8793a2; font-size:11px; white-space:nowrap; }.admission-notification-copy > p { margin:12px 0 17px; color:#566478; }
|
||||
.admission-notification-copy dl { display:flex; flex-wrap:wrap; gap:0; margin:0; border-top:1px solid #e2e8ed; }.admission-notification-copy dl div { min-width:150px; padding:12px 24px 0 0; }.admission-notification-copy dt { color:#9099a7; font-size:10px; }.admission-notification-copy dd { margin:4px 0 0; font-weight:700; }
|
||||
.admission-notification-status { align-self:start; margin:22px 22px 0 0; padding:7px 11px; border:1px solid #a6cfc7; border-radius:999px; color:#17665b; background:#e7f5f1; font-size:11px; font-weight:800; white-space:nowrap; }
|
||||
.admission-notification.invalid { border-color:#dfcecb; }.admission-notification.invalid::after { background:linear-gradient(90deg,#9c5c55,#d2ad62 70%,transparent); }.admission-notification.invalid .admission-notification-mark { background:#6f3f43; }.admission-notification.invalid .admission-notification-status { border-color:#e1c6c1; color:#8a4941; background:#faece9; }
|
||||
@media (max-width:700px) { .admission-notification { grid-template-columns:58px minmax(0,1fr); }.admission-notification::after { left:58px; }.admission-notification-mark { padding:17px 8px; }.admission-notification-copy { padding:20px 16px; }.admission-notification-copy header { display:grid; gap:6px; }.admission-notification-status { grid-column:2; grid-row:2; justify-self:start; margin:0 16px 16px; }.admission-notification-copy dl { display:grid; grid-template-columns:1fr 1fr; }.admission-notification-copy dl div { min-width:0; } }
|
||||
.placement-review-form { display:grid; min-width:210px; gap:7px; }.public-admission-board { margin-bottom:18px; overflow:hidden; }.public-admission-board > header { display:flex; justify-content:space-between; padding:20px 22px; color:#fff; background:#214d5a; }.public-admission-board h3 { margin:5px 0 0; font-size:19px; }.public-admission-board table { margin:0; }
|
||||
.placement-review-ledger { margin-bottom:16px; overflow:hidden; }.placement-review-ledger .data-toolbar { flex-wrap:wrap; }.placement-review-ledger table { min-width:1250px; }.placement-review-ledger td > small,.placement-review-ledger td > strong { display:block; margin-top:3px; }.placement-status-pills,.account-status-pills { margin:0 24px 12px; }.placement-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:0 24px 16px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; }.placement-bulk-toolbar > label,.placement-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.placement-bulk-toolbar input { width:17px; height:17px; }.placement-bulk-toolbar strong { margin-right:5px; color:#287486; }.placement-bulk-toolbar button { min-height:40px; }
|
||||
@media (max-width:1000px) { .admission-command-banner { flex-direction:column; }.admission-admin-grid { grid-template-columns:1fr; } }
|
||||
@media (max-width:620px) { .admission-command-banner { padding:20px; }.admission-command-banner dl { grid-template-columns:repeat(2,1fr); }.admission-progress-track span { font-size:9px; }.admission-score-strip,.preference-form-head { align-items:flex-start; flex-direction:column; }.admission-score-strip strong { margin-right:0; } }
|
||||
|
||||
/* 招生资格与计划台账:配额卡片是本轮的唯一结构化视觉重点。 */
|
||||
.admission-plan-console.structured { padding:22px; margin-bottom:16px; }
|
||||
.admission-categories-builder { display:grid; gap:12px; }
|
||||
.admission-builder-head,.indicator-allocation-head { display:flex; align-items:center; justify-content:space-between; gap:16px; }
|
||||
.admission-builder-head small,.indicator-allocation-head small { display:block; margin-top:3px; color:var(--muted); }
|
||||
[data-admission-categories] { display:grid; gap:13px; }
|
||||
.admission-category-editor { overflow:hidden; border:1px solid #d8e2ec; border-radius:12px; background:#fbfdfe; }
|
||||
.admission-category-editor > header { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 14px; color:#fff; background:linear-gradient(100deg,#244e72,#287486); }
|
||||
.admission-category-editor > header div { display:flex; align-items:center; gap:10px; }.admission-category-editor > header span { color:#b9d8df; font:700 10px Consolas,monospace; }.admission-category-editor > header strong { font-size:13px; }
|
||||
.admission-category-editor > header button { border:0; color:#dbeef1; background:transparent; cursor:pointer; }
|
||||
.admission-category-fields { display:grid; grid-template-columns:1.2fr .55fr .7fr; gap:11px; padding:14px; }
|
||||
.specialty-plan-fields { grid-column:1/-1; display:grid; grid-template-columns:1fr 1fr; gap:11px; padding:12px; border-left:3px solid #2b7e89; border-radius:8px; background:#edf7f7; }.specialty-plan-fields.hidden { display:none; }
|
||||
.indicator-allocation-editor { padding:0 14px 14px; }.indicator-allocation-head { padding-top:12px; border-top:1px solid #dfe7ee; }
|
||||
[data-indicator-allocations] { display:grid; gap:8px; margin-top:10px; }.indicator-allocation-row { display:grid; grid-template-columns:1fr 150px auto; align-items:end; gap:9px; padding:10px; border-radius:9px; background:#f1f5f8; }
|
||||
.indicator-allocation-row label,.admission-category-fields label { display:grid; gap:5px; }.indicator-allocation-row label span,.admission-category-fields label > span,.school-role-selector > strong { color:#607087; font-size:11px; font-weight:700; }
|
||||
.indicator-allocation-row input,.indicator-allocation-row select,.admission-category-fields input,.admission-category-fields select { width:100%; min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
|
||||
.preference-choice-row { display:grid; grid-template-columns:34px 1fr 1fr; align-items:end; gap:10px; padding:11px; border:1px solid #dce5eb; border-radius:10px; background:#f8fbfc; }
|
||||
.preference-choice-list .preference-choice-row > b { align-self:center; }.preference-choice-list .preference-choice-row > label { display:grid; grid-template-columns:1fr; align-items:stretch; gap:5px; }.preference-choice-row label > span { color:#687789; font-size:10px; font-weight:700; }
|
||||
.admission-score-strip > span b { margin-left:4px; color:#244e72; }.admission-score-strip > span:not(:first-child) { padding-left:12px; border-left:1px solid #cadde1; }
|
||||
.school-type-summary { grid-template-columns:repeat(4,1fr); }.school-role-tags { display:flex; flex-wrap:wrap; gap:5px; }.school-role { padding:5px 8px; border-radius:99px; font-size:11px; font-weight:700; }.school-role.source { color:#245c72; background:#e5f2f7; }.school-role.admission { color:#3c6651; background:#e5f3eb; }
|
||||
.school-role-selector { display:grid; gap:9px; padding:13px; border:1px solid var(--line); border-radius:10px; background:#f7f9fc; }.school-role-selector > div { display:grid; grid-template-columns:1fr 1fr; gap:9px; }.school-role-selector label { display:grid; grid-template-columns:auto 1fr; gap:9px; padding:11px; border:1px solid #dce3ed; border-radius:8px; background:#fff; }.school-role-selector label span { display:grid; gap:3px; }.school-role-selector small { color:var(--muted); }
|
||||
.feature-score-console { display:grid; grid-template-columns:minmax(260px,.8fr) minmax(420px,1.2fr); align-items:end; gap:24px; margin-bottom:15px; padding:21px; border-left:4px solid #287486; background:linear-gradient(105deg,#fff,#edf7f7); }.feature-score-console > div > span,.admission-export-bar > div > span { color:#2c7180; font:700 10px Consolas,monospace; letter-spacing:1.2px; }.feature-score-console h2 { margin:5px 0; }.feature-score-console p { margin:0; color:var(--muted); line-height:1.7; }.feature-score-console form { display:grid; grid-template-columns:1fr 130px auto; align-items:end; gap:10px; }.feature-score-console label { display:grid; gap:5px; }.feature-score-console select,.feature-score-console input,.admission-export-bar select { min-height:40px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; background:#fff; }
|
||||
.admission-export-bar { display:grid; grid-template-columns:1fr minmax(250px,.5fr) auto; align-items:end; gap:18px; margin-bottom:15px; padding:20px 22px; border-left:4px solid #287486; background:linear-gradient(105deg,#fff,#eef7f8); }.admission-export-bar > div { display:grid; gap:4px; }.admission-export-bar label { display:grid; gap:5px; }.admission-export-bar small { color:var(--muted); }
|
||||
.specialty-qualification-grid select:disabled { color:#8f99a8; background:#f1f3f6; }
|
||||
.result-panel .result-summary { grid-template-columns:repeat(4,1fr); }
|
||||
.result-panel .result-summary.qualified > span:last-child strong,.result-panel .result-summary.unqualified > span:last-child strong { color:var(--navy); }.result-panel .result-summary.qualified > span:nth-child(3) strong { color:#237358; }.result-panel .result-summary.unqualified > span:nth-child(3) strong { color:#a24f43; }
|
||||
@media (max-width:1000px) { .admission-category-fields { grid-template-columns:1fr 1fr; }.admission-category-fields > label:first-child { grid-column:1/-1; }.feature-score-console,.admission-export-bar { grid-template-columns:1fr; }.school-type-summary { grid-template-columns:repeat(2,1fr); } }
|
||||
@media (max-width:620px) { .admission-category-fields,.specialty-plan-fields,.indicator-allocation-row,.preference-choice-row,.school-role-selector > div,.feature-score-console form,.school-type-summary { grid-template-columns:1fr; }.admission-category-fields > label:first-child { grid-column:auto; }.preference-choice-list .preference-choice-row > b { justify-self:start; }.admission-builder-head,.indicator-allocation-head { align-items:flex-start; flex-direction:column; }.result-panel .result-summary { grid-template-columns:1fr; } }
|
||||
|
||||
.qualification-ledger-intro { margin-bottom:22px; padding:30px 34px; border-radius:22px; color:#fff; background:linear-gradient(125deg,#17375f,#245783 68%,#287486); box-shadow:0 18px 40px rgba(23,55,95,.18); }
|
||||
.qualification-ledger-intro span,.announcement-hero .overline { color:#a9d9df; font:700 12px/1.4 Consolas,monospace; letter-spacing:.16em; }
|
||||
.qualification-ledger-intro h2 { margin:8px 0; font:700 28px/1.2 STKaiti,KaiTi,serif; }.qualification-ledger-intro p { max-width:780px; margin:0; color:rgba(255,255,255,.78); }
|
||||
.qualification-ledger { margin-bottom:22px; overflow:hidden; }.qualification-ledger > header { display:flex; align-items:center; justify-content:space-between; padding:22px 24px 14px; }.qualification-ledger > header span { color:#287486; font:700 12px Consolas,monospace; }.qualification-ledger > header h2 { margin:5px 0 0; }
|
||||
.qualification-ledger > header > strong { padding:8px 13px; border-radius:999px; color:#9a6818; background:#fff6df; }.qualification-ledger > header > strong.complete { color:#17665b; background:#e5f5f1; }
|
||||
.qualification-progress { height:4px; margin:0 24px; overflow:hidden; border-radius:99px; background:#e6ebf1; }.qualification-progress i { display:block; height:100%; background:linear-gradient(90deg,#287486,#5ea8a0); }
|
||||
.qualification-publication-state { margin:14px 24px; padding:10px 13px; border-left:3px solid #287486; color:#17665b; background:#eef8f7; }.qualification-publication-state.pending { border-color:#c69037; color:#76551d; background:#fff8e9; }.qualification-ledger select { min-width:190px; }
|
||||
.preference-choice-row.indicator { border-color:#d5c38f; background:#fffaf0; }.preference-choice-row.indicator > b { color:#8a641d; }
|
||||
|
||||
.disclosure-entry { display:flex; align-items:center; justify-content:space-between; gap:32px; padding:34px 40px; border-radius:24px; color:#fff; background:#17375f; }.disclosure-entry h2 { margin:5px 0 10px; font:700 32px/1.15 STKaiti,KaiTi,serif; }.disclosure-entry p:last-child { margin:0; color:rgba(255,255,255,.72); }
|
||||
.announcement-page { padding-bottom:70px; background:#f4f7fa; }.announcement-hero { min-height:360px; display:grid; grid-template-columns:minmax(0,1fr) auto; align-items:end; gap:60px; padding:80px max(5vw,24px) 55px; color:#fff; background:radial-gradient(circle at 75% 15%,rgba(73,151,154,.38),transparent 32%),linear-gradient(132deg,#102a49,#17375f 55%,#245783); }
|
||||
.announcement-hero h1 { margin:12px 0 18px; font:700 clamp(42px,6vw,74px)/.95 STKaiti,KaiTi,serif; letter-spacing:-.03em; }.announcement-hero h1 em { color:#9bd0d2; font-style:normal; }.announcement-hero p:last-child { max-width:700px; color:rgba(255,255,255,.75); font-size:16px; line-height:1.8; }
|
||||
.announcement-hero dl { display:grid; grid-template-columns:repeat(3,110px); margin:0; border:1px solid rgba(255,255,255,.2); }.announcement-hero dl div { padding:20px; border-right:1px solid rgba(255,255,255,.2); }.announcement-hero dl div:last-child { border:0; }.announcement-hero dt { color:rgba(255,255,255,.6); font-size:12px; }.announcement-hero dd { margin:7px 0 0; font:700 30px Georgia,serif; }
|
||||
.announcement-index { position:sticky; top:72px; z-index:5; display:flex; justify-content:center; gap:4px; padding:12px; border-bottom:1px solid #dfe5ec; background:rgba(247,249,252,.94); backdrop-filter:blur(12px); }.announcement-index a { padding:10px 18px; border-radius:999px; color:#17375f; font-weight:700; text-decoration:none; }.announcement-index a:hover { color:#287486; background:#e3eef2; }
|
||||
.announcement-register { scroll-margin-top:130px; }.announcement-sheet { margin-top:20px; overflow:hidden; }.announcement-sheet > header { display:flex; align-items:center; justify-content:space-between; padding:22px 25px; border-bottom:1px solid #e6ebf1; background:linear-gradient(90deg,#fff,#f4f8fa); }.announcement-sheet > header span { color:#637386; font-size:13px; }.announcement-sheet > header h3 { margin:5px 0 0; font-size:20px; }.announcement-sheet > header > strong { color:#17375f; }
|
||||
.qualification-result { display:inline-flex; min-width:38px; justify-content:center; padding:4px 9px; border-radius:99px; color:#7b5b24; background:#fff5db; font-weight:700; }.qualification-result.eligible { color:#17665b; background:#e4f4ef; }.cutoff-score { color:#a75c18; font:700 20px Georgia,serif; }
|
||||
@media (max-width:760px) { .disclosure-entry,.announcement-hero { display:block; }.disclosure-entry button { margin-top:22px; }.announcement-hero dl { margin-top:28px; grid-template-columns:repeat(3,1fr); }.announcement-hero dl div { padding:14px 10px; }.announcement-index { justify-content:flex-start; overflow-x:auto; }.announcement-index a { white-space:nowrap; } }
|
||||
|
||||
/* 招生控制台:用稳定的表单栅格替代浏览器默认控件排版 */
|
||||
.admission-settings-panel,.admission-account-panel,.admission-plan-console { overflow:hidden; }
|
||||
.admission-account-management { margin-bottom:16px; }.admission-account-management > form { grid-template-columns:minmax(240px,1fr) minmax(380px,1.4fr) minmax(220px,.8fr) auto; align-items:end; }.admission-account-management > form > .field-row { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { min-width:170px; }.account-management-divider { display:flex; align-items:center; justify-content:space-between; padding:15px 24px; border-top:1px solid #e1e7ee; border-bottom:1px solid #e1e7ee; background:#f7f9fc; }.account-management-divider strong { color:#263a54; }.account-management-divider span { color:#8390a3; font-size:12px; }.admission-account-management .data-toolbar { padding-top:16px; padding-bottom:12px; }.admission-account-management table { min-width:980px; }
|
||||
.admission-settings-panel .panel-title,.admission-account-panel .panel-title,.admission-plan-console .panel-title { min-height:88px; height:auto; padding:20px 24px; background:linear-gradient(100deg,#fff,#f6f9fc); }
|
||||
.admission-settings-panel .panel-title h2,.admission-account-panel .panel-title h2,.admission-plan-console .panel-title h2 { font:400 23px/1.2 STKaiti,KaiTi,serif; }
|
||||
.admission-settings-panel .panel-title p,.admission-account-panel .panel-title p,.admission-plan-console .panel-title p { margin:7px 0 0; color:#69778c; font-size:13px; line-height:1.6; }
|
||||
.admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:22px 24px 24px; gap:17px; }
|
||||
.admission-settings-panel form label,.admission-account-panel form label,.admission-plan-console form > label { display:grid; align-content:start; gap:7px; min-width:0; color:#45546a; font-size:13px; font-weight:700; }
|
||||
.admission-settings-panel input,.admission-settings-panel select,.admission-account-panel input,.admission-account-panel select,.admission-plan-console form > label input,.admission-plan-console form > label select,.admission-plan-console form > label textarea { width:100%; min-height:44px; padding:9px 12px; border:1px solid #ccd6e2; border-radius:8px; color:#26364b; background:#fff; font-size:14px; }
|
||||
.admission-settings-panel input:focus,.admission-settings-panel select:focus,.admission-account-panel input:focus,.admission-account-panel select:focus,.admission-plan-console input:focus,.admission-plan-console select:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); }
|
||||
.admission-settings-panel label small { color:#7d899a; font-size:12px; font-weight:400; line-height:1.5; }
|
||||
.admission-settings-panel .field-row,.admission-account-panel .field-row,.admission-plan-console > form > .field-row { gap:16px; }
|
||||
.admission-settings-panel .agreement,.admission-account-panel .agreement { display:flex; align-items:center; gap:10px; font-weight:500; }
|
||||
.admission-settings-panel .agreement input,.admission-account-panel .agreement input { width:18px; min-height:18px; }
|
||||
.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { justify-self:end; min-width:190px; min-height:44px; }
|
||||
.admission-control-actions { margin:0; padding:17px 24px; border-top:1px solid #e1e7ee; background:#f8fafc; }
|
||||
.admission-control-actions button { min-height:42px; }
|
||||
.admission-plan-console .admission-builder-head { margin-bottom:2px; }.admission-plan-console .admission-builder-head strong { font-size:16px; }.admission-plan-console .admission-builder-head small { font-size:12px; }
|
||||
.admission-plan-console .admission-category-fields label > span,.admission-plan-console .indicator-allocation-row label span { font-size:12px; }.admission-plan-console .admission-category-fields input,.admission-plan-console .admission-category-fields select,.admission-plan-console .indicator-allocation-row input,.admission-plan-console .indicator-allocation-row select { min-height:43px; font-size:14px; }
|
||||
|
||||
/* 指标资格批处理 */
|
||||
.qualification-bulk-toolbar { display:flex; align-items:center; justify-content:space-between; gap:18px; margin:16px 24px; padding:14px 16px; border:1px solid #d8e2e9; border-radius:10px; background:#f6f9fb; }
|
||||
.qualification-bulk-toolbar > label { display:flex; align-items:center; gap:9px; color:#3d4e63; font-size:13px; font-weight:700; }.qualification-bulk-toolbar input[type="checkbox"] { width:17px; height:17px; }
|
||||
.qualification-bulk-toolbar > div { display:flex; align-items:center; gap:10px; }.qualification-bulk-toolbar strong { margin-right:4px; color:#287486; font-size:13px; }.qualification-bulk-toolbar select { min-width:210px; min-height:40px; padding:7px 10px; border:1px solid #cbd6df; border-radius:7px; background:#fff; }.qualification-bulk-toolbar .solid-button { min-height:40px; padding:8px 14px; }
|
||||
.qualification-ledger tbody td:first-child { width:52px; text-align:center; }.qualification-ledger [data-qualification-select] { width:17px; height:17px; }
|
||||
|
||||
/* 管理员账户操作 */
|
||||
.admin-account-actions { display:flex; flex-wrap:wrap; gap:7px; }.row-action.danger { color:#a1463e; border-color:#e3bcb8; background:#fff8f7; }.row-action.danger:hover { color:#fff; background:#a94e45; }.admin-account-ledger td:last-child { min-width:180px; }
|
||||
|
||||
/* 完整通知目录与正文页 */
|
||||
.notice-archive-link { grid-column:1/-1; justify-self:end; border:0; color:#245783; background:transparent; font-weight:700; cursor:pointer; }
|
||||
.notice-center-page,.notice-document-page { min-height:calc(100vh - 76px); padding-bottom:80px; background:#f3f6f9; }
|
||||
.notice-center-hero { display:flex; align-items:flex-end; justify-content:space-between; min-height:255px; padding:70px max(5vw,28px) 42px; color:#fff; background:linear-gradient(118deg,#132f52 0 62%,#20677a); }
|
||||
.notice-center-hero h1 { margin:8px 0 12px; font:400 clamp(40px,5vw,62px)/1 STKaiti,KaiTi,serif; }.notice-center-hero p:last-child { margin:0; color:rgba(255,255,255,.72); font-size:15px; }.notice-center-hero > strong { display:grid; justify-items:end; font:400 52px/1 Georgia,serif; }.notice-center-hero > strong small { margin-top:8px; color:#a9d9df; font:12px/1.2 system-ui,sans-serif; letter-spacing:.12em; }
|
||||
.notice-center-shell { width:min(1180px,calc(100% - 48px)); display:grid; grid-template-columns:210px minmax(0,1fr); gap:0; margin:38px auto 0; border:1px solid #dbe2ea; background:#fff; box-shadow:0 16px 44px rgba(22,46,75,.08); }
|
||||
.notice-category-nav { padding:24px 0; border-right:1px solid #e1e6ec; background:#f7f9fb; }.notice-category-nav button { width:100%; display:flex; justify-content:space-between; padding:13px 20px; border:0; border-left:3px solid transparent; color:#5f6d80; background:transparent; text-align:left; cursor:pointer; }.notice-category-nav button span { color:#9aa4b2; }.notice-category-nav button.active { border-left-color:#287486; color:#17375f; background:#eaf2f4; font-weight:700; }
|
||||
.notice-directory > header { display:flex; align-items:center; justify-content:space-between; min-height:72px; padding:0 28px; border-bottom:1px solid #e1e6ec; }.notice-directory > header div { display:flex; align-items:baseline; gap:13px; }.notice-directory > header strong { color:#17375f; font-size:18px; }.notice-directory > header span,.notice-directory > header small { color:#8894a4; font-size:12px; }
|
||||
.notice-directory-list > button { width:100%; display:grid; grid-template-columns:76px minmax(0,1fr) 24px; align-items:center; gap:20px; min-height:112px; padding:18px 28px; border:0; border-bottom:1px solid #e6eaf0; color:inherit; background:#fff; text-align:left; cursor:pointer; transition:background .16s,padding-left .16s; }.notice-directory-list > button:hover { padding-left:34px; background:#f7fafb; }.notice-directory-list time { display:grid; justify-items:center; padding-right:18px; border-right:1px solid #dce3e9; color:#8190a2; }.notice-directory-list time strong { color:#245783; font:400 27px Georgia,serif; }.notice-directory-list time span { margin-top:5px; font-size:11px; }
|
||||
.notice-directory-copy { display:grid; gap:6px; min-width:0; }.notice-directory-copy em { color:#287486; font-size:11px; font-style:normal; font-weight:700; letter-spacing:.08em; }.notice-directory-copy strong { color:#24354b; font-size:17px; }.notice-directory-copy small { overflow:hidden; color:#7d8998; font-size:13px; line-height:1.55; text-overflow:ellipsis; white-space:nowrap; }.notice-directory-arrow { color:#8092a6; }
|
||||
.notice-pagination { display:flex; justify-content:center; gap:7px; padding:22px; }.notice-pagination button { min-width:36px; height:36px; padding:0 10px; border:1px solid #d7dee7; color:#526176; background:#fff; cursor:pointer; }.notice-pagination button.active { border-color:#17375f; color:#fff; background:#17375f; }.notice-pagination button:disabled { opacity:.42; cursor:not-allowed; }
|
||||
.notice-breadcrumb { width:min(980px,calc(100% - 48px)); display:flex; gap:10px; margin:0 auto; padding:38px 0 18px; color:#8792a1; }.notice-breadcrumb button { border:0; color:#245783; background:transparent; cursor:pointer; }.notice-document { width:min(980px,calc(100% - 48px)); margin:0 auto; border-top:5px solid #245783; background:#fff; box-shadow:0 14px 42px rgba(22,46,75,.08); }.notice-document > header { padding:46px 56px 34px; border-bottom:1px solid #dfe5eb; }.notice-document > header span { color:#287486; font-size:12px; font-weight:700; letter-spacing:.1em; }.notice-document > header h1 { margin:15px 0 18px; color:#1e3047; font:400 clamp(30px,4vw,44px)/1.25 STKaiti,KaiTi,serif; }.notice-document > header p { margin:0; color:#8994a2; }.notice-document > section { padding:40px 56px 50px; }.notice-document > footer { padding:20px 56px; border-top:1px solid #e1e6ec; background:#f8fafb; }.notice-document-content { color:#334257; font-size:15px; line-height:1.9; }.document-lead { margin:0 0 25px; padding:14px 17px; border-left:3px solid #287486; color:#526176; background:#f2f7f8; line-height:1.7; }.notice-document table { font-size:13px; }
|
||||
@media (max-width:800px) { .admission-settings-panel form,.admission-account-panel form,.admission-plan-console form { padding:18px; }.admission-settings-panel form > .solid-button,.admission-account-panel form > .solid-button,.admission-plan-console form > .solid-button { width:100%; }.qualification-bulk-toolbar { align-items:stretch; flex-direction:column; }.qualification-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.notice-center-shell { width:calc(100% - 28px); grid-template-columns:1fr; }.notice-category-nav { display:flex; overflow-x:auto; padding:8px; border-right:0; border-bottom:1px solid #e1e6ec; scrollbar-width:none; }.notice-category-nav::-webkit-scrollbar { display:none; }.notice-category-nav button { width:auto; min-width:max-content; border-left:0; border-bottom:3px solid transparent; }.notice-category-nav button.active { border-bottom-color:#287486; }.notice-directory-list > button { grid-template-columns:58px minmax(0,1fr) 18px; gap:12px; padding:15px; }.notice-directory-copy small { white-space:normal; }.notice-center-hero { display:block; }.notice-center-hero > strong { display:none; }.notice-document > header,.notice-document > section,.notice-document > footer { padding-left:22px; padding-right:22px; } }
|
||||
|
||||
/* 审核工作台:以完整子页面承载资料、考试和处理结论 */
|
||||
body.review-subpage-open { overflow:hidden; }
|
||||
.review-subpage-layer { position:fixed; inset:0; z-index:120; overflow:auto; background:#eef2f6; animation:fadeIn .16s ease; }
|
||||
.review-subpage { min-height:100vh; color:#27364a; background:linear-gradient(90deg,#f8fafc 0 72%,#edf2f5 72%); }
|
||||
.review-subpage-header { min-height:190px; display:grid; grid-template-columns:minmax(190px,1fr) minmax(420px,2.2fr) minmax(140px,1fr); align-items:center; gap:28px; padding:34px clamp(28px,5vw,76px); color:#fff; background:linear-gradient(112deg,#132f52 0 62%,#216b79); box-shadow:0 12px 35px rgba(23,55,95,.16); }
|
||||
.review-back { justify-self:start; align-self:start; padding:10px 0; border:0; color:#c5dbe4; background:transparent; font-size:14px; font-weight:700; cursor:pointer; }
|
||||
.review-back:hover { color:#fff; }
|
||||
.review-subpage-header > div > span { color:#9bd0d6; font:700 12px/1.3 Consolas,monospace; letter-spacing:.16em; }
|
||||
.review-subpage-header h1 { margin:9px 0 12px; font:500 clamp(28px,3vw,42px)/1.18 STKaiti,KaiTi,serif; }
|
||||
.review-subpage-header p { display:flex; flex-wrap:wrap; align-items:center; gap:10px; margin:0; color:#d2dce7; font-size:14px; }
|
||||
.review-subpage-header p i { width:4px; height:4px; border-radius:50%; background:#7fa8b8; }
|
||||
.review-subpage-header > .status { justify-self:end; min-width:82px; justify-content:center; padding:9px 14px; font-size:13px; }
|
||||
.review-subpage-layout { width:min(1500px,100%); display:grid; grid-template-columns:minmax(0,1fr) 360px; gap:28px; margin:0 auto; padding:32px clamp(24px,4vw,58px) 70px; }
|
||||
.review-subpage-main { min-width:0; display:grid; align-content:start; gap:24px; }
|
||||
.review-section { overflow:hidden; border:1px solid #dbe3ea; border-radius:14px; background:#fff; box-shadow:0 9px 28px rgba(28,52,76,.05); }
|
||||
.review-section > header { min-height:86px; display:flex; align-items:center; gap:16px; padding:20px 24px; border-bottom:1px solid #e3e9ee; background:linear-gradient(90deg,#fff,#f6f9fa); }
|
||||
.review-section > header > span { min-width:45px; height:34px; display:grid; place-items:center; border-radius:6px; color:#fff; background:#245783; font:700 12px Consolas,monospace; }
|
||||
.review-section > header > div { min-width:0; margin-right:auto; }
|
||||
.review-section > header h2 { margin:0; color:#21364e; font-size:21px; }
|
||||
.review-section > header p { margin:5px 0 0; color:#748396; font-size:14px; }
|
||||
.review-section > header > strong { color:#287486; font-size:15px; }
|
||||
.review-detail-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1px; margin:0; background:#e5eaef; }
|
||||
.review-detail-grid > div { min-width:0; padding:17px 20px; background:#fff; }
|
||||
.review-detail-grid > div.wide { grid-column:1/-1; }
|
||||
.review-detail-grid dt { margin-bottom:7px; color:#798799; font-size:13px; }
|
||||
.review-detail-grid dd { margin:0; overflow-wrap:anywhere; color:#293b51; font-size:15px; line-height:1.65; }
|
||||
.review-exam-list { display:grid; gap:14px; padding:20px 22px 24px; }
|
||||
.review-exam-card { overflow:hidden; border:1px solid #dfe6eb; border-radius:11px; background:#fbfcfd; }
|
||||
.review-exam-card > header { display:flex; align-items:center; justify-content:space-between; gap:20px; padding:16px 18px; border-bottom:1px solid #e4e9ed; background:#fff; }
|
||||
.review-exam-card h3 { margin:5px 0 0; font-size:18px; }
|
||||
.review-exam-card dl { display:grid; grid-template-columns:1fr 1fr; gap:0; margin:0; }
|
||||
.review-exam-card dl > div { padding:15px 18px; border-top:1px solid #e8edf0; }
|
||||
.review-exam-card dl > div:nth-child(-n+2) { border-top:0; }
|
||||
.review-exam-card dt { color:#7d8997; font-size:13px; }
|
||||
.review-exam-card dd { margin:6px 0 0; color:#33465a; font-size:14px; line-height:1.6; }
|
||||
.review-subject-list { display:flex; flex-wrap:wrap; gap:8px; }
|
||||
.review-subject-list > span { display:grid; gap:3px; padding:9px 11px; border:1px solid #d9e3e7; border-radius:8px; background:#fff; }
|
||||
.review-subject-list small { color:#738395; font-size:12px; }
|
||||
.review-empty-context { padding:25px; border:1px dashed #cdd8df; border-radius:10px; color:#67788b; background:#f7fafb; text-align:center; }
|
||||
.review-empty-context strong { color:#33485e; font-size:16px; }
|
||||
.review-empty-context p { margin:7px 0 0; font-size:14px; }
|
||||
.review-decision-form,.review-readonly { position:sticky; top:24px; align-self:start; display:grid; gap:18px; padding:24px; border-top:5px solid #287486; border-radius:12px; background:#fff; box-shadow:0 14px 38px rgba(26,52,76,.12); }
|
||||
.review-decision-form > div { display:grid; gap:5px; padding-bottom:18px; border-bottom:1px solid #e1e7eb; }
|
||||
.review-decision-form > div span,.review-decision-form label > span { color:#708093; font-size:13px; font-weight:700; }
|
||||
.review-decision-form > div strong { color:#1f3853; font-size:18px; }
|
||||
.review-decision-form > div small { color:#7e8a99; font-size:13px; }
|
||||
.review-decision-form label { display:grid; gap:7px; }
|
||||
.review-decision-form select,.review-decision-form textarea { width:100%; padding:11px 12px; border:1px solid #cbd6df; border-radius:8px; color:#293b50; background:#fff; font-size:14px; line-height:1.6; }
|
||||
.review-decision-form select:focus,.review-decision-form textarea:focus { border-color:#287486; outline:3px solid rgba(40,116,134,.12); }
|
||||
.review-decision-form .solid-button { min-height:46px; font-size:14px; }
|
||||
.review-readonly strong { color:#233c56; font-size:18px; }.review-readonly p { margin:0; color:#697b8e; font-size:14px; line-height:1.7; }
|
||||
.exam-context-hero { border-top:5px solid #245783; }
|
||||
.subject-review-section > footer { display:flex; align-items:baseline; justify-content:flex-end; gap:14px; padding:18px 24px; border-top:1px solid #e1e7eb; background:#f8fafb; }
|
||||
.subject-review-section > footer span { color:#6d7e91; font-size:14px; }.subject-review-section > footer strong { color:#17375f; font-size:24px; }
|
||||
.review-subject-cards { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:13px; padding:20px 22px; }
|
||||
.review-subject-cards > article { display:grid; grid-template-columns:34px minmax(0,1fr) auto; align-items:center; gap:12px; padding:16px; border:1px solid #dbe4ea; border-radius:10px; background:#fff; }
|
||||
.review-subject-cards > article > i { width:32px; height:32px; display:grid; place-items:center; border-radius:50%; color:#fff; background:#287486; font-size:13px; font-style:normal; }
|
||||
.review-subject-cards > article > div { display:grid; gap:4px; min-width:0; }.review-subject-cards > article > div strong { font-size:16px; }.review-subject-cards > article > div span { color:#718194; font-size:13px; }
|
||||
.review-subject-cards dl { display:flex; gap:16px; margin:0; }.review-subject-cards dl div { display:grid; gap:4px; }.review-subject-cards dt { color:#8290a0; font-size:12px; }.review-subject-cards dd { margin:0; font-size:14px; font-weight:700; }
|
||||
|
||||
/* 高频台账筛选与批量操作 */
|
||||
.candidate-toolbar { flex-wrap:wrap; }.table-filter-selects.five-columns { grid-template-columns:repeat(5,minmax(120px,1fr)); }
|
||||
.candidate-exam-summary { min-width:190px; }.candidate-exam-summary strong,.candidate-exam-summary small { display:block; }.candidate-exam-summary span { color:#8793a2; }
|
||||
.registration-exam-cell { min-width:220px; }.subject-summary { display:flex; flex-wrap:wrap; gap:5px; margin:7px 0 4px; }.subject-summary span { padding:4px 7px; border-radius:5px; color:#36576e; background:#e9f1f4; font-size:12px; font-weight:700; }.subject-summary em { color:#a14c43; font-style:normal; }
|
||||
.qualification-filter-toolbar { flex-wrap:wrap; margin-top:14px; border-top:1px solid #e4e9ed; }.qualification-specialty-filter { flex:1 0 100%; display:flex; gap:9px; }.qualification-specialty-filter select { flex:1; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }.qualification-specialty-filter .row-action { padding:0 12px; border:1px solid #d6dfe5; border-radius:7px; background:#fff; }
|
||||
.workflow-filter-toolbar { flex-wrap:wrap; margin-bottom:16px; }.workflow-type-filter { flex:1 0 100%; }.workflow-type-filter select { width:100%; min-height:40px; padding:8px 11px; border:1px solid #ccd7df; border-radius:7px; color:#45576b; background:#fff; font-size:14px; }
|
||||
|
||||
/* 全站可读性下限:正文 14px,辅助信息不低于 12px */
|
||||
.admin-readable .portal-sidebar nav button { font-size:14px; }.admin-readable .portal-sidebar nav button em { font-size:12px; }
|
||||
.admin-readable .portal-topbar > div:first-of-type,.admin-readable .portal-user > span strong { font-size:14px; }
|
||||
.admin-readable .portal-user > span small,.admin-readable .sidebar-help span,.admin-readable .sidebar-help small { font-size:12px; }
|
||||
.admin-readable .portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
|
||||
.admin-readable .portal-content small { font-size:12px !important; line-height:1.55; }
|
||||
.admin-readable .portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }
|
||||
.admin-readable .portal-content :where(td strong,button strong,p strong) { font-size:14px !important; }
|
||||
.admin-readable .portal-content .status,.admin-readable .portal-content .overline,.admin-readable .portal-content .exam-code { font-size:12px !important; }
|
||||
.admin-readable .portal-content :where(.solid-button,.ghost-button,.row-action) { min-height:38px; }
|
||||
.portal-content :where(p,time,dt,dd,label > span,th,td,button,input,select,textarea,em) { font-size:14px !important; line-height:1.55; }
|
||||
.portal-content small { font-size:12px !important; line-height:1.55; }.portal-content span:not(:has(svg)):not(.status):not(.user-avatar) { font-size:14px !important; }.portal-content .status,.portal-content .overline,.portal-content .exam-code { font-size:12px !important; }
|
||||
.portal-sidebar nav button { font-size:14px; }.portal-sidebar nav button em,.portal-user small,.sidebar-help small { font-size:12px; }.portal-topbar button { font-size:13px; }
|
||||
.auth-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.auth-page span:not(:has(svg)) { font-size:14px; }.auth-page small { font-size:12px; }
|
||||
.onboarding-page :where(p,label > span,button,input,select,textarea) { font-size:14px; line-height:1.65; }.onboarding-page span:not(:has(svg)) { font-size:14px; }.onboarding-page small { font-size:12px; }
|
||||
.modal-card :where(p,dt,dd,label > span,button,input,select,textarea,li) { font-size:14px; line-height:1.65; }.modal-card span:not(:has(svg)) { font-size:14px; }.modal-card small,.modal-head span { font-size:12px; }
|
||||
.public-footer p,.public-footer > span { font-size:13px; line-height:1.65; }
|
||||
.notice-document table th,.notice-document table td { padding:15px 17px; font-size:15px; line-height:1.55; }
|
||||
.notice-document .document-lead { font-size:15px; }.notice-document .qualification-result { min-width:46px; padding:6px 11px; font-size:14px; }
|
||||
.public-main :where(button,input,select,textarea) { font-size:14px; }.public-main :where(p,dt,dd) { font-size:14px; line-height:1.7; }.public-main span:not(:has(svg)):not(.status) { font-size:14px; }.public-main small { font-size:12px; line-height:1.55; }
|
||||
|
||||
@media (max-width:1000px) {
|
||||
.review-subpage { background:#f4f7f9; }.review-subpage-header { grid-template-columns:1fr auto; min-height:0; }.review-subpage-header > div { grid-column:1/-1; grid-row:2; }.review-subpage-header > .status { grid-column:2; grid-row:1; }.review-subpage-layout { grid-template-columns:1fr; }.review-decision-form,.review-readonly { position:static; }.table-filter-selects.five-columns { grid-template-columns:repeat(2,minmax(130px,1fr)); }
|
||||
}
|
||||
@media (max-width:650px) {
|
||||
.review-subpage-header { padding:24px 20px; }.review-subpage-layout { padding:20px 14px 45px; }.review-detail-grid,.review-exam-card dl { grid-template-columns:1fr; }.review-detail-grid > div.wide { grid-column:auto; }.review-exam-card dl > div:nth-child(2) { border-top:1px solid #e8edf0; }.review-subject-cards { grid-template-columns:1fr; padding:14px; }.review-subject-cards > article { grid-template-columns:32px minmax(0,1fr); }.review-subject-cards dl { grid-column:2; }.qualification-specialty-filter { flex-direction:column; }.table-filter-selects.five-columns { grid-template-columns:1fr; }
|
||||
}
|
||||
@media (max-width:1000px) {
|
||||
.result-workbench-head,.feature-score-workbench > form > header { grid-template-columns:1fr; }.result-workbench-summary { grid-template-columns:repeat(4,1fr); }.result-workbench-summary p { grid-column:1/-1; justify-self:start; padding:8px 0 0; }.feature-score-rule { min-width:0; }
|
||||
}
|
||||
@media (max-width:650px) {
|
||||
.result-workbench-selectors,.result-workbench-summary { grid-template-columns:1fr; }.result-workbench-summary > span { padding:6px 0; border-right:0; border-bottom:1px solid #e2e8f0; }.result-workbench-actions { align-items:stretch; flex-direction:column; }.result-workbench-actions > div { margin:0 0 5px; }.result-workbench-actions button { width:100%; }
|
||||
}
|
||||
@media (max-width:1100px) {
|
||||
.admission-account-management > form { grid-template-columns:1fr 1fr; }.admission-account-management > form > .solid-button { width:100%; }
|
||||
}
|
||||
@media (max-width:800px) {
|
||||
.admission-account-management > form { grid-template-columns:1fr; }.placement-bulk-toolbar,.placement-bulk-toolbar > div { align-items:stretch; flex-direction:column; }.placement-bulk-toolbar button { width:100%; }.placement-status-pills,.account-status-pills { margin-right:18px; margin-left:18px; }
|
||||
}
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border-top: 1px solid var(--line, #e5e7eb);
|
||||
color: var(--muted, #64748b);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.table-pagination > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.table-pagination button,
|
||||
.table-pagination select {
|
||||
min-width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line, #dbe2ea);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.table-pagination button:not(:disabled) { cursor: pointer; }
|
||||
.table-pagination button:hover:not(:disabled) { border-color: #1f6f5f; color: #1f6f5f; }
|
||||
.table-pagination button.active { border-color: #1f6f5f; background: #1f6f5f; color: #fff; }
|
||||
.table-pagination button:disabled { opacity: .45; }
|
||||
.table-pagination label { display: inline-flex; align-items: center; gap: 6px; margin-left: 6px; }
|
||||
.table-pagination i { font-style: normal; padding: 0 2px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.table-pagination { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
/* 业务域分组导航 */
|
||||
.portal-sidebar { width: 264px; overflow-x: hidden; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #42547f transparent; }
|
||||
.portal-main { margin-left: 264px; }
|
||||
.portal-nav-groups { display: grid; gap: 15px !important; padding: 3px 0 18px; }
|
||||
.portal-nav-group { display: grid; gap: 4px; }
|
||||
.portal-nav-group > strong { padding: 0 13px 4px; color: #7181aa; font-size: 10px; font-weight: 700; letter-spacing: .12em; }
|
||||
.portal-nav-group button { min-height: 38px !important; }
|
||||
.sidebar-help { flex: 0 0 auto; }
|
||||
|
||||
/* 成绩卡:排名信息回归文档流,永不覆盖科目标题 */
|
||||
.score-grid { grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
|
||||
.score-grid article { min-width: 0; place-content: initial; align-content: start; justify-items: stretch; min-height: 230px; border: 1px solid var(--line); border-width: 0 1px 1px 0; }
|
||||
.score-subject-head { width: 100%; min-height: 31px; }
|
||||
.score-subject-head > span { overflow: hidden; color: var(--navy); font-size: 14px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.score-grid article > strong { margin: 22px 0 8px; font-size: 40px; }
|
||||
.score-grid article > em { position: static; display: block; padding: 0; color: #526079; background: transparent; font-size: 12px; font-style: normal; line-height: 1.55; }
|
||||
.rank-rule-line { align-items: flex-start; flex-direction: column; gap: 5px; margin-top: auto; }
|
||||
.rank-rule-line span { font-size: 11px; }
|
||||
.rank-rule-line b { font-size: 12px; line-height: 1.45; }
|
||||
.result-footer-actions { display: flex; align-items: center; gap: 14px; }
|
||||
.result-footer-actions .solid-button { min-height: 34px; padding: 0 14px; font-size: 11px; }
|
||||
|
||||
/* 通知书模板工作室 */
|
||||
.notice-template-studio { display: grid; grid-template-columns: minmax(380px, .82fr) minmax(420px, 1.18fr); gap: 22px; align-items: start; }
|
||||
.notice-template-form { padding: 24px; }
|
||||
.notice-template-form .panel-title { margin-bottom: 20px; }
|
||||
.notice-template-form > label { display: grid; gap: 7px; margin-bottom: 16px; }
|
||||
.notice-template-form textarea { resize: vertical; }
|
||||
.template-color-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 18px 0; }
|
||||
.template-color-row label { display: flex; align-items: center; justify-content: space-between; padding: 12px; border: 1px solid var(--line); border-radius: 9px; }
|
||||
.template-color-row input { width: 64px; height: 34px; padding: 2px; }
|
||||
.notice-template-preview { position: sticky; top: 20px; }
|
||||
.template-frame { position: relative; min-height: 720px; padding: 66px 64px; border: 8px solid var(--template-primary); outline: 2px solid var(--template-accent); outline-offset: -18px; color: #332f2c; background: #fffdf8; box-shadow: var(--shadow); }
|
||||
.template-frame > small { display: block; color: var(--template-accent); font-family: Consolas, monospace; letter-spacing: .18em; text-align: center; }
|
||||
.template-frame h2 { margin: 26px 0 12px; color: var(--template-primary); font-family: "STKaiti", serif; font-size: 45px; font-weight: 700; letter-spacing: .16em; text-align: center; }
|
||||
.template-frame h3 { margin: 0 0 70px; text-align: center; }
|
||||
.template-frame > strong { font-size: 18px; }
|
||||
.template-frame > p { min-height: 250px; margin: 24px 0; font-size: 16px; line-height: 2.2; white-space: pre-wrap; }
|
||||
.template-frame footer { display: grid; justify-items: end; gap: 12px; margin-top: 40px; }
|
||||
.template-frame footer span { justify-self: stretch; color: #786f65; }
|
||||
.notice-template-preview > p { color: var(--muted); font-size: 11px; line-height: 1.7; }
|
||||
|
||||
/* 公开防伪查询 */
|
||||
.verification-page { min-height: calc(100vh - 76px); padding: 72px max(24px, calc((100% - 1060px) / 2)); background: #f4f7fa; }
|
||||
.verification-hero { display: grid; grid-template-columns: 1fr 470px; gap: 70px; align-items: end; padding: 48px; border-radius: 18px; color: #fff; background: var(--navy); box-shadow: 0 26px 70px rgba(19,36,81,.2); }
|
||||
.verification-hero h1 { margin: 10px 0; font-family: "STKaiti", serif; font-size: 44px; font-weight: 400; }
|
||||
.verification-hero p { color: #abb7d3; }
|
||||
.verification-hero form { display: flex; align-items: end; gap: 10px; }
|
||||
.verification-hero label { display: grid; flex: 1; gap: 8px; color: #bdc8df; font-size: 12px; }
|
||||
.verification-hero input { height: 46px; border-color: rgba(255,255,255,.18); color: #fff; background: rgba(255,255,255,.08); font-family: Consolas, monospace; }
|
||||
.verification-result { display: grid; grid-template-columns: 64px 1fr; gap: 22px; margin-top: 24px; padding: 32px; border: 1px solid #cfe1db; border-radius: 15px; background: #fff; }
|
||||
.verification-result > span { width: 56px; height: 56px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: #28735e; font-size: 27px; }
|
||||
.verification-result h2 { margin: 5px 0; }
|
||||
.verification-result p { color: var(--muted); }
|
||||
.verification-result dl { grid-column: 1/-1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 4px 0 0; }
|
||||
.verification-result dl div { padding: 14px; border-radius: 8px; background: #f3f6f8; }
|
||||
.verification-result dt { color: var(--muted); font-size: 10px; }
|
||||
.verification-result dd { margin: 5px 0 0; font-weight: 700; }
|
||||
.verification-result.invalid { border-color: #efcfcb; }
|
||||
.verification-result.invalid > span { background: #b94b44; }
|
||||
.verification-notice { margin-top: 20px; padding: 20px 24px; border-left: 3px solid #8792a8; background: #fff; }
|
||||
.verification-notice p { margin: 6px 0 0; color: var(--muted); }
|
||||
.admission-result-banner .solid-button { justify-self: start; margin-top: 10px; }
|
||||
|
||||
/* 招生计划完成率与考生报到工作台 */
|
||||
.admission-progress-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:14px; margin:0 0 22px; }
|
||||
.admission-progress-grid article { padding:20px; border:1px solid #d8e4e2; border-radius:12px; background:#fff; box-shadow:0 10px 28px rgba(20,35,75,.06); }
|
||||
.admission-progress-grid header { display:flex; align-items:flex-start; justify-content:space-between; gap:18px; }
|
||||
.admission-progress-grid header span { color:#53627a; font-size:12px; }
|
||||
.admission-progress-grid header strong { color:#1f6f5f; font-size:27px; }
|
||||
.progress-meter { height:8px; margin:14px 0 12px; overflow:hidden; border-radius:99px; background:#e5eceb; }
|
||||
.progress-meter i { display:block; height:100%; border-radius:inherit; background:linear-gradient(90deg,#1f6f5f,#67a58f); }
|
||||
.admission-progress-grid p { margin:0; color:#273650; }
|
||||
.admission-progress-grid small { color:#7d8798; }
|
||||
.reporting-workbench { margin-bottom:24px; border:1px solid #d9e2e8; border-radius:15px; overflow:hidden; background:#fff; box-shadow:0 16px 40px rgba(20,35,75,.07); }
|
||||
.reporting-workbench > header { display:flex; align-items:flex-end; justify-content:space-between; gap:28px; padding:26px 28px 22px; color:#fff; background:linear-gradient(115deg,#172d55,#214f65); }
|
||||
.reporting-workbench > header > div:first-child { min-width:0; }
|
||||
.reporting-workbench > header span { color:#9fb5c9; font:11px Consolas,monospace; letter-spacing:.08em; }
|
||||
.reporting-workbench > header h2 { margin:7px 0 5px; font-size:25px; }
|
||||
.reporting-workbench > header p { margin:0; color:#c4d2df; }
|
||||
.reporting-rate { flex:0 0 auto; text-align:right; }
|
||||
.reporting-rate strong { display:block; color:#f1d28a; font-size:42px; line-height:1; }
|
||||
.reporting-rate span { color:#bdcbd7 !important; font-family:inherit !important; letter-spacing:0 !important; }
|
||||
.reporting-stat-strip { display:flex; align-items:center; gap:24px; padding:15px 28px; border-bottom:1px solid #e3e9ed; background:#f7f9fa; }
|
||||
.reporting-stat-strip span { color:#667085; }
|
||||
.reporting-stat-strip b { margin-left:5px; color:#172d55; font-size:17px; }
|
||||
.reporting-stat-strip em { margin-left:auto; padding:5px 10px; border-radius:99px; color:#1f6f5f; background:#dfeee9; font-style:normal; font-weight:700; }
|
||||
.reporting-tools { display:grid; grid-template-columns:1fr 1fr; gap:14px; align-items:stretch; margin:20px 24px 0; }
|
||||
.reporting-excel-tool,.reporting-scan-tool { display:grid; grid-template-columns:1fr auto; gap:12px; align-items:center; padding:17px; border:1px solid #ead9a5; border-radius:10px; background:#fffaf0; }
|
||||
.reporting-scan-tool { border-color:#bcd8d0; background:#f3faf7; }
|
||||
.reporting-tools > div > div:first-child { display:grid; gap:3px; }
|
||||
.reporting-tools small { color:#7d735e; }
|
||||
.reporting-tools form { grid-column:1/-1; display:flex; gap:8px; }
|
||||
.reporting-tools form input { min-width:220px; }
|
||||
.tool-buttons { display:flex; gap:8px; }
|
||||
.reporting-import-summary { grid-column:1/-1; display:grid; grid-template-columns:1fr auto; gap:4px 14px; padding:10px 12px; border-left:3px solid #1f6f5f; border-radius:6px; color:#315d52; background:#e7f3ee; }
|
||||
.reporting-import-summary.unchanged { border-left-color:#9a7a38; color:#6e5b33; background:#f8efd9; }
|
||||
.reporting-import-summary small { grid-column:1/-1; }
|
||||
.camera-button { min-height:42px; display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:0 16px; border:0; border-radius:8px; color:#fff; background:#1f6f5f; font-weight:700; cursor:pointer; box-shadow:0 8px 18px rgba(31,111,95,.2); }
|
||||
.qr-capture { min-height:38px; display:inline-flex; align-items:center; justify-content:center; padding:0 13px; border:1px dashed #1f6f5f; border-radius:8px; color:#1f6f5f; cursor:pointer; }
|
||||
.reporting-workbench > form,.reporting-ledger-readonly { padding:20px 24px 24px; }
|
||||
.reporting-workbench table select,.reporting-workbench table input { min-width:150px; }
|
||||
.reporting-workbench table input { width:100%; }
|
||||
.reporting-workbench table .selection-cell { width:56px; min-width:56px; text-align:center; }
|
||||
.reporting-workbench table .selection-cell input { width:17px; min-width:17px; height:17px; }
|
||||
.reporting-bulk-bar { display:grid; grid-template-columns:auto auto minmax(180px,.7fr) minmax(260px,1fr) auto; gap:12px; align-items:end; margin:14px 0; padding:14px; border:1px solid #d5e1e5; border-radius:9px; background:#f7f9fb; }
|
||||
.reporting-bulk-bar label:not(.bulk-check) { display:grid; gap:5px; }
|
||||
.reporting-bulk-bar label span { color:#667085; font-size:11px; }
|
||||
.reporting-bulk-bar .bulk-check { display:flex; align-items:center; gap:7px; min-height:40px; }
|
||||
.reporting-bulk-bar .bulk-check input { width:17px; height:17px; }
|
||||
.reporting-bulk-bar > strong { min-height:40px; display:flex; align-items:center; color:#1f6f5f; white-space:nowrap; }
|
||||
.reporting-actions { display:flex; justify-content:flex-end; gap:10px; padding-top:18px; }
|
||||
.reporting-decision { display:grid; grid-template-columns:1.2fr .7fr 1fr auto; gap:14px; align-items:end; margin:0; padding:22px 24px; border-top:1px solid #e3e9ed; background:#f7faf9; }
|
||||
.reporting-decision label { display:grid; gap:6px; }
|
||||
.reporting-decision p { margin:4px 0 0; color:#6b7688; }
|
||||
.reporting-readonly-note { padding:20px 24px; border-top:1px solid #e3e9ed; background:#f7f9fb; }
|
||||
.reporting-readonly-note p { margin:5px 0 0; color:#6f7a8d; }
|
||||
.reporting-camera-head,.reporting-confirm-head { border-bottom:1px solid #dce5e8; }
|
||||
.reporting-camera-stage { position:relative; margin:22px 24px 10px; overflow:hidden; aspect-ratio:16/10; border-radius:13px; background:#0c1725; }
|
||||
.reporting-camera-stage video { width:100%; height:100%; display:block; object-fit:cover; }
|
||||
.scan-frame { position:absolute; inset:15% 25%; display:grid; place-items:end center; padding:14px; color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.48)); }
|
||||
.scan-frame i { position:absolute; width:36px; height:36px; border-color:#f1d28a; border-style:solid; }
|
||||
.scan-frame i:nth-child(1) { top:0; left:0; border-width:3px 0 0 3px; }
|
||||
.scan-frame i:nth-child(2) { top:0; right:0; border-width:3px 3px 0 0; }
|
||||
.scan-frame i:nth-child(3) { bottom:0; left:0; border-width:0 0 3px 3px; }
|
||||
.scan-frame i:nth-child(4) { right:0; bottom:0; border-width:0 3px 3px 0; }
|
||||
.scan-frame span { font-size:12px; }
|
||||
.reporting-camera-status { margin:0 24px 14px; color:#315d52; }
|
||||
.reporting-camera-status.error { color:#a0473f; }
|
||||
.reporting-camera-fallback { display:grid; grid-template-columns:1fr auto; gap:10px; margin:0 24px 24px; padding-top:14px; border-top:1px solid #e1e7ea; }
|
||||
.reporting-camera-fallback form { display:flex; gap:8px; }
|
||||
.reporting-camera-fallback form input { flex:1; }
|
||||
.reporting-confirm-form { padding:22px 24px 24px; }
|
||||
.reporting-candidate-card { position:relative; padding:20px; border:1px solid #cfe1db; border-radius:11px; background:#f4faf7; }
|
||||
.reporting-candidate-card dl { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin:0; padding-right:90px; }
|
||||
.reporting-candidate-card dt { color:#748078; font-size:11px; }
|
||||
.reporting-candidate-card dd { margin:4px 0 0; color:#173f3a; font-weight:700; }
|
||||
.candidate-stamp { position:absolute; top:20px; right:18px; padding:7px 10px; border:2px solid #28735e; border-radius:5px; color:#28735e; font-weight:800; transform:rotate(-4deg); }
|
||||
.reporting-decision-options { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; margin:20px 0; padding:0; border:0; }
|
||||
.reporting-decision-options legend { margin-bottom:9px; color:#29364b; font-weight:700; }
|
||||
.reporting-decision-options label { display:flex; gap:10px; padding:14px; border:1px solid #d7e0e4; border-radius:9px; cursor:pointer; }
|
||||
.reporting-decision-options label.selected { border-color:#1f6f5f; background:#edf7f3; box-shadow:inset 0 0 0 1px #1f6f5f; }
|
||||
.reporting-decision-options input { margin-top:3px; }
|
||||
.reporting-decision-options strong,.reporting-decision-options small { display:block; }
|
||||
.reporting-decision-options small { margin-top:4px; color:#788393; }
|
||||
.reporting-confirm-note { display:grid; gap:7px; }
|
||||
.workflow-hint { display:block; margin-top:12px; color:#7a8495; line-height:1.6; }
|
||||
.template-notice-number { margin:36px 0 28px; color:#6d655b; font:12px Consolas,monospace; text-align:right; }
|
||||
.template-qr-placeholder { position:absolute; right:52px; bottom:48px; width:88px; height:88px; display:grid; place-items:center; border:1px dashed var(--template-primary); color:var(--template-primary); font-size:10px; text-align:center; }
|
||||
.reporting-public-stats { display:grid; grid-template-columns:repeat(4,1fr); gap:12px; margin:22px 0; }
|
||||
.reporting-public-stats article { padding:18px; border:1px solid #dce5e8; border-radius:9px; background:#f7faf9; }
|
||||
.reporting-public-stats span,.reporting-public-stats small { display:block; color:#758093; }
|
||||
.reporting-public-stats strong { display:inline-block; margin:8px 4px 2px 0; color:#173f60; font-size:28px; }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.notice-template-studio,.verification-hero { grid-template-columns: 1fr; }
|
||||
.notice-template-preview { position: static; }
|
||||
.reporting-tools { grid-template-columns:1fr; }
|
||||
.reporting-bulk-bar { grid-template-columns:auto auto 1fr; }
|
||||
.reporting-bulk-bar .bulk-note { grid-column:1/3; }
|
||||
.reporting-decision { grid-template-columns:1fr 1fr; }
|
||||
}
|
||||
@media (max-width: 850px) {
|
||||
.portal-sidebar { width: 264px; }
|
||||
.portal-main { margin-left: 0; }
|
||||
}
|
||||
@media (max-width: 620px) {
|
||||
.score-grid { grid-template-columns: 1fr; }
|
||||
.result-footer-actions { width: 100%; align-items: stretch; flex-direction: column; }
|
||||
.verification-page { padding: 28px 14px; }
|
||||
.verification-hero { padding: 26px 20px; }
|
||||
.verification-hero form { align-items: stretch; flex-direction: column; }
|
||||
.verification-result dl { grid-template-columns: 1fr; }
|
||||
.template-frame { min-height: 620px; padding: 48px 34px; }
|
||||
.reporting-workbench > header { align-items:flex-start; flex-direction:column; }
|
||||
.reporting-rate { text-align:left; }
|
||||
.reporting-stat-strip { align-items:flex-start; flex-direction:column; gap:8px; }
|
||||
.reporting-stat-strip em { margin-left:0; }
|
||||
.reporting-tools,.reporting-decision,.reporting-public-stats { grid-template-columns:1fr; }
|
||||
.reporting-tools form { grid-template-columns:1fr; }
|
||||
.reporting-excel-tool,.reporting-scan-tool,.reporting-camera-fallback,.reporting-bulk-bar,.reporting-decision-options { grid-template-columns:1fr; }
|
||||
.tool-buttons { align-items:stretch; flex-direction:column; }
|
||||
.reporting-bulk-bar .bulk-note { grid-column:auto; }
|
||||
.reporting-camera-fallback form { display:grid; grid-template-columns:1fr; }
|
||||
.reporting-candidate-card dl { grid-template-columns:1fr; padding-right:0; }
|
||||
.candidate-stamp { position:static; display:inline-block; margin-bottom:16px; }
|
||||
.reporting-actions { align-items:stretch; flex-direction:column; }
|
||||
}
|
||||
|
||||
.admission-snapshot-console { overflow:hidden; padding:0; }
|
||||
.admission-snapshot-console > .panel-title { padding:22px 24px 18px; border-bottom:1px solid #dfe7eb; background:linear-gradient(110deg,#f4f8fa,#eef6f3); }
|
||||
.snapshot-ledger-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px; padding:18px 24px 24px; }
|
||||
.snapshot-ledger-grid article { display:grid; grid-template-columns:minmax(180px,1fr) auto; gap:16px 22px; align-items:center; padding:18px; border:1px solid #dce5e8; border-radius:11px; background:#fff; box-shadow:0 6px 18px rgba(28,55,73,.04); }
|
||||
.snapshot-ledger-grid article > div > span { color:#2a7280; font:700 11px Consolas,monospace; letter-spacing:.08em; }
|
||||
.snapshot-ledger-grid h3 { margin:5px 0 4px; color:#18334d; font-size:17px; }
|
||||
.snapshot-ledger-grid p { margin:0; color:#758192; font-size:12px; }
|
||||
.snapshot-ledger-grid dl { display:grid; grid-template-columns:repeat(4,minmax(58px,1fr)); grid-column:1/-1; gap:8px; margin:0; }
|
||||
.snapshot-ledger-grid dl div { padding:10px 11px; border-radius:8px; background:#f4f7f8; }
|
||||
.snapshot-ledger-grid dt { color:#788492; font-size:11px; }
|
||||
.snapshot-ledger-grid dd { margin:4px 0 0; color:#173e4a; font-size:20px; font-weight:800; }
|
||||
.snapshot-ledger-grid article > button { grid-column:2; grid-row:1; }
|
||||
.admission-ledger-toolbar { align-items:center; flex-wrap:wrap; }
|
||||
.admission-ledger-toolbar .search-box,.placement-supervision-toolbar .search-box { flex:1 1 320px; }
|
||||
.admission-ledger-toolbar .filter-pills,.placement-supervision-toolbar .filter-pills { flex:1 1 100%; }
|
||||
.ledger-toolbar-actions { display:flex; gap:8px; margin-left:auto; }
|
||||
.preference-empty { display:block; padding:13px; border:1px dashed #cdd8de; border-radius:8px; color:#7b8794; font-style:normal; text-align:center; background:#fafcfc; }
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.snapshot-ledger-grid { grid-template-columns:1fr; padding:14px; }
|
||||
.snapshot-ledger-grid article { grid-template-columns:1fr; }
|
||||
.snapshot-ledger-grid article > button { grid-column:auto; grid-row:auto; }
|
||||
.snapshot-ledger-grid dl { grid-template-columns:repeat(2,1fr); }
|
||||
.ledger-toolbar-actions { width:100%; margin-left:0; }
|
||||
.ledger-toolbar-actions button { flex:1; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus, supplementarySchoolIds } from '../src/services/volunteer-admission.mjs';
|
||||
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
|
||||
import { systemNotificationItems } from '../src/services/system-notifications.mjs';
|
||||
|
||||
const now = '2026-07-21T08:00:00.000Z';
|
||||
let sequence = 0;
|
||||
const db = {
|
||||
users: [
|
||||
{ id: 'u-high', role: 'candidate', active: true, candidateNumber: '20260001', displayName: '高分考生' },
|
||||
{ id: 'u-low', role: 'candidate', active: true, candidateNumber: '20260002', displayName: '次高考生' },
|
||||
{ id: 'u-sport', role: 'candidate', active: true, candidateNumber: '20260003', displayName: '特长考生' }
|
||||
],
|
||||
candidateProfiles: [
|
||||
{ userId: 'u-high', name: '高分考生', schoolId: 'source-a', profileCompleted: true, idNumber: '320101200901011234', phone: '13812345678', specialtyTypes: [] },
|
||||
{ userId: 'u-low', name: '次高考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200902021234', phone: '13912345678', specialtyTypes: [] },
|
||||
{ userId: 'u-sport', name: '特长考生', schoolId: 'source-b', profileCompleted: true, idNumber: '320101200903031234', phone: '13712345678', specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] }
|
||||
],
|
||||
schools: [
|
||||
{ id: 'source-a', name: '生源学校 A' }, { id: 'source-b', name: '生源学校 B' },
|
||||
{ id: 'target-a', name: '第一中学' }, { id: 'target-b', name: '第二中学' }
|
||||
],
|
||||
registrations: [
|
||||
{ id: 'r-high', examId: 'exam', userId: 'u-high', status: 'approved', subjectIds: ['cn', 'math'] },
|
||||
{ id: 'r-low', examId: 'exam', userId: 'u-low', status: 'approved', subjectIds: ['cn', 'math'] },
|
||||
{ id: 'r-sport', examId: 'exam', userId: 'u-sport', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 88.5 }
|
||||
],
|
||||
results: [
|
||||
{ registrationId: 'r-high', subjectId: 'cn', score: 120, published: true }, { registrationId: 'r-high', subjectId: 'math', score: 130, published: true },
|
||||
{ registrationId: 'r-low', subjectId: 'cn', score: 118, published: true }, { registrationId: 'r-low', subjectId: 'math', score: 126, published: true },
|
||||
{ registrationId: 'r-sport', subjectId: 'cn', score: 105, published: true }, { registrationId: 'r-sport', subjectId: 'math', score: 110, published: true }
|
||||
],
|
||||
admissionRecords: [
|
||||
{ id: 'plan-a', kind: 'plan', examId: 'exam', schoolId: 'target-a', status: 'approved', payload: { categories: [{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] }] } },
|
||||
{ id: 'plan-b', kind: 'plan', examId: 'exam', schoolId: 'target-b', status: 'approved', payload: { categories: [
|
||||
{ code: 'general', name: '普通生', quota: 1, specialtyType: '', indicatorAllocations: [] },
|
||||
{ code: 'sport', name: '田径特长生', quota: 1, specialtyCategory: 'sports', specialtyType: 'track_field', indicatorAllocations: [{ sourceSchoolId: 'source-b', quota: 1 }] }
|
||||
] } },
|
||||
{ id: 'qual-low', kind: 'indicator_qualification', examId: 'exam', userId: 'u-low', schoolId: 'source-b', status: 'confirmed', payload: { eligible: false } },
|
||||
{ id: 'qual-sport', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
|
||||
{ id: 'pref-high', kind: 'preference', examId: 'exam', userId: 'u-high', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
|
||||
{ id: 'pref-low', kind: 'preference', examId: 'exam', userId: 'u-low', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'general', preferenceType: 'general' }, { schoolId: 'target-a', categoryCode: 'general', preferenceType: 'general' }] } },
|
||||
{ id: 'pref-sport', kind: 'preference', examId: 'exam', userId: 'u-sport', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
|
||||
]
|
||||
};
|
||||
|
||||
const setting = { examId: 'exam', payload: { round: 1 } };
|
||||
const placements = buildVolunteerPlacements(db, setting, { uid: prefix => `${prefix}-${++sequence}`, nowIso: () => now });
|
||||
assert.equal(candidateTotalScore(db, 'exam', 'u-high'), 250, '投档总分应取当次全部已发布科目之和');
|
||||
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { code: 'general' }), 215, '普通招生类别不得加入特征分');
|
||||
assert.equal(candidateAdmissionScore(db, 'exam', 'u-sport', { specialtyCategory: 'sports', specialtyType: 'track_field' }), 303.5, '特长生招生类别应使用文化课总分加特征分');
|
||||
assert.equal(placements.length, 3, '三个符合条件且计划充足的考生都应投档');
|
||||
assert.equal(placements.find(item => item.userId === 'u-high').schoolId, 'target-b', '最高分考生应优先满足第一志愿');
|
||||
assert.equal(placements.find(item => item.userId === 'u-low').schoolId, 'target-a', '第一志愿已满时应继续遵循下一志愿');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.quotaBucket, 'indicator:source-b', '特长生指标应使用对应生源学校指标名额');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.featureScore, 88.5, '特征分应随投档材料发送');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.culturalScore, 215, '特长生投档材料应保留文化课原始总分');
|
||||
assert.equal(placements.find(item => item.userId === 'u-sport').payload.totalScore, 303.5, '特长生类别投档总分应加入特征分');
|
||||
assert.equal(specialtyLabel('sports', 'track_field'), '体育·田径', '特长资格应显示大类和小类');
|
||||
assert.equal(candidateEligibleForCategory(db.candidateProfiles[2], { specialtyCategory: 'arts', specialtyType: 'fine_arts' }), false, '体育资格考生不得填报艺术类计划');
|
||||
|
||||
const specialtyPriorityDb = structuredClone(db);
|
||||
specialtyPriorityDb.users.push({ id: 'u-sport-rival', role: 'candidate', active: true, candidateNumber: '20260004', displayName: '特长竞争考生' });
|
||||
specialtyPriorityDb.candidateProfiles.push({ userId: 'u-sport-rival', name: '特长竞争考生', schoolId: 'source-b', profileCompleted: true, specialtyCategory: 'sports', specialtyType: 'track_field', specialtyTypes: ['track_field'] });
|
||||
specialtyPriorityDb.registrations.push({ id: 'r-sport-rival', examId: 'exam', userId: 'u-sport-rival', status: 'approved', subjectIds: ['cn', 'math'], featureScore: 0 });
|
||||
specialtyPriorityDb.results.push({ registrationId: 'r-sport-rival', subjectId: 'cn', score: 125, published: true }, { registrationId: 'r-sport-rival', subjectId: 'math', score: 125, published: true });
|
||||
specialtyPriorityDb.admissionRecords.push(
|
||||
{ id: 'qual-sport-rival', kind: 'indicator_qualification', examId: 'exam', userId: 'u-sport-rival', schoolId: 'source-b', status: 'confirmed', payload: { eligible: true } },
|
||||
{ id: 'pref-sport-rival', kind: 'preference', examId: 'exam', userId: 'u-sport-rival', status: 'submitted', payload: { round: 1, choices: [{ schoolId: 'target-b', categoryCode: 'sport', preferenceType: 'indicator' }] } }
|
||||
);
|
||||
const specialtyPriorityPlacements = buildVolunteerPlacements(specialtyPriorityDb, setting, { uid: prefix => `${prefix}-priority-${++sequence}`, nowIso: () => now });
|
||||
assert.equal(specialtyPriorityPlacements.find(item => item.payload.categoryCode === 'sport').userId, 'u-sport', '特长类别应按文化课加特征分排序,而不是只按文化课排序');
|
||||
|
||||
db.admissionRecords.push(...placements.map(item => ({ ...item, status: 'final' })));
|
||||
const remaining = remainingPlanQuota(db, db.admissionRecords.find(item => item.id === 'plan-b'));
|
||||
assert.equal(remaining.find(item => item.code === 'general').remaining, 0, '普通生计划占用应准确统计');
|
||||
assert.equal(remaining.find(item => item.code === 'sport').remaining, 0, '特长生计划占用应准确统计');
|
||||
|
||||
const supplementDb = structuredClone(db);
|
||||
const supplementSetting = { id: 'setting-supplement', kind: 'setting', examId: 'exam', status: 'supplementary', payload: { round: 2 } };
|
||||
supplementDb.admissionRecords.push(
|
||||
supplementSetting,
|
||||
{ id: 'report-a', kind: 'notification', examId: 'exam', schoolId: 'target-a', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } },
|
||||
{ id: 'report-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'approved', payload: { type: 'admission_reporting', round: 1, supplementDecision: 'supplement' } }
|
||||
);
|
||||
for (const placement of supplementDb.admissionRecords.filter(item => item.kind === 'placement' && item.payload?.categoryCode === 'general')) placement.status = 'forfeited';
|
||||
for (const [suffix, schoolId, score] of [['a', 'target-a', 230], ['b', 'target-b', 225]]) {
|
||||
supplementDb.users.push({ id: `u-supp-${suffix}`, role: 'candidate', active: true, candidateNumber: `2026010${suffix === 'a' ? 1 : 2}`, displayName: `补录考生${suffix.toUpperCase()}` });
|
||||
supplementDb.candidateProfiles.push({ userId: `u-supp-${suffix}`, name: `补录考生${suffix.toUpperCase()}`, schoolId: 'source-a', profileCompleted: true, specialtyTypes: [] });
|
||||
supplementDb.registrations.push({ id: `r-supp-${suffix}`, examId: 'exam', userId: `u-supp-${suffix}`, status: 'approved', subjectIds: ['cn'] });
|
||||
supplementDb.results.push({ registrationId: `r-supp-${suffix}`, subjectId: 'cn', score, published: true });
|
||||
supplementDb.admissionRecords.push({ id: `pref-supp-${suffix}`, kind: 'preference', examId: 'exam', userId: `u-supp-${suffix}`, status: 'submitted', payload: { round: 2, choices: [{ schoolId, categoryCode: 'general', preferenceType: 'general' }] } });
|
||||
}
|
||||
assert.deepEqual([...supplementarySchoolIds(supplementDb, supplementSetting)].sort(), ['target-a', 'target-b'], '同轮多所学校获批补录时应完整保留学校集合');
|
||||
const supplementPlacements = buildVolunteerPlacements(supplementDb, supplementSetting, { uid: prefix => `${prefix}-supp-${++sequence}`, nowIso: () => now });
|
||||
assert.deepEqual(supplementPlacements.map(item => item.schoolId).sort(), ['target-a', 'target-b'], '放弃考生不得继续占用缺额,两所获批学校都应进入补录投档');
|
||||
const publicRows = publicAdmissionRows(db, 'exam');
|
||||
const highPublicRow = publicRows.find(item => item.registrationNumber === '20260001');
|
||||
assert.equal(highPublicRow.registrationNumber, '20260001', '公示必须公开报名号');
|
||||
assert.equal(highPublicRow.name, '高分考生', '公示必须公开姓名');
|
||||
assert.equal(highPublicRow.totalScore, 250, '普通类别公示总成绩不得加入特征分');
|
||||
assert.equal(highPublicRow.admittedSchool, '第二中学', '公示必须公开录取学校');
|
||||
assert.ok(highPublicRow.idNumberMasked.includes('*') && !highPublicRow.idNumberMasked.includes('20090101'), '重要身份信息必须脱敏');
|
||||
assert.equal(publicRows.find(item => item.registrationNumber === '20260003').totalScore, 303.5, '特长生类别公示总成绩应包含特征分');
|
||||
const cutoffs = admissionCutoffRows(db, 'exam');
|
||||
assert.equal(cutoffs.find(item => item.schoolId === 'target-b' && item.categoryCode === 'general').cutoffScore, 250, '录取分数线应取学校招生类别最终录取最低总分');
|
||||
const qualificationStatus = sourceSchoolQualificationStatus(db, 'exam', 'source-b');
|
||||
assert.equal(qualificationStatus.complete, true, '生源校全部考生确认后应达到自动公示条件');
|
||||
assert.equal(qualificationStatus.rows.find(item => item.userId === 'u-sport').specialtyLabel, '体育·田径', '资格公示应包含对应特长类型');
|
||||
|
||||
const documentDb = structuredClone(db);
|
||||
documentDb.exams = [{ id: 'exam', code: 'EX-2026-ZK', name: '中考' }];
|
||||
documentDb.schools.find(item => item.id === 'target-b').code = 'AD02';
|
||||
const targetPlacements = documentDb.admissionRecords.filter(item => item.kind === 'placement' && item.schoolId === 'target-b');
|
||||
const numbered = assignAdmissionNoticeNumbers(documentDb, targetPlacements);
|
||||
assert.deepEqual(numbered.map(item => item.payload.noticeNumber), ['AD02-EX-2026-ZK-000001', 'AD02-EX-2026-ZK-000002'], '通知书编号应按学校与考试独立生成连续流水号');
|
||||
const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b');
|
||||
documentDb.admissionRecords = documentDb.admissionRecords.map(item => numbered.find(numberedItem => numberedItem.id === item.id) || item);
|
||||
const legacyRoundDb = structuredClone(documentDb);
|
||||
legacyRoundDb.admissionRecords.push({ id: 'setting-exam', kind: 'setting', examId: 'exam', status: 'reporting', updatedAt: now, payload: { enabled: true, autoPublish: true, round: 1, roundPublishedAt: now } });
|
||||
const legacyRoundPublication = admissionRoundPublications(legacyRoundDb).find(item => item.examId === 'exam' && item.round === 1);
|
||||
assert.ok(legacyRoundPublication?.virtual, '历史报到中数据缺少轮次公示记录时应自动兼容回显');
|
||||
assert.equal(legacyRoundPublication.rows.length, publicAdmissionRows(legacyRoundDb, 'exam', { round: 1 }).length, '历史轮次公示应恢复该轮全部正式录取名单');
|
||||
documentDb.admissionRecords.push({ id: 'reporting-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'draft', payload: { type: 'admission_reporting', round: 1, rows: [{ placementId: numbered[0].id, status: 'reported' }, { placementId: numbered[1].id, status: 'not_reported' }] } });
|
||||
const progress = admissionPlanProgress(documentDb, reportingPlan);
|
||||
assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数');
|
||||
assert.equal(progress.reportedCount, 1, '实际报到人数应来自学校报到暂存台账');
|
||||
assert.equal(progress.reportingRate, 50, '实际报到完成率应实时按计划人数计算');
|
||||
documentDb.admissionRecords.at(-1).status = 'approved';
|
||||
documentDb.admissionRecords.at(-1).payload.supplementDecision = 'no_supplement';
|
||||
documentDb.admissionRecords.at(-1).payload.decisionNote = '学校研究决定不进行补录。';
|
||||
documentDb.admissionRecords.at(-1).payload.statistics = progress;
|
||||
const reportingNotice = systemNotificationItems(documentDb).find(item => item.sourceType === 'reporting');
|
||||
assert.ok(reportingNotice.title.includes('报到情况公示') && !reportingNotice.title.includes('补录'), '计划完成或决定不补录时公告标题不应出现“补录”');
|
||||
|
||||
console.log('志愿投档、指标名额与脱敏公示测试通过');
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { api } from '../src/client/api.mjs';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
let release;
|
||||
globalThis.fetch = async () => {
|
||||
calls += 1;
|
||||
await new Promise(resolve => { release = resolve; });
|
||||
return new Response(JSON.stringify({ ok: true, calls }), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
};
|
||||
|
||||
try {
|
||||
const first = api('/api/large-ledger');
|
||||
const second = api('/api/large-ledger');
|
||||
await Promise.resolve();
|
||||
assert.equal(calls, 1, '并发的相同 GET 请求应只发送一次');
|
||||
release();
|
||||
assert.deepEqual(await first, { ok: true, calls: 1 });
|
||||
assert.deepEqual(await second, { ok: true, calls: 1 });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
console.log('API read deduplication tests passed');
|
||||
@@ -0,0 +1,209 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createAuthStateStore } from '../src/security/auth-state.mjs';
|
||||
|
||||
class FakeRedisClient extends EventEmitter {
|
||||
constructor({ connectError = null } = {}) {
|
||||
super();
|
||||
this.connectError = connectError;
|
||||
this.isOpen = false;
|
||||
this.values = new Map();
|
||||
this.sets = new Map();
|
||||
this.hashes = new Map();
|
||||
}
|
||||
|
||||
async connect() {
|
||||
if (this.connectError) throw this.connectError;
|
||||
this.isOpen = true;
|
||||
}
|
||||
|
||||
async get(key) {
|
||||
return this.values.has(key) ? this.values.get(key) : null;
|
||||
}
|
||||
|
||||
async set(key, value) {
|
||||
this.values.set(key, String(value));
|
||||
return 'OK';
|
||||
}
|
||||
|
||||
async del(key) {
|
||||
const deleted = Number(this.values.delete(key)) + Number(this.sets.delete(key)) + Number(this.hashes.delete(key));
|
||||
return deleted ? 1 : 0;
|
||||
}
|
||||
|
||||
async sAdd(key, value) {
|
||||
if (!this.sets.has(key)) this.sets.set(key, new Set());
|
||||
const before = this.sets.get(key).size;
|
||||
this.sets.get(key).add(value);
|
||||
return this.sets.get(key).size - before;
|
||||
}
|
||||
|
||||
async sRem(key, value) {
|
||||
return Number(this.sets.get(key)?.delete(value) || false);
|
||||
}
|
||||
|
||||
async sMembers(key) {
|
||||
return [...(this.sets.get(key) || [])];
|
||||
}
|
||||
|
||||
async expire() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async hSet(key, entries) {
|
||||
if (!this.hashes.has(key)) this.hashes.set(key, new Map());
|
||||
for (const [field, value] of Object.entries(entries)) this.hashes.get(key).set(field, String(value));
|
||||
return Object.keys(entries).length;
|
||||
}
|
||||
|
||||
async hGetAll(key) {
|
||||
return Object.fromEntries(this.hashes.get(key) || []);
|
||||
}
|
||||
|
||||
async eval(_script, { keys, arguments: scriptArguments }) {
|
||||
const hash = this.hashes.get(keys[0]);
|
||||
if (!hash) return -1;
|
||||
const attempts = Number(hash.get('attempts') || 0) + 1;
|
||||
hash.set('attempts', String(attempts));
|
||||
if (attempts >= Number(scriptArguments[0])) this.hashes.delete(keys[0]);
|
||||
return attempts;
|
||||
}
|
||||
|
||||
multi() {
|
||||
const operations = [];
|
||||
const transaction = {};
|
||||
for (const method of ['set', 'del', 'sAdd', 'sRem', 'expire', 'hSet']) {
|
||||
transaction[method] = (...args) => {
|
||||
operations.push(() => this[method](...args));
|
||||
return transaction;
|
||||
};
|
||||
}
|
||||
transaction.exec = async () => Promise.all(operations.map(operation => operation()));
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async quit() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.isOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
const silentLogger = { error() {} };
|
||||
|
||||
{
|
||||
const state = await createAuthStateStore({ env: {} });
|
||||
assert.equal(state.status, 'disabled');
|
||||
assert.equal(state.backend, 'memory');
|
||||
await state.createSession('session-1', 'user-1');
|
||||
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
|
||||
await state.createLoginChallenge('challenge-1', 'user-1');
|
||||
assert.deepEqual(await state.getLoginChallenge('challenge-1'), { userId: 'user-1', attempts: 0 });
|
||||
await state.createTotpSetup('session-1', 'user-1', 'SECRET');
|
||||
assert.deepEqual(await state.getTotpSetup('session-1'), { userId: 'user-1', secret: 'SECRET' });
|
||||
assert.equal(await state.deleteUserSessions('user-1'), 1);
|
||||
assert.equal(await state.getSession('session-1'), null);
|
||||
await state.close();
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
let clientOptions;
|
||||
const state = await createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_PREFIX: 'test:auth' },
|
||||
logger: silentLogger,
|
||||
clientFactory(options) {
|
||||
clientOptions = options;
|
||||
return client;
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(state.status, 'ready');
|
||||
assert.equal(state.backend, 'redis');
|
||||
assert.equal(state.database, 1, '普通缓存使用 DB 0 时,认证状态应自动使用 DB 1');
|
||||
assert.equal(clientOptions.database, 1);
|
||||
assert.equal(clientOptions.url, 'redis://cache.example:6379/0');
|
||||
|
||||
await state.createSession('session-1', 'user-1');
|
||||
await state.createSession('session-2', 'user-1');
|
||||
assert.deepEqual(await state.getSession('session-1'), { userId: 'user-1' });
|
||||
assert.equal(await state.deleteUserSessions('user-1'), 2);
|
||||
assert.equal(await state.getSession('session-1'), null);
|
||||
assert.equal(await state.getSession('session-2'), null);
|
||||
|
||||
await state.createLoginChallenge('challenge-1', 'user-1');
|
||||
for (let attempts = 1; attempts <= 4; attempts += 1) {
|
||||
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts, exhausted: false });
|
||||
}
|
||||
assert.deepEqual(await state.recordLoginChallengeFailure('challenge-1', 5), { attempts: 5, exhausted: true });
|
||||
assert.equal(await state.getLoginChallenge('challenge-1'), null);
|
||||
|
||||
await state.createTotpSetup('session-3', 'user-1', 'SECRET');
|
||||
assert.deepEqual(await state.getTotpSetup('session-3'), { userId: 'user-1', secret: 'SECRET' });
|
||||
await state.deleteTotpSetup('session-3');
|
||||
assert.equal(await state.getTotpSetup('session-3'), null);
|
||||
await state.close();
|
||||
assert.equal(client.isOpen, false);
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
let options;
|
||||
const state = await createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache.example:6379/0', REDIS_SESSION_URL: 'rediss://sessions.example:6380/4' },
|
||||
logger: silentLogger,
|
||||
clientFactory(clientOptions) {
|
||||
options = clientOptions;
|
||||
return client;
|
||||
}
|
||||
});
|
||||
assert.equal(options.url, 'rediss://sessions.example:6380/4');
|
||||
assert.equal(options.database, 4);
|
||||
await state.close();
|
||||
}
|
||||
|
||||
{
|
||||
const client = new FakeRedisClient();
|
||||
let options;
|
||||
const state = await createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache.example:6379/1', REDIS_SESSION_DB: '0' },
|
||||
logger: silentLogger,
|
||||
clientFactory(clientOptions) {
|
||||
options = clientOptions;
|
||||
return client;
|
||||
}
|
||||
});
|
||||
assert.equal(options.database, 0, '应允许显式选择 DB 0,只要普通缓存使用不同的 DB');
|
||||
await state.close();
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://same.example:6379/0', REDIS_SESSION_URL: 'redis://same.example:6379/0' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => new FakeRedisClient()
|
||||
}),
|
||||
/必须使用与普通缓存不同的逻辑数据库/
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://cache-user@same.example:6379/0', REDIS_SESSION_URL: 'redis://session-user@same.example:6379/0' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => new FakeRedisClient()
|
||||
}),
|
||||
/必须使用与普通缓存不同的逻辑数据库/
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
createAuthStateStore({
|
||||
env: { REDIS_URL: 'redis://unavailable.example:6379/0' },
|
||||
logger: silentLogger,
|
||||
clientFactory: () => new FakeRedisClient({ connectError: new Error('connection refused') })
|
||||
}),
|
||||
/认证状态存储连接失败/
|
||||
);
|
||||
|
||||
console.log('✓ 认证状态本机回退、独立 Redis DB、会话失效与 TOTP 临时状态');
|
||||
+23
-3
@@ -83,16 +83,35 @@ const silentLogger = { warn() {} };
|
||||
await cache.invalidate('results');
|
||||
assert.deepEqual(await cache.remember('results', 'candidate:1', loadResults), { version: 3 });
|
||||
assert.equal(resultLoads, 3, '后台手动刷新后应重新生成成绩缓存');
|
||||
|
||||
client.isReady = false;
|
||||
let fallbackLoads = 0;
|
||||
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
|
||||
assert.equal(await cache.remember('public', 'runtime-fallback', async () => ++fallbackLoads), 1);
|
||||
assert.equal(fallbackLoads, 1, 'Redis 运行中断开后,相同热点读取应由本机缓存合并');
|
||||
client.isReady = true;
|
||||
await cache.close();
|
||||
}
|
||||
|
||||
{
|
||||
const cache = await createRedisCache({ env: {}, logger: silentLogger });
|
||||
let loads = 0;
|
||||
await cache.remember('public', 'home', async () => ++loads);
|
||||
await cache.remember('public', 'home', async () => ++loads);
|
||||
assert.equal(loads, 2, '未配置 Redis 时应始终回源');
|
||||
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
|
||||
assert.equal(await cache.remember('public', 'home', async () => ++loads), 1);
|
||||
assert.equal(loads, 1, '未配置 Redis 时应使用有界本机缓存,避免重复回源');
|
||||
await cache.invalidate('public');
|
||||
assert.equal(await cache.remember('public', 'home', async () => ++loads), 2);
|
||||
assert.equal(loads, 2, '本机缓存应在数据库写入后立即失效');
|
||||
assert.equal(cache.status, 'disabled');
|
||||
|
||||
let releaseOld;
|
||||
const oldLoad = cache.remember('public', 'race', () => new Promise(resolve => { releaseOld = () => resolve('old'); }));
|
||||
await cache.invalidate('public');
|
||||
const newLoad = cache.remember('public', 'race', async () => 'new');
|
||||
releaseOld();
|
||||
assert.equal(await oldLoad, 'old');
|
||||
assert.equal(await newLoad, 'new', '失效后不得等待失效前仍在运行的加载');
|
||||
assert.equal(await cache.remember('public', 'race', async () => 'unexpected'), 'new', '旧加载完成后不得覆盖新缓存');
|
||||
}
|
||||
|
||||
{
|
||||
@@ -104,6 +123,7 @@ const silentLogger = { warn() {} };
|
||||
});
|
||||
assert.equal(cache.status, 'unavailable');
|
||||
assert.equal(await cache.remember('public', 'home', async () => 'database'), 'database');
|
||||
assert.equal(await cache.remember('public', 'home', async () => 'unexpected'), 'database', 'Redis 故障时本机缓存应继续承接重复读取');
|
||||
}
|
||||
|
||||
console.log('Redis 缓存测试通过');
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createAdminViews } from '../src/client/admin-views.mjs';
|
||||
import { createAdmissionViews } from '../src/client/admission-views.mjs';
|
||||
import { api } from '../src/client/api.mjs';
|
||||
import { createCandidateViews } from '../src/client/candidate-views.mjs';
|
||||
import { createPublicViews } from '../src/client/public-views.mjs';
|
||||
|
||||
function protectedViewContext() {
|
||||
let loginRequests = 0;
|
||||
const context = {
|
||||
state: { user: null },
|
||||
app: { classList: { add() {}, remove() {} } },
|
||||
requireLogin() { loginRequests += 1; }
|
||||
};
|
||||
return { context, loginRequests: () => loginRequests };
|
||||
}
|
||||
|
||||
for (const createView of [createAdminViews, createCandidateViews, createAdmissionViews]) {
|
||||
const fixture = protectedViewContext();
|
||||
const views = createView(fixture.context);
|
||||
const render = views.renderAdmin || views.renderCandidate || views.renderAdmission;
|
||||
await render('dashboard');
|
||||
assert.equal(fixture.loginRequests(), 1, '未登录访问受保护视图时应交给统一登录处理');
|
||||
}
|
||||
|
||||
{
|
||||
const app = { classList: { remove() {} }, innerHTML: '' };
|
||||
const state = {
|
||||
user: null,
|
||||
authNotice: '登录状态已失效,请重新登录。',
|
||||
publicData: { selfRegistrationEnabled: false }
|
||||
};
|
||||
const { renderAuth } = createPublicViews({
|
||||
state,
|
||||
app,
|
||||
h: value => String(value ?? ''),
|
||||
icons: { arrow: '', menu: '' }
|
||||
});
|
||||
renderAuth('login');
|
||||
assert.match(app.innerHTML, /需要重新登录/);
|
||||
assert.match(app.innerHTML, /登录状态已失效,请重新登录/);
|
||||
}
|
||||
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({ message: '请先登录' }), {
|
||||
status: 401,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
try {
|
||||
await assert.rejects(() => api('/api/protected'), error => error.status === 401 && error.message === '请先登录');
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('客户端登录失效处理测试通过');
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { admissionNoticeCode, resolveDocumentVerificationSecret, safeCodeEqual, scoreReportCode } from '../src/security/document-verification.mjs';
|
||||
|
||||
const secret = 'test-document-verification-secret-32-characters';
|
||||
const exam = { id: 'exam_1' };
|
||||
const registration = { id: 'registration_1', userId: 'candidate_1' };
|
||||
const results = [
|
||||
{ subjectId: 'math', score: 118, publishedAt: '2026-07-20T08:00:00.000Z' },
|
||||
{ subjectId: 'chinese', score: 112, publishedAt: '2026-07-20T08:00:00.000Z' }
|
||||
];
|
||||
|
||||
const scoreCode = scoreReportCode(secret, registration, exam, results);
|
||||
const reorderedCode = scoreReportCode(secret, registration, exam, [...results].reverse());
|
||||
const changedScoreCode = scoreReportCode(secret, registration, exam, [{ ...results[0], score: 119 }, results[1]]);
|
||||
|
||||
assert.match(scoreCode, /^SR-[A-F0-9]{24}$/);
|
||||
assert.equal(scoreCode, reorderedCode, '科目返回顺序不应改变同一成绩单的防伪码');
|
||||
assert.notEqual(scoreCode, changedScoreCode, '成绩变化必须使旧防伪码失效');
|
||||
assert.equal(safeCodeEqual(scoreCode, scoreCode.toLowerCase()), true);
|
||||
assert.equal(safeCodeEqual(scoreCode, `${scoreCode}0`), false);
|
||||
|
||||
const placement = {
|
||||
id: 'placement_1', userId: 'candidate_1', schoolId: 'school_1',
|
||||
payload: { categoryCode: 'general', noticeNumber: 'AD01-EX-2026-000001' }, updatedAt: '2026-07-21T08:00:00.000Z'
|
||||
};
|
||||
const noticeCode = admissionNoticeCode(secret, placement, exam);
|
||||
const movedSchoolCode = admissionNoticeCode(secret, { ...placement, schoolId: 'school_2' }, exam);
|
||||
const changedNumberCode = admissionNoticeCode(secret, { ...placement, payload: { ...placement.payload, noticeNumber: 'AD01-EX-2026-000002' } }, exam);
|
||||
|
||||
assert.match(noticeCode, /^AN-[A-F0-9]{24}$/);
|
||||
assert.notEqual(noticeCode, movedSchoolCode, '录取学校变化必须使旧通知书防伪码失效');
|
||||
assert.notEqual(noticeCode, changedNumberCode, '录取通知书编号变化必须使旧防伪码失效');
|
||||
|
||||
assert.throws(
|
||||
() => resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: 'too-short' }),
|
||||
/至少 32 个字符/,
|
||||
'生产环境不得静默使用弱密钥或开发回退值'
|
||||
);
|
||||
assert.equal(
|
||||
resolveDocumentVerificationSecret({ NODE_ENV: 'production', DOCUMENT_VERIFICATION_SECRET: secret }),
|
||||
secret
|
||||
);
|
||||
|
||||
console.log('✓ 文书防伪码稳定性、篡改失效与安全比较测试通过');
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
|
||||
const state = createSeedDatabase({ nowIso: () => '2026-07-21T00:00:00.000Z', hashPassword: password => `test-${password}` });
|
||||
const mainExam = state.exams.find(exam => exam.id === 'exam_autumn_2026');
|
||||
const mainRegistrations = state.registrations.filter(registration => registration.examId === mainExam.id);
|
||||
const mainRegistrationIds = new Set(mainRegistrations.map(registration => registration.id));
|
||||
const mainResults = state.results.filter(result => mainRegistrationIds.has(result.registrationId));
|
||||
const mainPreferences = state.admissionRecords.filter(record => record.kind === 'preference' && record.examId === mainExam.id && Number(record.payload?.round || 1) === 1);
|
||||
const mainPlans = state.admissionRecords.filter(record => record.kind === 'plan' && record.examId === mainExam.id && record.status === 'approved');
|
||||
const specialtyProfiles = state.candidateProfiles.filter(profile => profile.specialtyCategory && profile.specialtyType);
|
||||
const specialtyUserIds = new Set(specialtyProfiles.map(profile => profile.userId));
|
||||
const profilesByUserId = new Map(state.candidateProfiles.map(profile => [profile.userId, profile]));
|
||||
|
||||
assert.ok(state.exams.length >= 2, '演示数据至少包含两场考试');
|
||||
assert.equal(state.schools.filter(school => school.isSourceSchool).length, 5, '演示数据应包含 5 所生源校');
|
||||
assert.equal(state.schools.filter(school => school.isAdmissionSchool).length, 3, '演示数据应包含 3 所招生校');
|
||||
assert.deepEqual(Object.fromEntries(mainExam.subjects.map(subject => [subject.name, subject.fullScore])), {
|
||||
语文: 120, 数学: 120, 外语: 120, 历史: 75, 政治: 75, 物理: 80, 化学: 70, 实验: 20, 信息技术: 10
|
||||
});
|
||||
assert.equal(mainRegistrations.length, 1200, '主考试应有 1200 名考生');
|
||||
assert.ok(mainRegistrations.every(registration => registration.status === 'approved' && registration.subjectIds.length === 9), '主考试报名应全部审核通过并包含 9 科');
|
||||
assert.equal(mainResults.length, 1200 * 9, '每名主考试考生都应有完整的 9 科成绩');
|
||||
assert.ok(mainResults.every(result => result.published), '主考试成绩应全部发布');
|
||||
assert.equal(mainPreferences.length, 1200, '主考试每名考生都应完成第一轮志愿');
|
||||
assert.ok(mainPreferences.every(record => record.status === 'submitted' && record.payload.submissionCount === 1 && record.payload.choices.length === 3), '第一轮志愿应提交并填满 3 个招生校');
|
||||
assert.equal(mainPlans.length, 3, '三所招生校都应有已审核通过的招生计划');
|
||||
assert.ok(mainPlans.every(plan => plan.payload.categories.find(category => category.code === 'general')?.quota === 350), '每所招生校普通类计划应为 350 人');
|
||||
assert.ok(mainPlans.every(plan => plan.payload.categories.filter(category => category.specialtyCategory).reduce((sum, category) => sum + category.quota, 0) === 2), '每所招生校特长生计划合计应为 2 人');
|
||||
assert.equal(specialtyProfiles.length, 150, '应有 150 名特长生');
|
||||
assert.ok(mainRegistrations.filter(registration => specialtyUserIds.has(registration.userId)).every(registration => registration.featureScore >= 80 && registration.featureScore <= 100), '特长生特征分应分布在 80-100 分');
|
||||
assert.ok(mainPreferences.every(preference => {
|
||||
const profile = profilesByUserId.get(preference.userId);
|
||||
const categories = preference.payload.choices.map(choice => choice.categoryCode);
|
||||
return profile.specialtyCategory ? categories[0] === profile.specialtyCategory && categories.slice(1).every(code => code === 'general') : categories.every(code => code === 'general');
|
||||
}), '特长生第一志愿应匹配本人特长类别,其余志愿及普通考生志愿应填报普通类');
|
||||
assert.deepEqual(new Set(state.users.map(user => user.passwordHash)), new Set(['test-12345678']), '所有预置账号密码应统一为 12345678');
|
||||
|
||||
for (const subject of mainExam.subjects) {
|
||||
const scores = mainResults.filter(result => result.subjectId === subject.id).map(result => result.score);
|
||||
const mean = scores.reduce((sum, score) => sum + score, 0) / scores.length;
|
||||
const standardDeviation = Math.sqrt(scores.reduce((sum, score) => sum + (score - mean) ** 2, 0) / scores.length);
|
||||
assert.ok(mean > subject.fullScore * 0.66 && mean < subject.fullScore * 0.78, `${subject.name}平均分应符合正态样本预期`);
|
||||
assert.ok(standardDeviation > subject.fullScore * 0.09 && standardDeviation < subject.fullScore * 0.18, `${subject.name}标准差应符合正态样本预期`);
|
||||
assert.ok(scores.every(score => score >= 0 && score <= subject.fullScore), `${subject.name}成绩不得超出满分`);
|
||||
}
|
||||
|
||||
console.log('演示数据规模、学校角色、科目、志愿、特长生、成绩分布与密码校验通过');
|
||||
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createStateCache } from '../src/database/state-cache.mjs';
|
||||
|
||||
let loads = 0;
|
||||
let release;
|
||||
const gate = new Promise(resolve => { release = resolve; });
|
||||
const coalesced = createStateCache({
|
||||
maxAgeMs: 30000,
|
||||
async load() {
|
||||
loads += 1;
|
||||
await gate;
|
||||
return { load: loads };
|
||||
}
|
||||
});
|
||||
const firstPending = coalesced.read();
|
||||
const secondPending = coalesced.read();
|
||||
release();
|
||||
const [first, second] = await Promise.all([firstPending, secondPending]);
|
||||
assert.equal(loads, 1, '并发冷读取应合并为一次全量加载');
|
||||
assert.strictEqual(first, second, '并发读取应共享同一份快照');
|
||||
assert.strictEqual(await coalesced.read(), first, '有效期内应直接复用内存快照');
|
||||
|
||||
coalesced.invalidate();
|
||||
const afterInvalidation = await coalesced.read();
|
||||
assert.equal(loads, 2, '应用写入失效后应重新加载');
|
||||
assert.notStrictEqual(afterInvalidation, first, '失效后不得继续返回旧快照');
|
||||
|
||||
let version = 1;
|
||||
let versionLoads = 0;
|
||||
const versioned = createStateCache({
|
||||
version: () => version,
|
||||
load: () => ({ load: ++versionLoads })
|
||||
});
|
||||
const versionOne = await versioned.read();
|
||||
assert.strictEqual(await versioned.read(), versionOne, '数据库版本未变化时应复用快照');
|
||||
version += 1;
|
||||
const versionTwo = await versioned.read();
|
||||
assert.equal(versionLoads, 2, '外部数据库版本变化后应重新加载');
|
||||
assert.notStrictEqual(versionTwo, versionOne, '外部写入后不得返回旧快照');
|
||||
|
||||
console.log('State cache tests passed');
|
||||
+315
-14
@@ -2,18 +2,28 @@ import { spawn } from 'node:child_process';
|
||||
import { pbkdf2Sync, randomBytes } from 'node:crypto';
|
||||
import { readFile, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { createServer as createNetServer } from 'node:net';
|
||||
import assert from 'node:assert/strict';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { createDatabase, relationalTables } from '../database.mjs';
|
||||
import { createBaseDatabase } from '../src/data/base.mjs';
|
||||
import { createSeedDatabase } from '../src/data/seed.mjs';
|
||||
import { mysqlSchema } from '../src/database/schema.mjs';
|
||||
import { CURRENT_SCHEMA_VERSION } from '../src/database/version.mjs';
|
||||
import { totpAtStep } from '../src/security/totp.mjs';
|
||||
import { buildCenterMaterialsWorkbook, buildWorkbook } from '../excel.mjs';
|
||||
import { admissionNoticeCode } from '../src/security/document-verification.mjs';
|
||||
|
||||
const root = resolve(process.cwd());
|
||||
assert.equal(totpAtStep('GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ', 1), '287082', 'TOTP 实现应符合 RFC 6238 SHA-1 测试向量的 6 位结果');
|
||||
const port = 4182;
|
||||
const port = await new Promise((resolvePort, rejectPort) => {
|
||||
const probe = createNetServer();
|
||||
probe.once('error', rejectPort);
|
||||
probe.listen(0, '127.0.0.1', () => {
|
||||
const selectedPort = probe.address().port;
|
||||
probe.close(error => error ? rejectPort(error) : resolvePort(selectedPort));
|
||||
});
|
||||
});
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const testDb = resolve(root, 'data', 'test-db.sqlite');
|
||||
const mysqlRuleSchemas = mysqlSchema.filter(statement => /CREATE TABLE IF NOT EXISTS (?:admission_number_rules|number_rules)\b/.test(statement));
|
||||
@@ -39,7 +49,7 @@ assert.doesNotMatch(mysqlAdapterSource, /ADD\s+COLUMN\s+IF\s+NOT\s+EXISTS/i, 'My
|
||||
assert.match(mysqlAdapterSource, /for \(const statement of mysqlSchema\) await pool\.query\(statement\)/, 'MySQL DDL 应使用文本协议执行');
|
||||
assert.match(mysqlAdapterSource, /existingResultLockTriggers\.has\(name\)\) await pool\.query\(statement\)/, 'MySQL 触发器不得通过预处理协议创建');
|
||||
assert.doesNotMatch(mysqlAdapterSource, /\.execute\(\s*['"`]\s*(?:CREATE|ALTER|DROP|SHOW)\b/i, 'MySQL DDL 和 SHOW 语句不得通过预处理协议执行');
|
||||
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v17 结构并重建更旧或未完成的开发结构');
|
||||
assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetadata \|\| !\[15, 16, 17, 18, 19, 20\]\.includes\(existingSchemaVersion\)\)/, 'MySQL 应保留可迁移的 v15-v20 结构并重建更旧或未完成的开发结构');
|
||||
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
||||
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
||||
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
||||
@@ -52,7 +62,12 @@ assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例
|
||||
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
assert.match(testDataImportSource, /CURRENT_SCHEMA_VERSION/, '数据库导入脚本应复用统一的当前结构版本');
|
||||
const resetDatabaseSource = await readFile(resolve(root, 'scripts', 'reset-dev-database.mjs'), 'utf8');
|
||||
assert.match(resetDatabaseSource, /import\(['"]\.\/import-test-data\.mjs['"]\)/, '重置入口应复用统一的数据库初始化流程');
|
||||
assert.doesNotMatch(resetDatabaseSource, /DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '重置入口不得再强制使用 SQLite');
|
||||
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||
assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本');
|
||||
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||
assert.equal(baseState.candidateProfiles.length, 0, '正常首次建库不得预置考生');
|
||||
assert.equal(baseState.exams.length, 0, '正常首次建库不得预置考试');
|
||||
@@ -75,7 +90,8 @@ process.env.SQLITE_PATH = testDb;
|
||||
const seededTestDatabase = await createDatabase({
|
||||
root,
|
||||
seed: () => {
|
||||
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword });
|
||||
// 全量 1200 人规模由 seed.test.mjs 单独验证;端到端流程使用较小样本控制运行时间。
|
||||
const state = createSeedDatabase({ nowIso: () => new Date().toISOString(), hashPassword, candidateCount: 360 });
|
||||
const currentSuperAdmin = state.users.find(user => user.role === 'admin' && user.adminLevel === 'super');
|
||||
state.users.push({
|
||||
...currentSuperAdmin,
|
||||
@@ -137,6 +153,7 @@ const classAdmin2 = createClient();
|
||||
const candidate = createClient();
|
||||
const selfCandidate = createClient();
|
||||
const batchCandidate = createClient();
|
||||
const admissionSchoolClient = createClient();
|
||||
const anonymous = createClient();
|
||||
|
||||
try {
|
||||
@@ -147,6 +164,7 @@ try {
|
||||
const clientSources = await Promise.all([
|
||||
'app.js',
|
||||
'src/client/admin-views.mjs',
|
||||
'src/client/admission-views.mjs',
|
||||
'src/client/candidate-views.mjs',
|
||||
'src/client/public-views.mjs'
|
||||
].map(file => readFile(resolve(root, file), 'utf8')));
|
||||
@@ -161,6 +179,9 @@ try {
|
||||
assert.match(appSource, /data-action="edit-exam"/, '考试草稿应提供编辑入口');
|
||||
assert.match(appSource, /data-action="archive-exam"/, '超级管理员界面应提供不可逆考试归档入口');
|
||||
assert.match(appSource, /data-action="refresh-results-cache"/, '成绩管理中心应提供 Redis 成绩缓存刷新入口');
|
||||
assert.match(appSource, /data-action="bulk-placement-review"/, '招生学校投档审核应提供批量处理入口');
|
||||
assert.match(appSource, /data-action="toggle-admission-account"/, '超级管理员应有招生学校账户启停入口');
|
||||
assert.match(appSource, /data-action="reset-admission-account-password"/, '超级管理员应有招生学校账户密码重置入口');
|
||||
assert.match(appSource, /归档不可撤销/, '归档前应明确提示成绩将永久锁定');
|
||||
|
||||
const { DatabaseSync } = await import('node:sqlite');
|
||||
@@ -177,6 +198,8 @@ try {
|
||||
const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name);
|
||||
const examColumns = inspector.prepare('PRAGMA table_info(exams)').all().map(row => row.name);
|
||||
const userColumns = inspector.prepare('PRAGMA table_info(users)').all().map(row => row.name);
|
||||
const schoolColumns = inspector.prepare('PRAGMA table_info(schools)').all().map(row => row.name);
|
||||
const profileColumns = inspector.prepare('PRAGMA table_info(candidate_profiles)').all().map(row => row.name);
|
||||
const registrationColumns = inspector.prepare('PRAGMA table_info(registrations)').all().map(row => row.name);
|
||||
const seededSchoolCount = inspector.prepare('SELECT COUNT(*) AS count FROM schools').get().count;
|
||||
const seededExamCount = inspector.prepare('SELECT COUNT(*) AS count FROM exams').get().count;
|
||||
@@ -221,7 +244,7 @@ try {
|
||||
inspector.close();
|
||||
assert.ok(relationalTables.every(table => tableNames.includes(table)), '所有关系模型总表与分表登记表都必须存在');
|
||||
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
|
||||
assert.equal(schemaVersion, 17, 'TOTP 账户安全应使用 v17 数据结构');
|
||||
assert.equal(schemaVersion, 20, '指标资格、资格公示与分数线公告应使用 v20 数据结构');
|
||||
assert.ok(examPartitions.length > 0, '每场考试都应登记一组专属物理表');
|
||||
assert.equal(examPartitions.length, seededExamCount, '考试分表登记不得缺漏');
|
||||
assert.ok(examPartitionCoverage.every(item => item.candidates === item.expectedCandidates && item.admissions === item.expectedAdmissions && item.results === item.expectedResults), '考试专属表应与该场考试的考生、准考信息和成绩数据一致');
|
||||
@@ -232,7 +255,10 @@ try {
|
||||
assert.equal(resultLockTriggers.length, 3, '数据库应从插入、更新、删除三个方向永久锁定归档成绩');
|
||||
assert.ok(['archived_at', 'archived_by'].every(column => userColumns.includes(column)), '账户应保存独立归档状态和校方操作人');
|
||||
assert.ok(['totp_enabled', 'totp_secret_encrypted', 'totp_recovery_codes', 'totp_last_used_step'].every(column => userColumns.includes(column)), '账户应保存加密 TOTP 状态、恢复码哈希和防重放时间片');
|
||||
assert.ok(['is_source_school', 'is_admission_school'].every(column => schoolColumns.includes(column)), '学校档案应统一保存生源校和招生校类型');
|
||||
assert.ok(['specialty_category', 'specialty_type'].every(column => profileColumns.includes(column)), '考生档案应保存特长生大类和小类');
|
||||
assert.ok(['payment_status', 'paid_at', 'paid_by'].every(column => registrationColumns.includes(column)), '报名应保存缴费状态、确认时间和班级负责人');
|
||||
assert.ok(registrationColumns.includes('feature_score'), '每场考试报名应有独立且默认 0 分的特征分');
|
||||
assert.ok(seededSchoolCount >= 4, '独立测试数据应覆盖至少四所学校');
|
||||
assert.ok(seededCandidateCount >= 360, '独立测试数据应包含数百名考生');
|
||||
assert.ok(seededRegistrationCounts.some(item => item.status === 'approved' && item.payment_status === 'unpaid' && item.count >= 90), '测试数据应包含大量已报名未缴费记录');
|
||||
@@ -334,11 +360,15 @@ try {
|
||||
assert.equal((await candidate.request('/api/candidate/dashboard')).response.status, 428, '未补全个人信息前仍不得进入考试业务');
|
||||
const updateProfile = await candidate.request('/api/candidate/profile', {
|
||||
method: 'PUT',
|
||||
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888' }
|
||||
body: { name: '测试考生新名', gender: '男', idNumber: '320101200801019999', nativePlace: '江苏海州', birthDate: '2008-01-01', ethnicity: '汉族', phone: '13900009999', email: 'test@example.com', schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', address: '测试路 1 号', postalCode: '222000', guardianName: '测试家长', guardianPhone: '13800008888', emergencyContact: '测试家长', emergencyPhone: '13800008888', specialtyCategory: 'arts', specialtyType: 'fine_arts', specialtyCertificate: 'ART-2026-001' }
|
||||
});
|
||||
assert.equal(updateProfile.response.status, 200, '考生应补全包含籍贯、住址、手机、邮箱和班级的完整资料');
|
||||
assert.equal(updateProfile.data.profile.profileCompleted, true, '完整资料提交后应标记完成');
|
||||
assert.equal(updateProfile.data.profile.status, 'pending', '完整资料应进入审核');
|
||||
assert.equal(updateProfile.data.profile.specialtyCategory, 'arts', '考生资料应保存艺术大类资格');
|
||||
assert.equal(updateProfile.data.profile.specialtyType, 'fine_arts', '考生资料应保存对应的美术小类资格');
|
||||
const mismatchedSpecialty = await candidate.request('/api/candidate/profile', { method: 'PUT', body: { ...updateProfile.data.profile, schoolId: 'school_hz1', classId: 'class_hz1_302', provinceCode: '320000', cityCode: '320700', districtCode: '320706', specialtyCategory: 'arts', specialtyType: 'track_field' } });
|
||||
assert.equal(mismatchedSpecialty.response.status, 400, '艺术大类不得选择体育小类');
|
||||
const refreshedSession = await candidate.request('/api/auth/me');
|
||||
assert.equal(refreshedSession.data.user.displayName, '测试考生新名', '考生姓名修改后账号显示名应同步');
|
||||
const candidateCannotAdmin = await candidate.request('/api/admin/dashboard');
|
||||
@@ -359,7 +389,16 @@ try {
|
||||
const createSchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市第四中学', code: 'hz04', address: '海州市测试区学校路 4 号', active: true } });
|
||||
assert.equal(createSchool.response.status, 201, '超级管理员应能创建学校');
|
||||
assert.equal(createSchool.data.school.code, 'HZ04', '学校代码应规范化为大写');
|
||||
assert.equal(createSchool.data.school.isSourceSchool, true, '新建学校默认兼容生源校职责');
|
||||
assert.equal(createSchool.data.school.isAdmissionSchool, true, '新建学校默认兼容招生校职责');
|
||||
const createdSchoolId = createSchool.data.school.id;
|
||||
const admissionOnlySchool = await admin.request('/api/admin/schools', { method: 'POST', body: { name: '海州市招生实验学校', code: 'HZ-ADMISSION', isSourceSchool: false, isAdmissionSchool: true, active: true } });
|
||||
assert.equal(admissionOnlySchool.response.status, 201, '学校管理应支持只设置为招生校');
|
||||
assert.equal(admissionOnlySchool.data.school.isSourceSchool, false);
|
||||
assert.ok(!(await anonymous.request('/api/public/home')).data.schools.some(item => item.id === admissionOnlySchool.data.school.id), '仅招生校不得出现在考生生源学校选择中');
|
||||
const admissionAccount = await admin.request('/api/admin/admission-school-accounts', { method: 'POST', body: { schoolId: admissionOnlySchool.data.school.id, username: 'admission_only_test', password: 'Admission123!', displayName: '招生实验校招办' } });
|
||||
assert.equal(admissionAccount.response.status, 201, '招生校应可创建招生学校账号');
|
||||
assert.equal((await admissionSchoolClient.request('/api/auth/login', { method: 'POST', body: { username: 'admission_only_test', password: 'Admission123!' } })).response.status, 200, '招生学校账号应可登录独立工作台');
|
||||
assert.equal((await admin.request('/api/admin/schools', { method: 'POST', body: { name: '重复代码学校', code: 'HZ04' } })).response.status, 409, '学校代码必须唯一');
|
||||
const disableSchool = await admin.request(`/api/admin/schools/${createdSchoolId}`, { method: 'PATCH', body: { name: '海州市第四实验中学', active: false } });
|
||||
assert.equal(disableSchool.response.status, 200, '超级管理员应能编辑和停用学校');
|
||||
@@ -369,6 +408,16 @@ try {
|
||||
|
||||
const adminDirectory = await admin.request('/api/admin/admins');
|
||||
assert.ok(adminDirectory.data.admins.filter(item => item.adminLevel === 'school' && item.schoolId === 'school_hz1').length >= 2, '同一学校应支持多个同级管理员');
|
||||
const managedAdmin = adminDirectory.data.admins.find(item => item.username === 'school_admin_2');
|
||||
assert.ok(managedAdmin, '管理员台账应返回可管理的校级管理员');
|
||||
assert.equal((await admin.request(`/api/admin/admins/${adminDirectory.data.admins.find(item => item.username === 'admin')?.id}`, { method: 'PATCH', body: { active: false } })).response.status, 409, '超级管理员不能停用当前正在使用的自己');
|
||||
assert.equal((await admin.request(`/api/admin/admins/${managedAdmin.id}`, { method: 'PATCH', body: { active: false } })).response.status, 200, '超级管理员应能停用其他管理员账户');
|
||||
assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, false, '管理员台账应同步显示停用状态');
|
||||
const adminPasswordReset = await admin.request(`/api/admin/admins/${managedAdmin.id}/reset-password`, { method: 'POST' });
|
||||
assert.equal(adminPasswordReset.response.status, 200, '超级管理员应能重置其他管理员密码');
|
||||
assert.match(adminPasswordReset.data.temporaryPassword, /^Reset-/, '管理员密码重置应返回一次性临时密码');
|
||||
assert.equal((await admin.request('/api/admin/admins')).data.admins.find(item => item.id === managedAdmin.id).active, true, '重置密码应重新启用目标管理员账户');
|
||||
assert.equal((await schoolAdmin2.request('/api/auth/login', { method: 'POST', body: { username: managedAdmin.username, password: adminPasswordReset.data.temporaryPassword } })).response.status, 200, '被重置的管理员应可使用临时密码重新登录');
|
||||
const schoolCenters = await schoolAdmin.request('/api/admin/centers');
|
||||
assert.ok(schoolCenters.data.centers.every(item => item.schoolId === 'school_hz1'), '校级管理员只能读取本校考点');
|
||||
const legacySuperCenters = await legacyAdmin.request('/api/admin/centers');
|
||||
@@ -427,6 +476,12 @@ try {
|
||||
assert.match(classTemplate.response.headers.get('content-type'), /spreadsheetml/);
|
||||
const classWorkbook = new ExcelJS.Workbook(); await classWorkbook.xlsx.load(classTemplate.data);
|
||||
assert.equal(classWorkbook.worksheets[0].getCell('A2').value, '学校代码*', 'Excel 模板应包含中文字段表头');
|
||||
const admittedWorkbook = new ExcelJS.Workbook();
|
||||
await admittedWorkbook.xlsx.load(await buildWorkbook('admitted_candidates', [{ candidateNumber: '20260001', name: '录取考生', featureScore: 87.5, totalScore: 650, admittedSchool: '第一中学', categoryName: '艺术特长生', preferenceOrder: 1 }]));
|
||||
const admittedSheet = admittedWorkbook.getWorksheet('录取考生');
|
||||
assert.equal(admittedSheet.getCell('A3').value, '20260001', '录取考生 Excel 应包含报名号');
|
||||
assert.ok(admittedSheet.getRow(2).values.includes('特征分'), '录取考生 Excel 应单列特征分');
|
||||
assert.ok(admittedSheet.getRow(2).values.includes('录取学校'), '录取考生 Excel 应包含录取学校');
|
||||
const classImportFile = Buffer.from(await buildWorkbook('classes', [{ schoolCode: 'HZ01', grade: '高一', name: '高一(8)班', status: '启用' }]));
|
||||
const classImport = await schoolAdmin.request('/api/admin/excel/classes', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: classImportFile });
|
||||
assert.equal(classImport.response.status, 200, '校级管理员应能从 Excel 导入本校班级');
|
||||
@@ -544,6 +599,175 @@ try {
|
||||
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
|
||||
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
|
||||
const exam = createExam.data.exam;
|
||||
const admissionSettingResult = await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'draft', maxChoices: 5, maxSubmissions: 1 } });
|
||||
assert.equal(admissionSettingResult.response.status, 200, `超级管理员应能按考试启用志愿功能并设置填报次数:${JSON.stringify(admissionSettingResult.data)}\n${serverError}`);
|
||||
const structuredPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, note: '结构化计划测试', categories: [
|
||||
{ code: 'general', name: '普通生', quota: 20, specialtyCategory: '', specialtyType: '', indicatorAllocations: [{ sourceSchoolId: 'school_hz1', quota: 5 }] },
|
||||
{ code: 'arts', name: '美术特长生', quota: 4, specialtyCategory: 'arts', specialtyType: 'fine_arts', indicatorAllocations: [] }
|
||||
] } });
|
||||
assert.equal(structuredPlan.response.status, 201, '招生校应能提交结构化类别与生源校指标计划');
|
||||
assert.equal(structuredPlan.data.plan.payload.categories[1].specialtyType, 'fine_arts');
|
||||
assert.equal((await admin.request(`/api/admin/admission-plans/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '系统测试通过' } })).response.status, 200, '超级管理员应能审核结构化招生计划');
|
||||
const publicPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
const publicPlan = publicPlanAnnouncements.data.plans.find(item => item.id === structuredPlan.data.plan.id);
|
||||
assert.ok(publicPlan, '招生计划审核通过后应立即自动进入公开公示接口');
|
||||
assert.equal(publicPlan.schoolName, '海州市招生实验学校', '招生计划公示应标明招生学校');
|
||||
assert.deepEqual(publicPlan.rows.map(item => item.quota), [20, 4], '招生计划公示应保留各类别计划人数');
|
||||
const noticeControlList = await admin.request('/api/admin/notices');
|
||||
const controlledPlan = noticeControlList.data.publications.find(item => item.sourceType === 'plan' && item.id === structuredPlan.data.plan.id);
|
||||
assert.ok(controlledPlan?.visible, '自动发布的招生计划应出现在通知发布管理页并默认显示');
|
||||
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: false } })).response.status, 200, '通知发布管理页应能隐藏系统招生计划公示');
|
||||
const hiddenPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
assert.equal(hiddenPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), false, '隐藏系统公示后公开通知目录不得继续返回该招生计划');
|
||||
assert.equal((await admin.request(`/api/admin/publications/plan/${structuredPlan.data.plan.id}`, { method: 'PATCH', body: { visible: true } })).response.status, 200, '已隐藏的系统招生计划公示应能重新显示');
|
||||
const restoredPlanAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
assert.ok(restoredPlanAnnouncements.data.plans.some(item => item.id === structuredPlan.data.plan.id), '重新显示后招生计划应恢复到公开通知目录');
|
||||
assert.equal((await classAdmin.request('/api/admin/indicator-qualifications')).response.status, 403, '班级管理员不得查看或确认指标分配资格');
|
||||
assert.equal((await admin.request('/api/admin/indicator-qualifications')).response.status, 403, '超级管理员不得代替生源校确认指标分配资格');
|
||||
const qualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
|
||||
const qualificationExam = qualificationLedger.data.exams.find(item => item.examId === exam.id);
|
||||
assert.ok(qualificationExam?.qualificationStatus.rows.length, '生源校学校管理员应看到本校待确认考生');
|
||||
const bulkQualification = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/bulk`, { method: 'PUT', body: { userIds: qualificationExam.qualificationStatus.rows.map(row => row.userId), eligible: false } });
|
||||
assert.equal(bulkQualification.response.status, 200, '生源校应能多选后一键批量确认指标分配资格');
|
||||
assert.equal(bulkQualification.data.count, qualificationExam.qualificationStatus.rows.length, '批量确认应返回实际更新人数');
|
||||
assert.equal(bulkQualification.data.published, true, '批量确认覆盖全校考生时应自动完成公示');
|
||||
const candidateQualificationRow = qualificationExam.qualificationStatus.rows.find(row => row.registrationNumber === candidateNumber);
|
||||
const confirmation = await schoolAdmin.request(`/api/admin/indicator-qualifications/${exam.id}/${candidateQualificationRow.userId}`, { method: 'PUT', body: { eligible: true } });
|
||||
assert.equal(confirmation.response.status, 200, '批量确认后仍应支持逐人修正资格');
|
||||
const completedQualificationLedger = await schoolAdmin.request('/api/admin/indicator-qualifications');
|
||||
assert.equal(completedQualificationLedger.data.exams.find(item => item.examId === exam.id).qualificationStatus.complete, true, '本校全部考生确认后应自动完成资格公示');
|
||||
const publicQualification = await anonymous.request('/api/public/announcements');
|
||||
const qualificationPublication = publicQualification.data.qualifications.find(item => item.examId === exam.id && item.schoolName === '海州市第一中学');
|
||||
assert.ok(qualificationPublication, '资格全部确认后应自动出现在独立公开公告接口');
|
||||
assert.equal(qualificationPublication.rows.find(item => item.registrationNumber === candidateNumber).eligible, true, '资格公示应公开考生有无指标分配资格');
|
||||
const invalidSpecialtyPlan = await admissionSchoolClient.request('/api/admission/plans', { method: 'POST', body: { examId: exam.id, categories: [{ code: 'bad', name: '错误特长类别', quota: 1, specialtyCategory: 'arts', specialtyType: 'track_field', indicatorAllocations: [] }] } });
|
||||
assert.equal(invalidSpecialtyPlan.response.status, 400, '招生计划不得把艺术大类与体育小类混用');
|
||||
|
||||
const placementCandidates = qualificationExam.qualificationStatus.rows.slice(0, 3);
|
||||
assert.equal(placementCandidates.length, 3, '批量投档测试至少需要三名候选考生');
|
||||
const reportedCandidate = createClient();
|
||||
const reportedCandidateLogin = await reportedCandidate.request('/api/auth/login', { method: 'POST', body: { username: placementCandidates[0].registrationNumber, password: '12345678' } });
|
||||
assert.equal(reportedCandidateLogin.response.status, 200, '报到补录边界测试应能登录正式录取考生账号');
|
||||
const placementIds = placementCandidates.map((row, index) => `placement_bulk_test_${index + 1}`);
|
||||
const placementCreatedAt = new Date().toISOString();
|
||||
const placementWriter = new DatabaseSync(testDb);
|
||||
const insertPlacement = placementWriter.prepare(`
|
||||
INSERT INTO admission_records (id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at)
|
||||
VALUES (?, 'placement', ?, ?, ?, 'school_review', ?, ?, ?)
|
||||
`);
|
||||
for (const [index, row] of placementCandidates.entries()) {
|
||||
insertPlacement.run(
|
||||
placementIds[index], exam.id, row.userId, admissionOnlySchool.data.school.id,
|
||||
JSON.stringify({ categoryCode: index === 2 ? 'arts' : 'general', categoryName: index === 2 ? '美术特长生' : '普通生', totalScore: 620 - index, preferenceOrder: index + 1 }),
|
||||
placementCreatedAt, placementCreatedAt
|
||||
);
|
||||
}
|
||||
placementWriter.prepare("UPDATE admission_records SET status = 'school_review' WHERE kind = 'setting' AND exam_id = ?").run(exam.id);
|
||||
placementWriter.close();
|
||||
|
||||
const placementReviewLedger = await admissionSchoolClient.request('/api/admission/placements');
|
||||
const seededPlacements = placementReviewLedger.data.placements.filter(item => placementIds.includes(item.id));
|
||||
assert.equal(seededPlacements.length, 3, '招生学校投档台账应返回可供筛选、搜索的完整候选记录');
|
||||
assert.ok(seededPlacements.every(item => item.examName === exam.name && item.candidate.registrationNumber), '投档台账应包含考试名称和考生报名号');
|
||||
const bulkAccept = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept', note: '批量核验通过' } });
|
||||
assert.equal(bulkAccept.response.status, 200, '招生学校应能批量接收多名投档考生');
|
||||
assert.equal(bulkAccept.data.count, 2, '批量接收应返回实际处理人数');
|
||||
const shortBulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '材料不符' } });
|
||||
assert.equal(shortBulkWithdrawal.response.status, 400, '批量退档必须填写充分的特殊理由');
|
||||
const bulkWithdrawal = await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: [placementIds[2]], decision: 'withdraw', note: '专项资格证明材料复核不通过' } });
|
||||
assert.equal(bulkWithdrawal.response.status, 200, '招生学校应能批量申请退档');
|
||||
const processedPlacementLedger = await admissionSchoolClient.request('/api/admission/placements');
|
||||
assert.ok(processedPlacementLedger.data.placements.filter(item => placementIds.slice(0, 2).includes(item.id)).every(item => item.status === 'admitted'), '批量接收后所选记录应全部进入拟录取状态');
|
||||
assert.equal(processedPlacementLedger.data.placements.find(item => item.id === placementIds[2]).status, 'withdrawal_pending', '批量退档后记录应进入上级审核状态');
|
||||
assert.equal((await admissionSchoolClient.request('/api/admission/placements/bulk', { method: 'POST', body: { ids: placementIds.slice(0, 2), decision: 'accept' } })).response.status, 409, '已处理记录不得被重复批量审核');
|
||||
|
||||
assert.equal((await admin.request(`/api/admin/admission-withdrawals/${placementIds[2]}`, { method: 'PATCH', body: { approved: true, reviewNote: '同意退档' } })).response.status, 200, '超级管理员应先办结退档再签发录取通知书');
|
||||
const finalizedAdmission = await admin.request(`/api/admin/admissions/${exam.id}/finalize`, { method: 'POST' });
|
||||
assert.equal(finalizedAdmission.response.status, 200, '超级管理员应能签发带编号的录取通知书并开启报到');
|
||||
assert.equal(finalizedAdmission.data.admittedCount, 2, '正式签发人数应与学校接收人数一致');
|
||||
const roundAdmissionAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
const roundAdmissionPublication = roundAdmissionAnnouncements.data.admissions.find(item => item.examId === exam.id && item.round === 1);
|
||||
assert.ok(roundAdmissionPublication, '每轮录取通知书签发后应立即自动生成本轮录取名单公示');
|
||||
assert.equal(roundAdmissionPublication.rows.length, 2, '本轮录取公示应固定保存本轮全部正式录取考生');
|
||||
assert.ok(roundAdmissionPublication.title.includes('第 1 轮录取名单公示'), '本轮公示标题应明确标注录取轮次');
|
||||
const homeAfterRoundAdmission = await anonymous.request('/api/public/home');
|
||||
assert.ok(homeAfterRoundAdmission.data.notices.some(item => item.title.includes('第 1 轮录取名单公示')), '本轮录取公示应同步进入首页通知公告');
|
||||
const reportingLedger = await admissionSchoolClient.request('/api/admission/reporting');
|
||||
const reportingBatch = reportingLedger.data.batches.find(item => item.exam.id === exam.id);
|
||||
assert.equal(reportingBatch.rows.length, 2, '招生学校报到台账应包含本轮全部正式录取考生');
|
||||
assert.deepEqual(reportingBatch.rows.map(item => item.noticeNumber), [`${admissionOnlySchool.data.school.code}-${exam.code}-000001`, `${admissionOnlySchool.data.school.code}-${exam.code}-000002`], '通知书编号应使用学校代码、考试代码和学校独立流水号');
|
||||
const reportingExport = await admissionSchoolClient.request(`/api/admission/reporting/export?examId=${exam.id}`);
|
||||
assert.equal(reportingExport.response.status, 200, '招生学校应能导出报到状态 Excel');
|
||||
const reportingWorkbook = new ExcelJS.Workbook(); await reportingWorkbook.xlsx.load(reportingExport.data);
|
||||
const reportingSheet = reportingWorkbook.getWorksheet('考生报到');
|
||||
assert.ok(reportingSheet.getRow(2).values.includes('报到状态码*(Y/N/P)'), '报到 Excel 应明确提供 Y/N/P 状态码列');
|
||||
assert.equal(reportingSheet.getCell('G3').value, 'P', '新报到批次导出时应默认为待确认状态码 P');
|
||||
const placementLedgerExport = await admin.request(`/api/admin/admissions/placements/export?examId=${exam.id}&schoolId=${admissionOnlySchool.data.school.id}&status=final&q=${encodeURIComponent(placementCandidates[0].registrationNumber)}`);
|
||||
assert.equal(placementLedgerExport.response.status, 200, '超级管理员应能按考试、招生学校、状态和搜索词导出录取情况台账');
|
||||
const placementLedgerWorkbook = new ExcelJS.Workbook(); await placementLedgerWorkbook.xlsx.load(placementLedgerExport.data);
|
||||
const placementLedgerSheet = placementLedgerWorkbook.getWorksheet('录取情况');
|
||||
assert.ok(placementLedgerSheet.getRow(2).values.includes('录取状态'), '录取情况台账应包含录取状态列');
|
||||
assert.equal(placementLedgerSheet.rowCount, 3, '录取台账导出应严格应用当前筛选条件,只保留命中的一名考生');
|
||||
assert.equal(placementLedgerSheet.getRow(3).values.includes(placementCandidates[0].registrationNumber), true, '筛选后的录取台账应包含搜索命中的报名号');
|
||||
assert.equal((await classAdmin.request(`/api/admin/admissions/placements/export?examId=${exam.id}`)).response.status, 403, '非超级管理员不得导出全市录取台账');
|
||||
|
||||
const finalizedInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||
const finalizedPlacementRow = finalizedInspector.prepare('SELECT * FROM admission_records WHERE id = ?').get(placementIds[0]);
|
||||
finalizedInspector.close();
|
||||
const finalizedPlacement = { id: finalizedPlacementRow.id, kind: finalizedPlacementRow.kind, examId: finalizedPlacementRow.exam_id, userId: finalizedPlacementRow.user_id, schoolId: finalizedPlacementRow.school_id, status: finalizedPlacementRow.status, payload: JSON.parse(finalizedPlacementRow.payload_json), createdAt: finalizedPlacementRow.created_at, updatedAt: finalizedPlacementRow.updated_at };
|
||||
const noticeCode = admissionNoticeCode('development-document-verification-secret', finalizedPlacement, exam);
|
||||
const reportingPreview = await admissionSchoolClient.request('/api/admission/reporting/scan-preview', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}` } });
|
||||
assert.equal(reportingPreview.response.status, 200, '扫描通知书二维码后应先返回考生确认信息');
|
||||
assert.equal(reportingPreview.data.row.status, 'pending', '扫码预览不得提前修改报到状态');
|
||||
const scannedReporting = await admissionSchoolClient.request('/api/admission/reporting/scan', { method: 'POST', body: { examId: exam.id, code: `http://127.0.0.1/#verify/${noticeCode}`, status: 'reported' } });
|
||||
assert.equal(scannedReporting.response.status, 200, '确认页点击暂存后应能保存扫码报到结果');
|
||||
assert.equal(scannedReporting.data.row.status, 'reported', '确认页默认选择应能暂存为已报到而不是直接提交');
|
||||
|
||||
const secondRow = reportingBatch.rows.find(row => row.noticeNumber !== finalizedPlacement.payload.noticeNumber);
|
||||
assert.ok(secondRow, '报到测试应找到另一名尚未处理的正式录取考生');
|
||||
const importReportingFile = Buffer.from(await buildWorkbook('admission_reporting', [{ noticeNumber: secondRow.noticeNumber, candidateNumber: secondRow.candidateNumber, name: secondRow.name, examCode: exam.code, schoolCode: admissionOnlySchool.data.school.code, categoryName: secondRow.categoryName, reportingStatusCode: 'N', reportingNote: '逾期未报到' }]));
|
||||
const importedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile });
|
||||
assert.equal(importedReporting.response.status, 200, '招生学校应能导入修改后的报到 Excel 并暂存');
|
||||
assert.equal(importedReporting.data.count, 1, 'Excel 导入应返回读取行数');
|
||||
assert.equal(importedReporting.data.changedCount, 1, 'Excel 导入应明确返回实际变化人数');
|
||||
assert.equal(importedReporting.data.changes[0].toCode, 'N', 'Excel 导入变化摘要应说明修改后的状态码');
|
||||
const unchangedReporting = await admissionSchoolClient.request(`/api/admission/reporting/import?examId=${exam.id}`, { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: importReportingFile });
|
||||
assert.equal(unchangedReporting.data.changedCount, 0, '重复导入相同 Excel 时应明确提示没有变化');
|
||||
assert.equal(unchangedReporting.data.unchangedCount, 1, '重复导入相同 Excel 时应返回未变化行数');
|
||||
assert.equal((await admissionSchoolClient.request('/api/admission/reporting/submit', { method: 'POST', body: { examId: exam.id } })).response.status, 200, '全部状态确认后招生学校应能提交报到情况');
|
||||
assert.equal((await admissionSchoolClient.request('/api/admission/reporting/decision', { method: 'POST', body: { examId: exam.id, supplement: true, decisionNote: '一名考生未报到,申请补录缺额' } })).response.status, 200, '招生学校应能根据实时完成率提交补录决定');
|
||||
const reportingApprovalLedger = await admin.request('/api/admin/admissions');
|
||||
const pendingReportingApproval = reportingApprovalLedger.data.reportingRequests.find(item => item.examId === exam.id && item.status === 'pending_approval');
|
||||
assert.ok(pendingReportingApproval, '超级管理员应看到招生学校报到与补录审批待办');
|
||||
const supplementEnd = new Date(Date.now() + 48 * hour).toISOString();
|
||||
const approvedReporting = await admin.request(`/api/admin/admission-reporting/${pendingReportingApproval.id}`, { method: 'PATCH', body: { approved: true, approvalNote: '同意按缺额补录', preferenceEnd: supplementEnd } });
|
||||
assert.equal(approvedReporting.response.status, 200, '超级管理员应能批准补录并自动公开报到情况');
|
||||
assert.equal(approvedReporting.data.phase, 'supplementary', '全部学校审批完成且存在补录申请时应自动开启下一轮补录');
|
||||
const reportedCandidateSupplementView = await reportedCandidate.request('/api/candidate/admissions');
|
||||
const reportedCandidateAdmission = reportedCandidateSupplementView.data.admissions.find(item => item.examId === exam.id);
|
||||
assert.equal(reportedCandidateAdmission.supplementEligible, false, '已经正式录取并报到的考生不得再次进入补录填报');
|
||||
assert.match(reportedCandidateAdmission.supplementIneligibilityReason, /已被录取/, '考生页面应明确说明不能重复参加补录的原因');
|
||||
const repeatedSupplementPreference = await reportedCandidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: admissionOnlySchool.data.school.id, categoryCode: 'general', preferenceType: 'general' }] } });
|
||||
assert.equal(repeatedSupplementPreference.response.status, 403, '服务端必须拒绝已录取且已报到考生再次提交补录志愿');
|
||||
const reportingAnnouncements = await anonymous.request('/api/public/announcements');
|
||||
const reportingPublication = reportingAnnouncements.data.reports.find(item => item.id === pendingReportingApproval.id);
|
||||
assert.ok(reportingPublication?.title.includes('补录说明'), '批准补录后报到公示标题应自动包含补录说明');
|
||||
assert.equal(reportingPublication.statistics.reportedCount, 1, '公开报到情况应包含简单统计数据');
|
||||
const homeAfterReporting = await anonymous.request('/api/public/home');
|
||||
assert.ok(homeAfterReporting.data.notices.some(item => item.id === `system-reporting-${pendingReportingApproval.id}`), '系统自动生成的报到公示应进入所有用户共用的通知列表');
|
||||
|
||||
const admissionAccountLedger = await admin.request('/api/admin/admissions');
|
||||
const managedAdmissionAccount = admissionAccountLedger.data.schoolAccounts.find(item => item.id === admissionAccount.data.account.id);
|
||||
assert.equal(managedAdmissionAccount.schoolName, '海州市招生实验学校', '招生学校账户台账应显示绑定学校');
|
||||
assert.equal(managedAdmissionAccount.active, true, '招生学校账户台账应返回真实启停状态');
|
||||
const disabledAdmissionAccount = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: false } });
|
||||
assert.equal(disabledAdmissionAccount.data.account.active, false, '超级管理员应能停用招生学校账户');
|
||||
assert.equal((await admissionSchoolClient.request('/api/admission/context')).response.status, 401, '停用招生学校账户应立即使既有会话失效');
|
||||
assert.equal((await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}`, { method: 'PATCH', body: { active: true } })).data.account.active, true, '超级管理员应能重新启用招生学校账户');
|
||||
const resetAdmissionPassword = await admin.request(`/api/admin/admission-school-accounts/${managedAdmissionAccount.id}/reset-password`, { method: 'POST' });
|
||||
assert.match(resetAdmissionPassword.data.temporaryPassword, /^Reset-/, '重置招生学校账户密码应返回一次性临时密码');
|
||||
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: 'Admission123!' } })).response.status, 401, '招生学校账户重置后原密码应失效');
|
||||
assert.equal((await createClient().request('/api/auth/login', { method: 'POST', body: { username: managedAdmissionAccount.username, password: resetAdmissionPassword.data.temporaryPassword } })).response.status, 200, '招生学校账户应能使用临时密码重新登录');
|
||||
const createdExamInspector = new DatabaseSync(testDb, { readOnly: true });
|
||||
const createdExamPartition = createdExamInspector.prepare('SELECT * FROM exam_data_partitions WHERE exam_id = ?').get(exam.id);
|
||||
assert.ok(createdExamPartition, '创建考试时应同步登记该场考试的专属物理表');
|
||||
@@ -618,6 +842,10 @@ try {
|
||||
const adminRegistration = adminRegistrations.data.registrations.find(item => item.id === registrationId);
|
||||
assert.equal(adminRegistration.status, 'pending', '新报名应进入管理员审核队列');
|
||||
assert.ok(adminRegistration.exam?.name && adminRegistration.schoolName && adminRegistration.gradeName && adminRegistration.className, '报名审核列表应提供考试、学校、年级和班级筛选字段');
|
||||
assert.deepEqual(adminRegistration.subjects.map(item => item.id), [exam.subjects[0].id, exam.subjects[2].id], '报名审核详情应完整提供考生所报科目');
|
||||
const candidateReviewList = await admin.request('/api/admin/candidates');
|
||||
const candidateReviewItem = candidateReviewList.data.candidates.find(item => item.id === profile.id);
|
||||
assert.ok(candidateReviewItem.registrations.some(item => item.id === registrationId && item.exam?.id === exam.id && item.subjects.length === 2), '考生资料审核应同时提供关联考试和所报科目上下文');
|
||||
|
||||
const schoolFlows = await schoolAdmin.request('/api/admin/workflow-instances');
|
||||
const registrationFlow = schoolFlows.data.instances.find(item => item.businessId === registrationId && item.status === 'pending');
|
||||
@@ -815,26 +1043,62 @@ try {
|
||||
assert.match(publishNotice.data.notice.summary, /成绩发布/, '首页摘要留空时应从富文本正文提取纯文本');
|
||||
const emptyRichNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '空富文本', content: '<script>alert(1)</script>', status: 'draft' } });
|
||||
assert.equal(emptyRichNotice.response.status, 400, '清洗后没有正文的通知必须拒绝保存');
|
||||
const draftNotice = await admin.request('/api/admin/notices', { method: 'POST', body: { title: '待编辑草稿', content: '<p>初始草稿内容</p>', status: 'draft' } });
|
||||
assert.equal(draftNotice.response.status, 201, '管理员应能保存通知草稿');
|
||||
const editedDraftNotice = await admin.request(`/api/admin/notices/${draftNotice.data.notice.id}`, { method: 'PATCH', body: { title: '已编辑草稿', content: '<p>修改后的草稿内容</p>', status: 'draft' } });
|
||||
assert.equal(editedDraftNotice.response.status, 200, '草稿编辑入口应通过原通知修改接口保存');
|
||||
assert.equal(editedDraftNotice.data.notice.title, '已编辑草稿');
|
||||
const noticeListAfterEdit = await admin.request('/api/admin/notices');
|
||||
assert.equal(noticeListAfterEdit.data.notices.filter(item => item.id === draftNotice.data.notice.id).length, 1, '编辑草稿不得重复新建通知');
|
||||
assert.match(noticeListAfterEdit.data.notices.find(item => item.id === draftNotice.data.notice.id).contentHtml, /修改后的草稿内容/, '草稿正文修改后应正确回填到管理接口');
|
||||
const refreshedHome = await anonymous.request('/api/public/home');
|
||||
const publicRichNotice = refreshedHome.data.notices.find(item => item.title === '系统测试成绩发布通知');
|
||||
assert.ok(publicRichNotice, '管理员发布通知后首页应可见');
|
||||
assert.equal(publicRichNotice.contentHtml, publishNotice.data.notice.contentHtml, '公开接口应返回已清洗的富文本正文');
|
||||
|
||||
const resultTemplate = await admin.request(`/api/admin/excel/results?template=1&examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.equal(resultTemplate.response.status, 200, '成绩模板应支持按考试下载完整录分名单');
|
||||
const resultTemplateWorkbook = new ExcelJS.Workbook();
|
||||
await resultTemplateWorkbook.xlsx.load(resultTemplate.data);
|
||||
const resultTemplateSheet = resultTemplateWorkbook.getWorksheet('成绩');
|
||||
const templateHeaders = resultTemplateSheet.getRow(2).values;
|
||||
assert.ok(templateHeaders.includes('报名号*') && templateHeaders.includes('准考证号(只读参考)') && templateHeaders.includes('姓名(只读参考)'), '成绩模板应包含报名号、准考证号与姓名列');
|
||||
const templateCandidateNumberColumn = templateHeaders.indexOf('报名号*');
|
||||
const templateCandidateNameColumn = templateHeaders.indexOf('姓名(只读参考)');
|
||||
const templateRows = resultTemplateSheet.getRows(3, Math.max(0, resultTemplateSheet.rowCount - 2)) || [];
|
||||
const candidateTemplateRow = templateRows.find(row => row.getCell(templateCandidateNumberColumn).text === candidateNumber);
|
||||
assert.ok(candidateTemplateRow, '成绩模板应预填已通过报名的真实考生,而不是空白示例行');
|
||||
assert.ok(candidateTemplateRow.getCell(templateCandidateNameColumn).text, '成绩模板应预填考生姓名供核对');
|
||||
|
||||
const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }]));
|
||||
const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile });
|
||||
assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览');
|
||||
assert.equal(stagedPreview.data.preview, true);
|
||||
assert.equal(stagedPreview.data.summary.valid, 1);
|
||||
assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
||||
const unselectedResults = await admin.request('/api/admin/results');
|
||||
assert.deepEqual(unselectedResults.data.results, [], '未选择考试时不得读取成绩数据');
|
||||
assert.deepEqual(unselectedResults.data.registrations, [], '未选择考试时不得读取录分名单');
|
||||
assert.ok(!(await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
|
||||
const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } });
|
||||
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
|
||||
assert.equal(stagedCommit.data.count, 1);
|
||||
assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
|
||||
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } });
|
||||
assert.equal(publishResult.response.status, 200);
|
||||
assert.ok((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
|
||||
const bulkDraftResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: false, rows: [{ registrationId, score: 125.5 }] } });
|
||||
assert.equal(bulkDraftResult.response.status, 200, '名单式录分应支持按科目批量暂存');
|
||||
assert.equal(bulkDraftResult.data.published, false);
|
||||
const publishResult = await admin.request('/api/admin/results/bulk', { method: 'POST', body: { examId: exam.id, subjectId: exam.subjects[0].id, published: true, rows: [{ registrationId, score: 126 }] } });
|
||||
assert.equal(publishResult.response.status, 200, '名单式录分应支持按科目批量发布');
|
||||
assert.equal(publishResult.data.published, true);
|
||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
|
||||
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[2].id, score: 90, published: true } })).response.status, 200);
|
||||
const adminResults = await admin.request('/api/admin/results');
|
||||
const adminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.equal(adminResults.data.registrations.find(item => item.id === registrationId).featureScore, 0, '所有考试的特征分默认应为 0');
|
||||
const featureScoreUpdate = await admin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 87.5 }] } });
|
||||
assert.equal(featureScoreUpdate.response.status, 200, '超级管理员应能按考试名单批量登记特征分');
|
||||
assert.equal(featureScoreUpdate.data.count, 1);
|
||||
assert.equal((await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`)).data.registrations.find(item => item.id === registrationId).featureScore, 87.5, '批量保存后应立即回填特征分');
|
||||
assert.equal((await classAdmin.request(`/api/admin/registrations/${registrationId}/feature-score`, { method: 'PATCH', body: { featureScore: 10 } })).response.status, 403, '班级管理员不得登记特征分');
|
||||
assert.equal((await classAdmin.request('/api/admin/feature-scores/bulk', { method: 'POST', body: { examId: exam.id, rows: [{ registrationId, featureScore: 10 }] } })).response.status, 403, '班级管理员不得批量登记特征分');
|
||||
assert.equal(adminResults.data.resultCache.status, 'disabled', '未配置 REDIS_URL 时成绩管理接口应报告缓存未启用');
|
||||
assert.equal((await schoolAdmin.request('/api/admin/results/cache/refresh', { method: 'POST' })).response.status, 403, '仅超级管理员可以手动刷新成绩缓存');
|
||||
const cacheRefresh = await admin.request('/api/admin/results/cache/refresh', { method: 'POST' });
|
||||
@@ -843,11 +1107,45 @@ try {
|
||||
const results = await candidate.request('/api/candidate/results');
|
||||
assert.ok(results.data.results.some(item => item.score === 126 && item.subjectName === '语文'), '已发布成绩应在考生端可查询');
|
||||
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
|
||||
assert.match(resultSummary.verificationQr, /^data:image\/png;base64,/, '成绩单防伪信息应包含可直接扫描的二维码');
|
||||
assert.equal(resultSummary.featureScore, 87.5, '考生端整场成绩应单独显示特征分');
|
||||
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
|
||||
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
|
||||
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
|
||||
assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算');
|
||||
const classResults = await classAdmin.request('/api/admin/results');
|
||||
assert.equal((await admin.request(`/api/admin/admissions/${exam.id}/setting`, { method: 'PUT', body: { enabled: true, status: 'filling', maxChoices: 5, maxSubmissions: 1 } })).response.status, 200, '超级管理员应能开放志愿填报并限制提交次数');
|
||||
const unfilledPreferenceLedger = await admin.request('/api/admin/admissions');
|
||||
const unfilledPreferenceRow = unfilledPreferenceLedger.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
|
||||
assert.equal(unfilledPreferenceRow.status, 'unfilled', '实时志愿台账应包含尚未填报的合格考生,而不只是已提交志愿');
|
||||
const candidateAdmissions = await candidate.request('/api/candidate/admissions');
|
||||
const candidateAdmission = candidateAdmissions.data.admissions.find(item => item.examId === exam.id);
|
||||
assert.equal(candidateAdmission.indicatorQualification.payload.eligible, true, '考生页面应显示生源校确认的指标分配资格');
|
||||
assert.equal(candidateAdmission.maxSubmissions, 1, '考生页面应显示管理员设置的提交次数上限');
|
||||
const firstPreference = await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [
|
||||
{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'indicator' },
|
||||
{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }
|
||||
] } });
|
||||
assert.equal(firstPreference.response.status, 200, '有资格考生应能分别填报一个指标志愿和普通志愿');
|
||||
assert.equal(firstPreference.data.locked, true, '达到管理员设置的提交次数后应自动锁定');
|
||||
const lockedPreferenceView = await candidate.request('/api/candidate/admissions');
|
||||
const lockedChoice = lockedPreferenceView.data.admissions.find(item => item.examId === exam.id).preference.payload.choices[0];
|
||||
assert.equal(lockedChoice.schoolName, '海州市招生实验学校', '考生志愿锁定后应稳定返回学校名称,而不是只返回代码');
|
||||
assert.equal(lockedChoice.categoryName, '普通生', '考生志愿锁定后应稳定返回招生类别名称');
|
||||
const adminPreferenceView = await admin.request('/api/admin/admissions');
|
||||
const adminLockedChoice = adminPreferenceView.data.preferences.find(item => item.id === firstPreference.data.preference.id).choices[0];
|
||||
assert.equal(adminLockedChoice.schoolName, '海州市招生实验学校', '管理员志愿台账应显示完整学校名称');
|
||||
assert.equal(adminLockedChoice.categoryName, '普通生', '管理员志愿台账应显示招生类别名称而不是类别代码');
|
||||
const lockedSnapshot = adminPreferenceView.data.preferenceRows.find(item => item.examId === exam.id && item.candidate.registrationNumber === candidateNumber);
|
||||
assert.equal(lockedSnapshot.status, 'locked', '考生提交达到次数上限后,管理员实时台账应同步显示已锁定');
|
||||
const preferenceLedgerExport = await admin.request(`/api/admin/admissions/preferences/export?examId=${exam.id}&round=${lockedSnapshot.round}&status=locked&q=${encodeURIComponent(candidateNumber)}`);
|
||||
assert.equal(preferenceLedgerExport.response.status, 200, '超级管理员应能在填报期间按当前搜索和筛选条件导出志愿台账');
|
||||
const preferenceLedgerWorkbook = new ExcelJS.Workbook(); await preferenceLedgerWorkbook.xlsx.load(preferenceLedgerExport.data);
|
||||
const preferenceLedgerSheet = preferenceLedgerWorkbook.getWorksheet('志愿填报情况');
|
||||
assert.ok(preferenceLedgerSheet.getRow(2).values.includes('填报状态'), '志愿台账应包含填报状态列');
|
||||
assert.equal(preferenceLedgerSheet.rowCount, 4, '一名考生的两个志愿应导出为两条明细,并保留两行表头');
|
||||
assert.equal(preferenceLedgerSheet.getRow(3).values.includes('海州市招生实验学校'), true, '志愿台账应导出学校名称而不是只导出代码');
|
||||
assert.equal((await candidate.request(`/api/candidate/admissions/${exam.id}/preferences`, { method: 'PUT', body: { choices: [{ schoolId: structuredPlan.data.plan.schoolId, categoryCode: 'general', preferenceType: 'general' }] } })).response.status, 409, '超过填报次数后服务端必须拒绝继续修改');
|
||||
const classResults = await classAdmin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
|
||||
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
|
||||
|
||||
@@ -928,7 +1226,7 @@ try {
|
||||
assert.match(archivedPreview.data.rows[0].errors.join(''), /永久锁定/, '归档场次的 Excel 行必须标记为不可提交');
|
||||
assert.equal((await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: archivedPreview.data.rows } })).response.status, 400, '归档后 Excel 批量提交不得修改成绩');
|
||||
assert.equal((await candidate.request(`/api/candidate/results/${appealResults[0].id}/appeals`, { method: 'POST', body: { reason: '归档后再次申请复议' } })).response.status, 409, '归档后考生不得再发起成绩复议');
|
||||
const archivedAdminResults = await admin.request('/api/admin/results');
|
||||
const archivedAdminResults = await admin.request(`/api/admin/results?examId=${encodeURIComponent(exam.id)}`);
|
||||
assert.ok(archivedAdminResults.data.exams.find(item => item.id === exam.id)?.archivedAt, '管理端成绩中心应返回归档状态');
|
||||
const archivedCandidateResults = await candidate.request('/api/candidate/results');
|
||||
assert.ok(archivedCandidateResults.data.results.filter(item => item.examId === exam.id).every(item => item.archivedAt), '考生历史成绩应标记为归档并进入折叠区');
|
||||
@@ -961,8 +1259,11 @@ try {
|
||||
console.log('✓ 成绩录入、发布与考生查询');
|
||||
console.log('✓ 成绩复议、班级审批、范围匹配与多人均分');
|
||||
} finally {
|
||||
server.kill('SIGTERM');
|
||||
await new Promise(resolveWait => server.once('exit', resolveWait));
|
||||
if (server.exitCode == null && server.signalCode == null) {
|
||||
const serverExit = new Promise(resolveWait => server.once('exit', resolveWait));
|
||||
server.kill('SIGTERM');
|
||||
await serverExit;
|
||||
}
|
||||
await rm(testDb, { force: true });
|
||||
await rm(`${testDb}-shm`, { force: true });
|
||||
await rm(`${testDb}-wal`, { force: true });
|
||||
|
||||
Reference in New Issue
Block a user